Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2f6842fdf4 | ||
|
|
282dbe3167 | ||
|
|
6d749b6e2c |
No files matched your search
@@ -1,5 +1,4 @@
|
|||||||
node_modules
|
node_modules
|
||||||
**/node_modules/
|
|
||||||
.env
|
.env
|
||||||
docker-compose.extra.yml
|
docker-compose.extra.yml
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
[submodule "Peekaboo"]
|
||||||
|
path = Peekaboo
|
||||||
|
url = https://github.com/steipete/Peekaboo.git
|
||||||
|
branch = main
|
||||||
@@ -1 +0,0 @@
|
|||||||
src/canvas-host/a2ui/a2ui.bundle.js
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
- Repo: https://github.com/clawdbot/clawdbot
|
- Repo: https://github.com/clawdbot/clawdbot
|
||||||
- GitHub issues/comments/PR comments: use literal multiline strings or `-F - <<'EOF'` (or $'...') for real newlines; never embed "\\n".
|
- GitHub issues: use literal multiline strings or $'...' for newlines; avoid "\\n" escapes in `gh issue create/edit`.
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Structure & Module Organization
|
||||||
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
- Source code: `src/` (CLI wiring in `src/cli`, commands in `src/commands`, web provider in `src/provider-web.ts`, infra in `src/infra`, media pipeline in `src/media`).
|
||||||
@@ -8,10 +8,6 @@
|
|||||||
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
- Docs: `docs/` (images, queue, Pi config). Built output lives in `dist/`.
|
||||||
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
- Plugins/extensions: live under `extensions/*` (workspace packages). Keep plugin-only deps in the extension `package.json`; do not add them to the root `package.json` unless core uses them.
|
||||||
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
- Installers served from `https://clawd.bot/*`: live in the sibling repo `../clawd.bot` (`public/install.sh`, `public/install-cli.sh`, `public/install.ps1`).
|
||||||
- Messaging channels: always consider **all** built-in + extension channels when refactoring shared logic (routing, allowlists, pairing, command gating, onboarding, docs).
|
|
||||||
- Core channel docs: `docs/channels/`
|
|
||||||
- Core channel code: `src/telegram`, `src/discord`, `src/slack`, `src/signal`, `src/imessage`, `src/web` (WhatsApp web), `src/channels`, `src/routing`
|
|
||||||
- Extensions (channel plugins): `extensions/*` (e.g. `extensions/msteams`, `extensions/matrix`, `extensions/zalo`, `extensions/zalouser`, `extensions/voice-call`)
|
|
||||||
|
|
||||||
## Docs Linking (Mintlify)
|
## Docs Linking (Mintlify)
|
||||||
- Docs are hosted on Mintlify (docs.clawd.bot).
|
- Docs are hosted on Mintlify (docs.clawd.bot).
|
||||||
@@ -66,7 +62,7 @@
|
|||||||
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
- After merging a PR: run `bun scripts/update-clawtributors.ts` if the contributor is missing, then commit the regenerated README.
|
||||||
|
|
||||||
## Shorthand Commands
|
## Shorthand Commands
|
||||||
- `sync`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
- `sync up`: if working tree is dirty, commit all changes (pick a sensible Conventional Commit message), then `git pull --rebase`; if rebase conflicts and cannot resolve, stop; otherwise `git push`.
|
||||||
|
|
||||||
### PR Workflow (Review vs Land)
|
### PR Workflow (Review vs Land)
|
||||||
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
- **Review mode (PR link only):** read `gh pr view/diff`; **do not** switch branches; **do not** change code.
|
||||||
@@ -84,12 +80,10 @@
|
|||||||
|
|
||||||
## Agent-Specific Notes
|
## Agent-Specific Notes
|
||||||
- Vocabulary: "makeup" = "mac app".
|
- Vocabulary: "makeup" = "mac app".
|
||||||
- Never edit `node_modules` (global/Homebrew/npm/git installs too). Updates overwrite. Skill notes go in `tools.md` or `AGENTS.md`.
|
|
||||||
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
- When working on a GitHub Issue or PR, print the full URL at the end of the task.
|
||||||
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
- When answering questions, respond with high-confidence answers only: verify in code; do not guess.
|
||||||
- Never update the Carbon dependency.
|
- Never update the Carbon dependency.
|
||||||
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
- Any dependency with `pnpm.patchedDependencies` must use an exact version (no `^`/`~`).
|
||||||
- Patching dependencies (pnpm patches, overrides, or vendored changes) requires explicit approval; do not do this by default.
|
|
||||||
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
- CLI progress: use `src/cli/progress.ts` (`osc-progress` + `@clack/prompts` spinner); don’t hand-roll spinners/bars.
|
||||||
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
- Status output: keep tables + ANSI-safe wrapping (`src/terminal/table.ts`); `status --all` = read-only/pasteable, `status --deep` = probes.
|
||||||
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
- Gateway currently runs only as the menubar app; there is no separate LaunchAgent/helper label installed. Restart via the Clawdbot Mac app or `scripts/restart-mac.sh`; to verify/kill use `launchctl print gui/$UID | grep clawdbot` rather than assuming a fixed label. **When debugging on macOS, start/stop the gateway via the app, not ad-hoc tmux sessions; kill any temporary tunnels before handoff.**
|
||||||
@@ -116,7 +110,8 @@
|
|||||||
- 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.
|
- 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/<agentId>/sessions/*.jsonl` (use the `agent=<id>` value in the Runtime line of the system prompt; 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.
|
||||||
- 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.
|
||||||
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
- Never send streaming/partial replies to external messaging surfaces (WhatsApp, Telegram); only final replies should be delivered there. Streaming/tool events may still go to internal UIs/control channel.
|
||||||
- Voice wake forwarding tips:
|
- Voice wake forwarding tips:
|
||||||
@@ -132,3 +127,19 @@
|
|||||||
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
- Publish: `npm publish --access public --otp="<otp>"` (run from the package dir).
|
||||||
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
- Verify without local npmrc side effects: `npm view <pkg> version --userconfig "$(mktemp)"`.
|
||||||
- Kill the tmux session after publish.
|
- Kill the tmux session after publish.
|
||||||
|
|
||||||
|
## Exclamation Mark Escaping Workaround
|
||||||
|
The Claude Code Bash tool escapes `!` to `\\!` in command arguments. When using `clawdbot message send` with messages containing exclamation marks, use heredoc syntax:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# WRONG - will send "Hello\\!" with backslash
|
||||||
|
clawdbot message send --to "+1234" --message 'Hello!'
|
||||||
|
|
||||||
|
# CORRECT - use heredoc to avoid escaping
|
||||||
|
clawdbot message send --to "+1234" --message "$(cat <<'EOF'
|
||||||
|
Hello!
|
||||||
|
EOF
|
||||||
|
)"
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a Claude Code quirk, not a clawdbot bug.
|
||||||
+1
-97
@@ -2,70 +2,6 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.19-1
|
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Reject invalid/unknown config entries and refuse to start the gateway for safety; run `clawdbot doctor --fix` to repair.
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Usage: add `/usage cost` summaries and macOS menu cost submenu with daily charting.
|
|
||||||
- Agents: clarify node_modules read-only guidance in agent instructions.
|
|
||||||
- TUI: add syntax highlighting for code blocks. (#1200) — thanks @vignesh07.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- UI: enable shell mode for sync Windows spawns to avoid `pnpm ui:build` EINVAL. (#1212) — thanks @longmaba.
|
|
||||||
- Plugins: surface plugin load/register/config errors in gateway logs with plugin/source context.
|
|
||||||
- Agents: propagate accountId into embedded runs so sub-agent announce routing honors the originating account. (#1058)
|
|
||||||
- Compaction: include tool failure summaries in safeguard compaction to prevent retry loops. (#1084)
|
|
||||||
- TUI: show generic empty-state text for searchable pickers. (#1201) — thanks @vignesh07.
|
|
||||||
- Doctor: canonicalize legacy session keys in session stores to prevent stale metadata. (#1169)
|
|
||||||
- CLI: centralize CLI command registration to keep fast-path routing and program wiring in sync. (#1207) — thanks @gumadeiras.
|
|
||||||
- Voice call: keep Twilio notify callbacks out of streaming paths, normalize Tailscale serve paths, and honor ASCII signature ordering. (#1213) — thanks @andrew-kurin.
|
|
||||||
|
|
||||||
## 2026.1.18-5
|
|
||||||
|
|
||||||
### Changes
|
|
||||||
- Dependencies: update core + plugin deps (grammy, vitest, openai, Microsoft agents hosting, etc.).
|
|
||||||
- Onboarding: add allowlist prompts and username-to-id resolution across core and extension channels.
|
|
||||||
- TUI: add searchable model picker for quicker model selection. (#1198) — thanks @vignesh07.
|
|
||||||
- Docs: clarify allowlist input types and onboarding behavior for messaging channels.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Configure: hide OpenRouter auto routing model from the model picker. (#1182) — thanks @zerone0x.
|
|
||||||
- Docs: make docs:list fail fast with a clear error if the docs directory is missing.
|
|
||||||
- macOS: load menu session previews asynchronously so items populate while the menu is open.
|
|
||||||
- macOS: use label colors for session preview text so previews render in menu subviews.
|
|
||||||
- macOS: suppress usage error text in the menubar cost view.
|
|
||||||
- Telegram: honor pairing allowlists for native slash commands.
|
|
||||||
- TUI: highlight model search matches and stabilize search ordering.
|
|
||||||
- CLI: keep banners on routed commands, restore config guarding outside fast-path routing, and tighten fast-path flag parsing while skipping console capture for extra speed. (#1195) — thanks @gumadeiras.
|
|
||||||
- Slack: resolve Bolt import interop for Bun + Node. (#1191) — thanks @CoreyH.
|
|
||||||
- Gateway: require authorized restarts for SIGUSR1 (restart/apply/update) so config gating can't be bypassed.
|
|
||||||
- Discord: stop reconnecting the gateway after aborts to prevent duplicate listeners.
|
|
||||||
|
|
||||||
## 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.
|
|
||||||
- Plugins: auto-enable bundled channel/provider plugins when configuration is present.
|
|
||||||
- Config: stamp last-touched metadata on write and warn if the config is newer than the running build.
|
|
||||||
- macOS: hide usage section when usage is unavailable instead of showing provider errors.
|
|
||||||
- Memory: add native Gemini embeddings provider for memory search. (#1151)
|
|
||||||
- Agents: add local docs path resolution and include docs/mirror/source/community pointers in the system prompt.
|
|
||||||
- Slack: add HTTP webhook mode via Bolt HTTP receiver for Events API deployments. (#1143) — thanks @jdrhyne.
|
|
||||||
|
|
||||||
### Fixes
|
|
||||||
- Auth profiles: keep auto-pinned preference while allowing rotation on failover; user pins stay locked. (#1138) — thanks @cheeeee.
|
|
||||||
- Agents: sanitize oversized image payloads before send and surface image-dimension errors.
|
|
||||||
- macOS: Doctor repairs LaunchAgent bootstrap issues for Gateway + Node when listed but not loaded. (#1166) — thanks @AlexMikhalev.
|
|
||||||
- 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)
|
|
||||||
- Memory: avoid sqlite-vec unique constraint failures when reindexing duplicate chunk ids. (#1151)
|
|
||||||
|
|
||||||
## 2026.1.18-3
|
## 2026.1.18-3
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
@@ -73,52 +9,20 @@ Docs: https://docs.clawd.bot
|
|||||||
- Exec: add `/exec` directive for per-session exec defaults (host/security/ask/node).
|
- 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: 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.
|
- 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.
|
- 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.
|
- 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: 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.
|
|
||||||
- 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
|
|
||||||
- macOS: add exec-host IPC for node service `system.run` with HMAC + peer UID checks.
|
|
||||||
|
|
||||||
### Fixes
|
### 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.
|
- 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.
|
- 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.
|
- Tests: clean up gateway env stubs and assert config.apply sentinel writes. (#1148) — thanks @TSavo.
|
||||||
|
|
||||||
## 2026.1.18-2
|
## 2026.1.18-2
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
|
||||||
|
|
||||||
## 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-6
|
## 2026.1.17-6
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
|||||||
Submodule
+1
Submodule Peekaboo added at 5c195f5e46.
@@ -11,13 +11,12 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
<a href="https://github.com/clawdbot/clawdbot/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdbot/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||||
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
<a href="https://github.com/clawdbot/clawdbot/releases"><img src="https://img.shields.io/github/v/release/clawdbot/clawdbot?include_prereleases&style=for-the-badge" alt="GitHub release"></a>
|
||||||
<a href="https://deepwiki.com/clawdbot/clawdbot"><img src="https://img.shields.io/badge/DeepWiki-clawdbot-111111?style=for-the-badge" alt="DeepWiki"></a>
|
|
||||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
**Clawdbot** is a *personal AI assistant* you run on your own devices.
|
||||||
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), plus extension channels like BlueBubbles, Matrix, Zalo, and Zalo Personal. It can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
It answers you on the channels you already use (WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat), can speak and listen on macOS/iOS/Android, and can render a live Canvas you control. The Gateway is just the control plane — the product is the assistant.
|
||||||
|
|
||||||
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
If you want a personal, single-user assistant that feels local, fast, and always-on, this is it.
|
||||||
|
|
||||||
@@ -65,7 +64,7 @@ clawdbot gateway --port 18789 --verbose
|
|||||||
# Send a message
|
# Send a message
|
||||||
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
clawdbot message send --to +1234567890 --message "Hello from Clawdbot"
|
||||||
|
|
||||||
# Talk to the assistant (optionally deliver back to any connected channel: WhatsApp/Telegram/Slack/Discord/Signal/iMessage/BlueBubbles/Microsoft Teams/Matrix/Zalo/Zalo Personal/WebChat)
|
# Talk to the assistant (optionally deliver back to WhatsApp/Telegram/Slack/Discord/Microsoft Teams)
|
||||||
clawdbot agent --message "Ship checklist" --thinking high
|
clawdbot agent --message "Ship checklist" --thinking high
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -107,7 +106,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## Highlights
|
## Highlights
|
||||||
|
|
||||||
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
- **[Local-first Gateway](https://docs.clawd.bot/gateway)** — single control plane for sessions, channels, tools, and events.
|
||||||
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, BlueBubbles, Microsoft Teams, Matrix, Zalo, Zalo Personal, WebChat, macOS, iOS/Android.
|
- **[Multi-channel inbox](https://docs.clawd.bot/channels)** — WhatsApp, Telegram, Slack, Discord, Signal, iMessage, Microsoft Teams, WebChat, macOS, iOS/Android.
|
||||||
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
- **[Multi-agent routing](https://docs.clawd.bot/gateway/configuration)** — route inbound channels/accounts/peers to isolated agents (workspaces + per-agent sessions).
|
||||||
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
- **[Voice Wake](https://docs.clawd.bot/nodes/voicewake) + [Talk Mode](https://docs.clawd.bot/nodes/talk)** — always-on speech for macOS/iOS/Android with ElevenLabs.
|
||||||
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
- **[Live Canvas](https://docs.clawd.bot/platforms/mac/canvas)** — agent-driven visual workspace with [A2UI](https://docs.clawd.bot/platforms/mac/canvas#canvas-a2ui).
|
||||||
@@ -129,7 +128,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
- [Media pipeline](https://docs.clawd.bot/nodes/images): images/audio/video, transcription hooks, size caps, temp file lifecycle. Audio details: [Audio](https://docs.clawd.bot/nodes/audio).
|
||||||
|
|
||||||
### Channels
|
### Channels
|
||||||
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [BlueBubbles](https://docs.clawd.bot/channels/bluebubbles) (extension), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (extension), [Matrix](https://docs.clawd.bot/channels/matrix) (extension), [Zalo](https://docs.clawd.bot/channels/zalo) (extension), [Zalo Personal](https://docs.clawd.bot/channels/zalouser) (extension), [WebChat](https://docs.clawd.bot/web/webchat).
|
- [Channels](https://docs.clawd.bot/channels): [WhatsApp](https://docs.clawd.bot/channels/whatsapp) (Baileys), [Telegram](https://docs.clawd.bot/channels/telegram) (grammY), [Slack](https://docs.clawd.bot/channels/slack) (Bolt), [Discord](https://docs.clawd.bot/channels/discord) (discord.js), [Signal](https://docs.clawd.bot/channels/signal) (signal-cli), [iMessage](https://docs.clawd.bot/channels/imessage) (imsg), [Microsoft Teams](https://docs.clawd.bot/channels/msteams) (Bot Framework), [WebChat](https://docs.clawd.bot/web/webchat).
|
||||||
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
- [Group routing](https://docs.clawd.bot/concepts/group-messages): mention gating, reply tags, per-channel chunking and routing. Channel rules: [Channels](https://docs.clawd.bot/channels).
|
||||||
|
|
||||||
### Apps + nodes
|
### Apps + nodes
|
||||||
@@ -160,7 +159,7 @@ Run `clawdbot doctor` to surface risky/misconfigured DM policies.
|
|||||||
## How it works (short)
|
## How it works (short)
|
||||||
|
|
||||||
```
|
```
|
||||||
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / BlueBubbles / Microsoft Teams / Matrix / Zalo / Zalo Personal / WebChat
|
WhatsApp / Telegram / Slack / Discord / Signal / iMessage / Microsoft Teams / WebChat
|
||||||
│
|
│
|
||||||
▼
|
▼
|
||||||
┌───────────────────────────────┐
|
┌───────────────────────────────┐
|
||||||
@@ -475,25 +474,25 @@ 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/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a>
|
<a href="https://github.com/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/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a>
|
<a href="https://github.com/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/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/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/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a>
|
<a href="https://github.com/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/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a> <a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a>
|
<a href="https://github.com/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/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a> <a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a>
|
<a href="https://github.com/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/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a> <a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a>
|
<a href="https://github.com/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/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a> <a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a>
|
<a href="https://github.com/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/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a> <a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a>
|
<a href="https://github.com/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/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/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/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
<a href="https://github.com/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/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a>
|
<a href="https://github.com/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/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a>
|
<a href="https://github.com/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/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a>
|
<a href="https://github.com/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/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a>
|
<a href="https://github.com/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/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a>
|
<a href="https://github.com/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/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
<a href="https://github.com/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/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a>
|
<a href="https://github.com/conhecendocontato"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendocontato" title="conhecendocontato"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a>
|
||||||
<a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/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/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/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
<a href="https://github.com/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/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
<a href="https://github.com/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=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
<a href="https://github.com/search?q=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=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>
|
<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>
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"version" : "0.1.0"
|
"version" : "0.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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: [
|
||||||
|
|||||||
+74
-29
@@ -1,33 +1,24 @@
|
|||||||
{
|
{
|
||||||
"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",
|
||||||
"location" : "https://github.com/steipete/ElevenLabsKit",
|
"location" : "https://github.com/steipete/ElevenLabsKit",
|
||||||
"state" : {
|
"state" : {
|
||||||
"revision" : "7e3c948d8340abe3977014f3de020edf221e9269",
|
"revision" : "c8679fbd37416a8780fe43be88a497ff16209e2d",
|
||||||
"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" : "bace59f90bb276f1c6fb613acfda3935ec4a7a90"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
# Clawdbot macOS app (dev + signing)
|
|
||||||
|
|
||||||
## Quick dev run
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# from repo root
|
|
||||||
scripts/restart-mac.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Options:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/restart-mac.sh --no-sign # fastest dev; ad-hoc signing (TCC permissions do not stick)
|
|
||||||
scripts/restart-mac.sh --sign # force code signing (requires cert)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Packaging flow
|
|
||||||
|
|
||||||
```bash
|
|
||||||
scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
Creates `dist/Clawdbot.app` and signs it via `scripts/codesign-mac-app.sh`.
|
|
||||||
|
|
||||||
## Signing behavior
|
|
||||||
|
|
||||||
Auto-selects identity (first match):
|
|
||||||
1) Developer ID Application
|
|
||||||
2) Apple Distribution
|
|
||||||
3) Apple Development
|
|
||||||
4) first available identity
|
|
||||||
|
|
||||||
If none found:
|
|
||||||
- errors by default
|
|
||||||
- set `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` to ad-hoc sign
|
|
||||||
|
|
||||||
## Team ID audit (Sparkle mismatch guard)
|
|
||||||
|
|
||||||
After signing, we read the app bundle Team ID and compare every Mach-O inside the app.
|
|
||||||
If any embedded binary has a different Team ID, signing fails.
|
|
||||||
|
|
||||||
Skip the audit:
|
|
||||||
```bash
|
|
||||||
SKIP_TEAM_ID_CHECK=1 scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
## Library validation workaround (dev only)
|
|
||||||
|
|
||||||
If Sparkle Team ID mismatch blocks loading (common with Apple Development certs), opt in:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
This adds `com.apple.security.cs.disable-library-validation` to app entitlements.
|
|
||||||
Use for local dev only; keep off for release builds.
|
|
||||||
|
|
||||||
## Useful env flags
|
|
||||||
|
|
||||||
- `SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"`
|
|
||||||
- `ALLOW_ADHOC_SIGNING=1` (ad-hoc, TCC permissions do not persist)
|
|
||||||
- `CODESIGN_TIMESTAMP=off` (offline debug)
|
|
||||||
- `DISABLE_LIBRARY_VALIDATION=1` (dev-only Sparkle workaround)
|
|
||||||
- `SKIP_TEAM_ID_CHECK=1` (bypass audit)
|
|
||||||
@@ -8,8 +8,6 @@ struct BridgeNodeInfo: Sendable {
|
|||||||
var displayName: String?
|
var displayName: String?
|
||||||
var platform: String?
|
var platform: String?
|
||||||
var version: String?
|
var version: String?
|
||||||
var coreVersion: String?
|
|
||||||
var uiVersion: String?
|
|
||||||
var deviceFamily: String?
|
var deviceFamily: String?
|
||||||
var modelIdentifier: String?
|
var modelIdentifier: String?
|
||||||
var remoteAddress: String?
|
var remoteAddress: String?
|
||||||
@@ -149,8 +147,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
coreVersion: hello.coreVersion,
|
|
||||||
uiVersion: hello.uiVersion,
|
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
remoteAddress: self.remoteAddressString(),
|
remoteAddress: self.remoteAddressString(),
|
||||||
@@ -175,8 +171,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: req.displayName,
|
displayName: req.displayName,
|
||||||
platform: req.platform,
|
platform: req.platform,
|
||||||
version: req.version,
|
version: req.version,
|
||||||
coreVersion: req.coreVersion,
|
|
||||||
uiVersion: req.uiVersion,
|
|
||||||
deviceFamily: req.deviceFamily,
|
deviceFamily: req.deviceFamily,
|
||||||
modelIdentifier: req.modelIdentifier,
|
modelIdentifier: req.modelIdentifier,
|
||||||
caps: req.caps,
|
caps: req.caps,
|
||||||
@@ -192,8 +186,6 @@ actor BridgeConnectionHandler {
|
|||||||
displayName: enriched.displayName,
|
displayName: enriched.displayName,
|
||||||
platform: enriched.platform,
|
platform: enriched.platform,
|
||||||
version: enriched.version,
|
version: enriched.version,
|
||||||
coreVersion: enriched.coreVersion,
|
|
||||||
uiVersion: enriched.uiVersion,
|
|
||||||
deviceFamily: enriched.deviceFamily,
|
deviceFamily: enriched.deviceFamily,
|
||||||
modelIdentifier: enriched.modelIdentifier,
|
modelIdentifier: enriched.modelIdentifier,
|
||||||
remoteAddress: enriched.remoteAddress,
|
remoteAddress: enriched.remoteAddress,
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ final class ConnectionModeCoordinator {
|
|||||||
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
func apply(mode: AppState.ConnectionMode, paused: Bool) async {
|
||||||
switch mode {
|
switch mode {
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
if let error = await NodeServiceManager.stop() {
|
|
||||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
|
||||||
}
|
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
GatewayProcessManager.shared.stop()
|
GatewayProcessManager.shared.stop()
|
||||||
@@ -23,9 +20,6 @@ final class ConnectionModeCoordinator {
|
|||||||
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
Task.detached { await PortGuardian.shared.sweep(mode: .unconfigured) }
|
||||||
|
|
||||||
case .local:
|
case .local:
|
||||||
if let error = await NodeServiceManager.stop() {
|
|
||||||
NodesStore.shared.lastError = "Node service stop failed: \(error)"
|
|
||||||
}
|
|
||||||
await RemoteTunnelManager.shared.stopAll()
|
await RemoteTunnelManager.shared.stopAll()
|
||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
let shouldStart = GatewayAutostartPolicy.shouldStartGateway(mode: .local, paused: paused)
|
||||||
@@ -56,9 +50,6 @@ final class ConnectionModeCoordinator {
|
|||||||
WebChatManager.shared.resetTunnels()
|
WebChatManager.shared.resetTunnels()
|
||||||
|
|
||||||
do {
|
do {
|
||||||
if let error = await NodeServiceManager.start() {
|
|
||||||
NodesStore.shared.lastError = "Node service start failed: \(error)"
|
|
||||||
}
|
|
||||||
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
_ = try await GatewayEndpointStore.shared.ensureRemoteControlTunnel()
|
||||||
let settings = CommandResolver.connectionSettings()
|
let settings = CommandResolver.connectionSettings()
|
||||||
try await ControlChannel.shared.configure(mode: .remote(
|
try await ControlChannel.shared.configure(mode: .remote(
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
import Charts
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct CostUsageHistoryMenuView: View {
|
|
||||||
let summary: GatewayCostUsageSummary
|
|
||||||
let width: CGFloat
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
|
||||||
self.header
|
|
||||||
self.chart
|
|
||||||
self.footer
|
|
||||||
}
|
|
||||||
.padding(.horizontal, 12)
|
|
||||||
.padding(.vertical, 10)
|
|
||||||
.frame(width: max(1, self.width), alignment: .leading)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var header: some View {
|
|
||||||
let todayKey = CostUsageMenuDateParser.format(Date())
|
|
||||||
let todayEntry = self.summary.daily.first { $0.date == todayKey }
|
|
||||||
let todayCost = CostUsageFormatting.formatUsd(todayEntry?.totalCost) ?? "n/a"
|
|
||||||
let totalCost = CostUsageFormatting.formatUsd(self.summary.totals.totalCost) ?? "n/a"
|
|
||||||
|
|
||||||
return HStack(alignment: .firstTextBaseline, spacing: 12) {
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Today")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(todayCost)
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
}
|
|
||||||
VStack(alignment: .leading, spacing: 2) {
|
|
||||||
Text("Last \(self.summary.days)d")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
Text(totalCost)
|
|
||||||
.font(.system(size: 14, weight: .semibold))
|
|
||||||
}
|
|
||||||
Spacer()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var chart: some View {
|
|
||||||
let entries = self.summary.daily.compactMap { entry -> (Date, Double)? in
|
|
||||||
guard let date = CostUsageMenuDateParser.parse(entry.date) else { return nil }
|
|
||||||
return (date, entry.totalCost)
|
|
||||||
}
|
|
||||||
|
|
||||||
return Chart(entries, id: \.0) { entry in
|
|
||||||
BarMark(
|
|
||||||
x: .value("Day", entry.0),
|
|
||||||
y: .value("Cost", entry.1))
|
|
||||||
.foregroundStyle(Color.accentColor)
|
|
||||||
.cornerRadius(3)
|
|
||||||
}
|
|
||||||
.chartXAxis {
|
|
||||||
AxisMarks(values: .stride(by: .day, count: 7)) {
|
|
||||||
AxisGridLine().foregroundStyle(.clear)
|
|
||||||
AxisValueLabel(format: .dateTime.month().day())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.chartYAxis {
|
|
||||||
AxisMarks(position: .leading) {
|
|
||||||
AxisGridLine()
|
|
||||||
AxisValueLabel()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.frame(height: 110)
|
|
||||||
}
|
|
||||||
|
|
||||||
private var footer: some View {
|
|
||||||
if self.summary.totals.missingCostEntries == 0 {
|
|
||||||
return AnyView(EmptyView())
|
|
||||||
}
|
|
||||||
return AnyView(
|
|
||||||
Text("Partial: \(self.summary.totals.missingCostEntries) entries missing cost")
|
|
||||||
.font(.caption2)
|
|
||||||
.foregroundStyle(.secondary))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private enum CostUsageMenuDateParser {
|
|
||||||
static let formatter: DateFormatter = {
|
|
||||||
let formatter = DateFormatter()
|
|
||||||
formatter.dateFormat = "yyyy-MM-dd"
|
|
||||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
|
||||||
formatter.timeZone = TimeZone.current
|
|
||||||
return formatter
|
|
||||||
}()
|
|
||||||
|
|
||||||
static func parse(_ value: String) -> Date? {
|
|
||||||
self.formatter.date(from: value)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func format(_ date: Date) -> String {
|
|
||||||
self.formatter.string(from: date)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import CryptoKit
|
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
import Security
|
import Security
|
||||||
@@ -122,13 +121,6 @@ struct ExecApprovalsFile: Codable {
|
|||||||
var agents: [String: ExecApprovalsAgent]?
|
var agents: [String: ExecApprovalsAgent]?
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ExecApprovalsSnapshot: Codable {
|
|
||||||
var path: String
|
|
||||||
var exists: Bool
|
|
||||||
var hash: String
|
|
||||||
var file: ExecApprovalsFile
|
|
||||||
}
|
|
||||||
|
|
||||||
struct ExecApprovalsResolved {
|
struct ExecApprovalsResolved {
|
||||||
let url: URL
|
let url: URL
|
||||||
let socketPath: String
|
let socketPath: String
|
||||||
@@ -161,58 +153,6 @@ enum ExecApprovalsStore {
|
|||||||
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
|
||||||
}
|
}
|
||||||
|
|
||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: 1,
|
|
||||||
socket: ExecApprovalsSocketConfig(
|
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
|
||||||
token: token.isEmpty ? nil : token),
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
|
||||||
let url = self.fileURL()
|
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
|
||||||
return ExecApprovalsSnapshot(
|
|
||||||
path: url.path,
|
|
||||||
exists: false,
|
|
||||||
hash: self.hashRaw(nil),
|
|
||||||
file: ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:]))
|
|
||||||
}
|
|
||||||
let raw = try? String(contentsOf: url, encoding: .utf8)
|
|
||||||
let data = raw.flatMap { $0.data(using: .utf8) }
|
|
||||||
let decoded: ExecApprovalsFile = {
|
|
||||||
if let data, let file = try? JSONDecoder().decode(ExecApprovalsFile.self, from: data), file.version == 1 {
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
|
||||||
}()
|
|
||||||
return ExecApprovalsSnapshot(
|
|
||||||
path: url.path,
|
|
||||||
exists: true,
|
|
||||||
hash: self.hashRaw(raw),
|
|
||||||
file: decoded)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func redactForSnapshot(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if socketPath.isEmpty {
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: file.version,
|
|
||||||
socket: nil,
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(
|
|
||||||
version: file.version,
|
|
||||||
socket: ExecApprovalsSocketConfig(path: socketPath, token: nil),
|
|
||||||
defaults: file.defaults,
|
|
||||||
agents: file.agents)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func loadFile() -> ExecApprovalsFile {
|
static func loadFile() -> ExecApprovalsFile {
|
||||||
let url = self.fileURL()
|
let url = self.fileURL()
|
||||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||||
@@ -222,7 +162,7 @@ enum ExecApprovalsStore {
|
|||||||
let data = try Data(contentsOf: url)
|
let data = try Data(contentsOf: url)
|
||||||
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
|
||||||
if decoded.version != 1 {
|
if decoded.version != 1 {
|
||||||
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
|
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
|
||||||
}
|
}
|
||||||
return decoded
|
return decoded
|
||||||
} catch {
|
} catch {
|
||||||
@@ -264,7 +204,7 @@ enum ExecApprovalsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
static func resolve(agentId: String?) -> ExecApprovalsResolved {
|
||||||
let file = self.ensureFile()
|
var file = self.ensureFile()
|
||||||
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
let defaults = file.defaults ?? ExecApprovalsDefaults()
|
||||||
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
let resolvedDefaults = ExecApprovalsResolvedDefaults(
|
||||||
security: defaults.security ?? self.defaultSecurity,
|
security: defaults.security ?? self.defaultSecurity,
|
||||||
@@ -432,12 +372,6 @@ enum ExecApprovalsStore {
|
|||||||
return UUID().uuidString
|
return UUID().uuidString
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func hashRaw(_ raw: String?) -> String {
|
|
||||||
let data = Data((raw ?? "").utf8)
|
|
||||||
let digest = SHA256.hash(data: data)
|
|
||||||
return digest.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func expandPath(_ raw: String) -> String {
|
private static func expandPath(_ raw: String) -> String {
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed == "~" {
|
if trimmed == "~" {
|
||||||
@@ -463,32 +397,11 @@ struct ExecCommandResolution: Sendable {
|
|||||||
let executableName: String
|
let executableName: String
|
||||||
let cwd: 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? {
|
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
|
||||||
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return self.resolveExecutable(rawExecutable: raw, cwd: cwd, env: env)
|
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
|
||||||
}
|
|
||||||
|
|
||||||
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 hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
|
||||||
let resolvedPath: String? = {
|
let resolvedPath: String? = {
|
||||||
if hasPathSeparator {
|
if hasPathSeparator {
|
||||||
@@ -506,20 +419,6 @@ struct ExecCommandResolution: Sendable {
|
|||||||
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
|
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] {
|
private static func searchPaths(from env: [String: String]?) -> [String] {
|
||||||
let raw = env?["PATH"]
|
let raw = env?["PATH"]
|
||||||
if let raw, !raw.isEmpty {
|
if let raw, !raw.isEmpty {
|
||||||
@@ -540,12 +439,6 @@ enum ExecCommandFormatter {
|
|||||||
return "\"\(escaped)\""
|
return "\"\(escaped)\""
|
||||||
}.joined(separator: " ")
|
}.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 {
|
enum ExecAllowlistMatcher {
|
||||||
@@ -629,7 +522,7 @@ struct ExecEventPayload: Codable, Sendable {
|
|||||||
guard !trimmed.isEmpty else { return nil }
|
guard !trimmed.isEmpty else { return nil }
|
||||||
if trimmed.count <= maxChars { return trimmed }
|
if trimmed.count <= maxChars { return trimmed }
|
||||||
let suffix = trimmed.suffix(maxChars)
|
let suffix = trimmed.suffix(maxChars)
|
||||||
return "... (truncated) \(suffix)"
|
return "… (truncated) \(suffix)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import AppKit
|
import AppKit
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
import CryptoKit
|
|
||||||
import Darwin
|
import Darwin
|
||||||
import Foundation
|
import Foundation
|
||||||
import OSLog
|
import OSLog
|
||||||
@@ -28,49 +27,6 @@ private struct ExecApprovalSocketDecision: Codable {
|
|||||||
var decision: ExecApprovalDecision
|
var decision: ExecApprovalDecision
|
||||||
}
|
}
|
||||||
|
|
||||||
fileprivate struct ExecHostSocketRequest: Codable {
|
|
||||||
var type: String
|
|
||||||
var id: String
|
|
||||||
var nonce: String
|
|
||||||
var ts: Int
|
|
||||||
var hmac: String
|
|
||||||
var requestJson: String
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostRequest: Codable {
|
|
||||||
var command: [String]
|
|
||||||
var rawCommand: String?
|
|
||||||
var cwd: String?
|
|
||||||
var env: [String: String]?
|
|
||||||
var timeoutMs: Int?
|
|
||||||
var needsScreenRecording: Bool?
|
|
||||||
var agentId: String?
|
|
||||||
var sessionKey: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostRunResult: Codable {
|
|
||||||
var exitCode: Int?
|
|
||||||
var timedOut: Bool
|
|
||||||
var success: Bool
|
|
||||||
var stdout: String
|
|
||||||
var stderr: String
|
|
||||||
var error: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostError: Codable {
|
|
||||||
var code: String
|
|
||||||
var message: String
|
|
||||||
var reason: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
fileprivate struct ExecHostResponse: Codable {
|
|
||||||
var type: String
|
|
||||||
var id: String
|
|
||||||
var ok: Bool
|
|
||||||
var payload: ExecHostRunResult?
|
|
||||||
var error: ExecHostError?
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ExecApprovalsSocketClient {
|
enum ExecApprovalsSocketClient {
|
||||||
private struct TimeoutError: LocalizedError {
|
private struct TimeoutError: LocalizedError {
|
||||||
var message: String
|
var message: String
|
||||||
@@ -190,9 +146,6 @@ final class ExecApprovalsPromptServer {
|
|||||||
token: approvals.token,
|
token: approvals.token,
|
||||||
onPrompt: { request in
|
onPrompt: { request in
|
||||||
await ExecApprovalsPromptPresenter.prompt(request)
|
await ExecApprovalsPromptPresenter.prompt(request)
|
||||||
},
|
|
||||||
onExec: { request in
|
|
||||||
await ExecHostExecutor.handle(request)
|
|
||||||
})
|
})
|
||||||
server.start()
|
server.start()
|
||||||
self.server = server
|
self.server = server
|
||||||
@@ -204,10 +157,9 @@ final class ExecApprovalsPromptServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ExecApprovalsPromptPresenter {
|
private enum ExecApprovalsPromptPresenter {
|
||||||
@MainActor
|
@MainActor
|
||||||
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
|
||||||
NSApp.activate(ignoringOtherApps: true)
|
|
||||||
let alert = NSAlert()
|
let alert = NSAlert()
|
||||||
alert.alertStyle = .warning
|
alert.alertStyle = .warning
|
||||||
alert.messageText = "Allow this command?"
|
alert.messageText = "Allow this command?"
|
||||||
@@ -253,182 +205,11 @@ enum ExecApprovalsPromptPresenter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@MainActor
|
private final class ExecApprovalsSocketServer {
|
||||||
fileprivate enum ExecHostExecutor {
|
|
||||||
private static let blockedEnvKeys: Set<String> = [
|
|
||||||
"PATH",
|
|
||||||
"NODE_OPTIONS",
|
|
||||||
"PYTHONHOME",
|
|
||||||
"PYTHONPATH",
|
|
||||||
"PERL5LIB",
|
|
||||||
"PERL5OPT",
|
|
||||||
"RUBYOPT",
|
|
||||||
]
|
|
||||||
|
|
||||||
private static let blockedEnvPrefixes: [String] = [
|
|
||||||
"DYLD_",
|
|
||||||
"LD_",
|
|
||||||
]
|
|
||||||
|
|
||||||
static func handle(_ request: ExecHostRequest) async -> ExecHostResponse {
|
|
||||||
let command = request.command.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
guard !command.isEmpty else {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "command required", reason: "invalid"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let displayCommand = ExecCommandFormatter.displayString(
|
|
||||||
for: command,
|
|
||||||
rawCommand: request.rawCommand)
|
|
||||||
let agentId = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let trimmedAgent = (agentId?.isEmpty == false) ? agentId : nil
|
|
||||||
let approvals = ExecApprovalsStore.resolve(agentId: trimmedAgent)
|
|
||||||
let security = approvals.agent.security
|
|
||||||
let ask = approvals.agent.ask
|
|
||||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
|
||||||
let env = self.sanitizedEnv(request.env)
|
|
||||||
let resolution = ExecCommandResolution.resolve(
|
|
||||||
command: command,
|
|
||||||
rawCommand: request.rawCommand,
|
|
||||||
cwd: request.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 {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny", reason: "security=deny"))
|
|
||||||
}
|
|
||||||
|
|
||||||
let requiresAsk: Bool = {
|
|
||||||
if ask == .always { return true }
|
|
||||||
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
|
|
||||||
return false
|
|
||||||
}()
|
|
||||||
|
|
||||||
var approvedByAsk = false
|
|
||||||
if requiresAsk {
|
|
||||||
let decision = ExecApprovalsPromptPresenter.prompt(
|
|
||||||
ExecApprovalPromptRequest(
|
|
||||||
command: displayCommand,
|
|
||||||
cwd: request.cwd,
|
|
||||||
host: "node",
|
|
||||||
security: security.rawValue,
|
|
||||||
ask: ask.rawValue,
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
resolvedPath: resolution?.resolvedPath))
|
|
||||||
|
|
||||||
switch decision {
|
|
||||||
case .deny:
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied", reason: "user-denied"))
|
|
||||||
case .allowAlways:
|
|
||||||
approvedByAsk = true
|
|
||||||
if security == .allowlist {
|
|
||||||
let pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? command.first ?? ""
|
|
||||||
if !pattern.isEmpty {
|
|
||||||
ExecApprovalsStore.addAllowlistEntry(agentId: trimmedAgent, pattern: pattern)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case .allowOnce:
|
|
||||||
approvedByAsk = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss", reason: "allowlist-miss"))
|
|
||||||
}
|
|
||||||
|
|
||||||
if let match = allowlistMatch {
|
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
|
||||||
agentId: trimmedAgent,
|
|
||||||
pattern: match.pattern,
|
|
||||||
command: displayCommand,
|
|
||||||
resolvedPath: resolution?.resolvedPath)
|
|
||||||
}
|
|
||||||
|
|
||||||
if request.needsScreenRecording == true {
|
|
||||||
let authorized = await PermissionManager
|
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
|
||||||
if !authorized {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording", reason: "permission:screenRecording"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let timeoutSec = request.timeoutMs.flatMap { Double($0) / 1000.0 }
|
|
||||||
let result = await Task.detached { () -> ShellExecutor.ShellResult in
|
|
||||||
await ShellExecutor.runDetailed(
|
|
||||||
command: command,
|
|
||||||
cwd: request.cwd,
|
|
||||||
env: env,
|
|
||||||
timeout: timeoutSec)
|
|
||||||
}.value
|
|
||||||
let payload = ExecHostRunResult(
|
|
||||||
exitCode: result.exitCode,
|
|
||||||
timedOut: result.timedOut,
|
|
||||||
success: result.success,
|
|
||||||
stdout: result.stdout,
|
|
||||||
stderr: result.stderr,
|
|
||||||
error: result.errorMessage)
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: UUID().uuidString,
|
|
||||||
ok: true,
|
|
||||||
payload: payload,
|
|
||||||
error: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func sanitizedEnv(_ overrides: [String: String]?) -> [String: String]? {
|
|
||||||
guard let overrides else { return nil }
|
|
||||||
var merged = ProcessInfo.processInfo.environment
|
|
||||||
for (rawKey, value) in overrides {
|
|
||||||
let key = rawKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard !key.isEmpty else { continue }
|
|
||||||
let upper = key.uppercased()
|
|
||||||
if self.blockedEnvKeys.contains(upper) { continue }
|
|
||||||
if self.blockedEnvPrefixes.contains(where: { upper.hasPrefix($0) }) { continue }
|
|
||||||
merged[key] = value
|
|
||||||
}
|
|
||||||
return merged
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|
||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
|
||||||
private let socketPath: String
|
private let socketPath: String
|
||||||
private let token: String
|
private let token: String
|
||||||
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
|
||||||
private let onExec: @Sendable (ExecHostRequest) async -> ExecHostResponse
|
|
||||||
private var socketFD: Int32 = -1
|
private var socketFD: Int32 = -1
|
||||||
private var acceptTask: Task<Void, Never>?
|
private var acceptTask: Task<Void, Never>?
|
||||||
private var isRunning = false
|
private var isRunning = false
|
||||||
@@ -436,13 +217,11 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
init(
|
init(
|
||||||
socketPath: String,
|
socketPath: String,
|
||||||
token: String,
|
token: String,
|
||||||
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision,
|
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
|
||||||
onExec: @escaping @Sendable (ExecHostRequest) async -> ExecHostResponse)
|
|
||||||
{
|
{
|
||||||
self.socketPath = socketPath
|
self.socketPath = socketPath
|
||||||
self.token = token
|
self.token = token
|
||||||
self.onPrompt = onPrompt
|
self.onPrompt = onPrompt
|
||||||
self.onExec = onExec
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
@@ -537,39 +316,26 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
private func handleClient(fd: Int32) async {
|
private func handleClient(fd: Int32) async {
|
||||||
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||||
do {
|
do {
|
||||||
guard self.isAllowedPeer(fd: fd) else {
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: UUID().uuidString, decision: .deny)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
|
||||||
let data = line.data(using: .utf8)
|
let data = line.data(using: .utf8)
|
||||||
else {
|
else {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
guard
|
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
||||||
let envelope = try JSONSerialization.jsonObject(with: data) as? [String: Any],
|
guard request.type == "request", request.token == self.token else {
|
||||||
let type = envelope["type"] as? String
|
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
|
||||||
else {
|
let data = try JSONEncoder().encode(response)
|
||||||
return
|
var payload = data
|
||||||
}
|
payload.append(0x0A)
|
||||||
|
try handle.write(contentsOf: payload)
|
||||||
if type == "request" {
|
|
||||||
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
|
|
||||||
guard request.token == self.token else {
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: .deny)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let decision = await self.onPrompt(request.request)
|
|
||||||
try self.sendApprovalResponse(handle: handle, id: request.id, decision: decision)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if type == "exec" {
|
|
||||||
let request = try JSONDecoder().decode(ExecHostSocketRequest.self, from: data)
|
|
||||||
let response = await self.handleExecRequest(request)
|
|
||||||
try self.sendExecResponse(handle: handle, response: response)
|
|
||||||
return
|
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 {
|
} catch {
|
||||||
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
|
||||||
}
|
}
|
||||||
@@ -590,77 +356,4 @@ private final class ExecApprovalsSocketServer: @unchecked Sendable {
|
|||||||
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
let lineData = buffer.subdata(in: 0..<newlineIndex)
|
||||||
return String(data: lineData, encoding: .utf8)
|
return String(data: lineData, encoding: .utf8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func sendApprovalResponse(
|
|
||||||
handle: FileHandle,
|
|
||||||
id: String,
|
|
||||||
decision: ExecApprovalDecision) throws
|
|
||||||
{
|
|
||||||
let response = ExecApprovalSocketDecision(type: "decision", id: id, decision: decision)
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func sendExecResponse(handle: FileHandle, response: ExecHostResponse) throws {
|
|
||||||
let data = try JSONEncoder().encode(response)
|
|
||||||
var payload = data
|
|
||||||
payload.append(0x0A)
|
|
||||||
try handle.write(contentsOf: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func isAllowedPeer(fd: Int32) -> Bool {
|
|
||||||
var uid = uid_t(0)
|
|
||||||
var gid = gid_t(0)
|
|
||||||
if getpeereid(fd, &uid, &gid) != 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return uid == geteuid()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleExecRequest(_ request: ExecHostSocketRequest) async -> ExecHostResponse {
|
|
||||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
|
||||||
if abs(nowMs - request.ts) > 10_000 {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "expired request", reason: "ttl"))
|
|
||||||
}
|
|
||||||
let expected = self.hmacHex(nonce: request.nonce, ts: request.ts, requestJson: request.requestJson)
|
|
||||||
if expected != request.hmac {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid auth", reason: "hmac"))
|
|
||||||
}
|
|
||||||
guard let requestData = request.requestJson.data(using: .utf8),
|
|
||||||
let payload = try? JSONDecoder().decode(ExecHostRequest.self, from: requestData)
|
|
||||||
else {
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: false,
|
|
||||||
payload: nil,
|
|
||||||
error: ExecHostError(code: "INVALID_REQUEST", message: "invalid payload", reason: "json"))
|
|
||||||
}
|
|
||||||
let response = await self.onExec(payload)
|
|
||||||
return ExecHostResponse(
|
|
||||||
type: "exec-res",
|
|
||||||
id: request.id,
|
|
||||||
ok: response.ok,
|
|
||||||
payload: response.payload,
|
|
||||||
error: response.error)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func hmacHex(nonce: String, ts: Int, requestJson: String) -> String {
|
|
||||||
let key = SymmetricKey(data: Data(self.token.utf8))
|
|
||||||
let message = "\(nonce):\(ts):\(requestJson)"
|
|
||||||
let mac = HMAC<SHA256>.authenticationCode(for: Data(message.utf8), using: key)
|
|
||||||
return mac.map { String(format: "%02x", $0) }.joined()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -249,13 +249,6 @@ actor GatewayConnection {
|
|||||||
return trimmed.isEmpty ? nil : trimmed
|
return trimmed.isEmpty ? nil : trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
func cachedGatewayVersion() -> String? {
|
|
||||||
guard let snapshot = self.lastSnapshot else { return nil }
|
|
||||||
let raw = snapshot.server["version"]?.value as? String
|
|
||||||
let trimmed = raw?.trimmingCharacters(in: CharacterSet.whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? nil : trimmed
|
|
||||||
}
|
|
||||||
|
|
||||||
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
func snapshotPaths() -> (configPath: String?, stateDir: String?) {
|
||||||
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
guard let snapshot = self.lastSnapshot else { return (nil, nil) }
|
||||||
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let configPath = snapshot.snapshot.configpath?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private weak var statusItem: NSStatusItem?
|
private weak var statusItem: NSStatusItem?
|
||||||
private var loadTask: Task<Void, Never>?
|
private var loadTask: Task<Void, Never>?
|
||||||
private var nodesLoadTask: Task<Void, Never>?
|
private var nodesLoadTask: Task<Void, Never>?
|
||||||
private var previewTasks: [Task<Void, Never>] = []
|
|
||||||
private var isMenuOpen = false
|
private var isMenuOpen = false
|
||||||
private var lastKnownMenuWidth: CGFloat?
|
private var lastKnownMenuWidth: CGFloat?
|
||||||
private var menuOpenWidth: CGFloat?
|
private var menuOpenWidth: CGFloat?
|
||||||
@@ -27,10 +26,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
private var cachedUsageErrorText: String?
|
private var cachedUsageErrorText: String?
|
||||||
private var usageCacheUpdatedAt: Date?
|
private var usageCacheUpdatedAt: Date?
|
||||||
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
private let usageRefreshIntervalSeconds: TimeInterval = 30
|
||||||
private var cachedCostSummary: GatewayCostUsageSummary?
|
|
||||||
private var cachedCostErrorText: String?
|
|
||||||
private var costCacheUpdatedAt: Date?
|
|
||||||
private let costRefreshIntervalSeconds: TimeInterval = 45
|
|
||||||
private let nodesStore = NodesStore.shared
|
private let nodesStore = NodesStore.shared
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
private var testControlChannelConnected: Bool?
|
private var testControlChannelConnected: Bool?
|
||||||
@@ -68,7 +63,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
await self.refreshCache(force: forceRefresh)
|
await self.refreshCache(force: forceRefresh)
|
||||||
await self.refreshUsageCache(force: forceRefresh)
|
await self.refreshUsageCache(force: forceRefresh)
|
||||||
await self.refreshCostUsageCache(force: forceRefresh)
|
|
||||||
await MainActor.run {
|
await MainActor.run {
|
||||||
guard self.isMenuOpen else { return }
|
guard self.isMenuOpen else { return }
|
||||||
self.inject(into: menu)
|
self.inject(into: menu)
|
||||||
@@ -93,7 +87,6 @@ final class MenuSessionsInjector: NSObject, NSMenuDelegate {
|
|||||||
self.menuOpenWidth = nil
|
self.menuOpenWidth = nil
|
||||||
self.loadTask?.cancel()
|
self.loadTask?.cancel()
|
||||||
self.nodesLoadTask?.cancel()
|
self.nodesLoadTask?.cancel()
|
||||||
self.cancelPreviewTasks()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func menuNeedsUpdate(_ menu: NSMenu) {
|
func menuNeedsUpdate(_ menu: NSMenu) {
|
||||||
@@ -114,7 +107,6 @@ extension MenuSessionsInjector {
|
|||||||
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
private var mainSessionKey: String { WorkActivityStore.shared.mainSessionKey }
|
||||||
|
|
||||||
private func inject(into menu: NSMenu) {
|
private func inject(into menu: NSMenu) {
|
||||||
self.cancelPreviewTasks()
|
|
||||||
// Remove any previous injected items.
|
// Remove any previous injected items.
|
||||||
for item in menu.items where item.tag == self.tag {
|
for item in menu.items where item.tag == self.tag {
|
||||||
menu.removeItem(item)
|
menu.removeItem(item)
|
||||||
@@ -205,7 +197,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
cursor = self.insertUsageSection(into: menu, at: cursor, width: width)
|
||||||
cursor = self.insertCostUsageSection(into: menu, at: cursor, width: width)
|
|
||||||
|
|
||||||
DispatchQueue.main.async { [weak self, weak headerView] in
|
DispatchQueue.main.async { [weak self, weak headerView] in
|
||||||
guard let self, let headerView else { return }
|
guard let self, let headerView else { return }
|
||||||
@@ -289,7 +280,9 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
private func insertUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
||||||
let rows = self.usageRows
|
let rows = self.usageRows
|
||||||
if rows.isEmpty {
|
let errorText = self.cachedUsageErrorText
|
||||||
|
|
||||||
|
if rows.isEmpty, errorText == nil {
|
||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -313,6 +306,25 @@ extension MenuSessionsInjector {
|
|||||||
menu.insertItem(headerItem, at: cursor)
|
menu.insertItem(headerItem, at: cursor)
|
||||||
cursor += 1
|
cursor += 1
|
||||||
|
|
||||||
|
if let errorText = errorText?.nonEmpty, !rows.isEmpty {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(
|
||||||
|
text: errorText,
|
||||||
|
symbolName: "exclamationmark.triangle",
|
||||||
|
width: width,
|
||||||
|
maxLines: 2),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if rows.isEmpty {
|
||||||
|
menu.insertItem(
|
||||||
|
self.makeMessageItem(text: errorText ?? "No usage available", symbolName: "minus", width: width),
|
||||||
|
at: cursor)
|
||||||
|
cursor += 1
|
||||||
|
return cursor
|
||||||
|
}
|
||||||
|
|
||||||
if let selectedProvider = self.selectedUsageProviderId,
|
if let selectedProvider = self.selectedUsageProviderId,
|
||||||
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
let primary = rows.first(where: { $0.providerId.lowercased() == selectedProvider }),
|
||||||
rows.count > 1
|
rows.count > 1
|
||||||
@@ -350,28 +362,6 @@ extension MenuSessionsInjector {
|
|||||||
return cursor
|
return cursor
|
||||||
}
|
}
|
||||||
|
|
||||||
private func insertCostUsageSection(into menu: NSMenu, at cursor: Int, width: CGFloat) -> Int {
|
|
||||||
guard self.isControlChannelConnected else { return cursor }
|
|
||||||
guard let submenu = self.buildCostUsageSubmenu(width: width) else { return cursor }
|
|
||||||
var cursor = cursor
|
|
||||||
|
|
||||||
if cursor > 0, !menu.items[cursor - 1].isSeparatorItem {
|
|
||||||
let separator = NSMenuItem.separator()
|
|
||||||
separator.tag = self.tag
|
|
||||||
menu.insertItem(separator, at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
}
|
|
||||||
|
|
||||||
let item = NSMenuItem(title: "Usage cost (30 days)", action: nil, keyEquivalent: "")
|
|
||||||
item.tag = self.tag
|
|
||||||
item.isEnabled = true
|
|
||||||
item.image = NSImage(systemSymbolName: "chart.bar.xaxis", accessibilityDescription: nil)
|
|
||||||
item.submenu = submenu
|
|
||||||
menu.insertItem(item, at: cursor)
|
|
||||||
cursor += 1
|
|
||||||
return cursor
|
|
||||||
}
|
|
||||||
|
|
||||||
private var selectedUsageProviderId: String? {
|
private var selectedUsageProviderId: String? {
|
||||||
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
guard let model = self.cachedSnapshot?.defaults.model.nonEmpty else { return nil }
|
||||||
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
@@ -421,36 +411,6 @@ extension MenuSessionsInjector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func buildCostUsageSubmenu(width: CGFloat) -> NSMenu? {
|
|
||||||
if let error = self.cachedCostErrorText, !error.isEmpty, self.cachedCostSummary == nil {
|
|
||||||
let menu = NSMenu()
|
|
||||||
let item = NSMenuItem(title: error, action: nil, keyEquivalent: "")
|
|
||||||
item.isEnabled = false
|
|
||||||
menu.addItem(item)
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let summary = self.cachedCostSummary else { return nil }
|
|
||||||
guard !summary.daily.isEmpty else { return nil }
|
|
||||||
|
|
||||||
let menu = NSMenu()
|
|
||||||
menu.delegate = self
|
|
||||||
|
|
||||||
let chartView = CostUsageHistoryMenuView(summary: summary, width: width)
|
|
||||||
let hosting = NSHostingView(rootView: AnyView(chartView))
|
|
||||||
let controller = NSHostingController(rootView: AnyView(chartView))
|
|
||||||
let size = controller.sizeThatFits(in: CGSize(width: width, height: .greatestFiniteMagnitude))
|
|
||||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
|
||||||
|
|
||||||
let chartItem = NSMenuItem()
|
|
||||||
chartItem.view = hosting
|
|
||||||
chartItem.isEnabled = false
|
|
||||||
chartItem.representedObject = "costUsageChart"
|
|
||||||
menu.addItem(chartItem)
|
|
||||||
|
|
||||||
return menu
|
|
||||||
}
|
|
||||||
|
|
||||||
private func gatewayEntry() -> NodeInfo? {
|
private func gatewayEntry() -> NodeInfo? {
|
||||||
let mode = AppStateStore.shared.connectionMode
|
let mode = AppStateStore.shared.connectionMode
|
||||||
let isConnected = self.isControlChannelConnected
|
let isConnected = self.isControlChannelConnected
|
||||||
@@ -480,8 +440,6 @@ extension MenuSessionsInjector {
|
|||||||
displayName: "Gateway",
|
displayName: "Gateway",
|
||||||
platform: platform,
|
platform: platform,
|
||||||
version: nil,
|
version: nil,
|
||||||
coreVersion: nil,
|
|
||||||
uiVersion: nil,
|
|
||||||
deviceFamily: nil,
|
deviceFamily: nil,
|
||||||
modelIdentifier: nil,
|
modelIdentifier: nil,
|
||||||
remoteIp: host,
|
remoteIp: host,
|
||||||
@@ -515,46 +473,15 @@ extension MenuSessionsInjector {
|
|||||||
item.tag = self.tag
|
item.tag = self.tag
|
||||||
item.isEnabled = false
|
item.isEnabled = false
|
||||||
let view = AnyView(SessionMenuPreviewView(
|
let view = AnyView(SessionMenuPreviewView(
|
||||||
|
sessionKey: sessionKey,
|
||||||
width: width,
|
width: width,
|
||||||
|
maxItems: 10,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
title: title,
|
title: title))
|
||||||
items: [],
|
item.view = self.makeHostedView(rootView: view, width: width, highlighted: false)
|
||||||
status: .loading))
|
|
||||||
let hosting = NSHostingView(rootView: view)
|
|
||||||
hosting.frame.size.width = max(1, width)
|
|
||||||
let size = hosting.fittingSize
|
|
||||||
hosting.frame = NSRect(origin: .zero, size: NSSize(width: width, height: size.height))
|
|
||||||
item.view = hosting
|
|
||||||
|
|
||||||
let task = Task { [weak hosting] in
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: sessionKey, maxItems: 10)
|
|
||||||
guard !Task.isCancelled else { return }
|
|
||||||
await MainActor.run {
|
|
||||||
guard let hosting else { return }
|
|
||||||
let nextView = AnyView(SessionMenuPreviewView(
|
|
||||||
width: width,
|
|
||||||
maxLines: maxLines,
|
|
||||||
title: title,
|
|
||||||
items: snapshot.items,
|
|
||||||
status: snapshot.status))
|
|
||||||
hosting.rootView = nextView
|
|
||||||
hosting.invalidateIntrinsicContentSize()
|
|
||||||
hosting.frame.size.width = max(1, width)
|
|
||||||
let size = hosting.fittingSize
|
|
||||||
hosting.frame.size.height = size.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
self.previewTasks.append(task)
|
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
private func cancelPreviewTasks() {
|
|
||||||
for task in self.previewTasks {
|
|
||||||
task.cancel()
|
|
||||||
}
|
|
||||||
self.previewTasks.removeAll()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
private func makeMessageItem(text: String, symbolName: String, width: CGFloat, maxLines: Int? = 2) -> NSMenuItem {
|
||||||
let view = AnyView(
|
let view = AnyView(
|
||||||
HStack(alignment: .top, spacing: 8) {
|
HStack(alignment: .top, spacing: 8) {
|
||||||
@@ -632,36 +559,14 @@ extension MenuSessionsInjector {
|
|||||||
|
|
||||||
do {
|
do {
|
||||||
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
self.cachedUsageSummary = try await UsageLoader.loadSummary()
|
||||||
} catch {
|
|
||||||
self.cachedUsageSummary = nil
|
|
||||||
self.cachedUsageErrorText = nil
|
self.cachedUsageErrorText = nil
|
||||||
}
|
self.usageCacheUpdatedAt = Date()
|
||||||
self.usageCacheUpdatedAt = Date()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func refreshCostUsageCache(force: Bool) async {
|
|
||||||
if !force,
|
|
||||||
let updated = self.costCacheUpdatedAt,
|
|
||||||
Date().timeIntervalSince(updated) < self.costRefreshIntervalSeconds
|
|
||||||
{
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard self.isControlChannelConnected else {
|
|
||||||
self.cachedCostSummary = nil
|
|
||||||
self.cachedCostErrorText = nil
|
|
||||||
self.costCacheUpdatedAt = Date()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
self.cachedCostSummary = try await CostUsageLoader.loadSummary()
|
|
||||||
self.cachedCostErrorText = nil
|
|
||||||
} catch {
|
} catch {
|
||||||
self.cachedCostSummary = nil
|
if self.cachedUsageSummary == nil {
|
||||||
self.cachedCostErrorText = self.compactUsageError(error)
|
self.cachedUsageErrorText = self.compactUsageError(error)
|
||||||
|
}
|
||||||
|
self.usageCacheUpdatedAt = Date()
|
||||||
}
|
}
|
||||||
self.costCacheUpdatedAt = Date()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func compactUsageError(_ error: Error) -> String {
|
private func compactUsageError(_ error: Error) -> String {
|
||||||
@@ -842,8 +747,8 @@ extension MenuSessionsInjector {
|
|||||||
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
menu.addItem(self.makeNodeCopyItem(label: "Platform", value: platform))
|
||||||
}
|
}
|
||||||
|
|
||||||
if let version = NodeMenuEntryFormatter.detailRightVersion(entry)?.nonEmpty {
|
if let version = entry.version?.nonEmpty {
|
||||||
menu.addItem(self.makeNodeCopyItem(label: "Version", value: version))
|
menu.addItem(self.makeNodeCopyItem(label: "Version", value: self.formatVersionLabel(version)))
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
menu.addItem(self.makeNodeDetailItem(label: "Connected", value: entry.isConnected ? "Yes" : "No"))
|
||||||
|
|||||||
@@ -95,8 +95,6 @@ actor MacNodeBridgePairingClient {
|
|||||||
displayName: hello.displayName,
|
displayName: hello.displayName,
|
||||||
platform: hello.platform,
|
platform: hello.platform,
|
||||||
version: hello.version,
|
version: hello.version,
|
||||||
coreVersion: hello.coreVersion,
|
|
||||||
uiVersion: hello.uiVersion,
|
|
||||||
deviceFamily: hello.deviceFamily,
|
deviceFamily: hello.deviceFamily,
|
||||||
modelIdentifier: hello.modelIdentifier,
|
modelIdentifier: hello.modelIdentifier,
|
||||||
caps: hello.caps,
|
caps: hello.caps,
|
||||||
|
|||||||
@@ -114,19 +114,12 @@ final class MacNodeModeCoordinator {
|
|||||||
let caps = self.currentCaps()
|
let caps = self.currentCaps()
|
||||||
let commands = self.currentCommands(caps: caps)
|
let commands = self.currentCommands(caps: caps)
|
||||||
let permissions = await self.currentPermissions()
|
let permissions = await self.currentPermissions()
|
||||||
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
|
||||||
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
|
||||||
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
|
||||||
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
|
||||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
return BridgeHello(
|
return BridgeHello(
|
||||||
nodeId: Self.nodeId(),
|
nodeId: Self.nodeId(),
|
||||||
displayName: InstanceIdentity.displayName,
|
displayName: InstanceIdentity.displayName,
|
||||||
token: token,
|
token: token,
|
||||||
platform: "macos",
|
platform: "macos",
|
||||||
version: uiVersion,
|
version: Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String,
|
||||||
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
|
||||||
uiVersion: uiVersion,
|
|
||||||
deviceFamily: "Mac",
|
deviceFamily: "Mac",
|
||||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||||
caps: caps,
|
caps: caps,
|
||||||
@@ -165,8 +158,6 @@ final class MacNodeModeCoordinator {
|
|||||||
ClawdbotSystemCommand.notify.rawValue,
|
ClawdbotSystemCommand.notify.rawValue,
|
||||||
ClawdbotSystemCommand.which.rawValue,
|
ClawdbotSystemCommand.which.rawValue,
|
||||||
ClawdbotSystemCommand.run.rawValue,
|
ClawdbotSystemCommand.run.rawValue,
|
||||||
ClawdbotSystemCommand.execApprovalsGet.rawValue,
|
|
||||||
ClawdbotSystemCommand.execApprovalsSet.rawValue,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
let capsSet = Set(caps)
|
let capsSet = Set(caps)
|
||||||
|
|||||||
@@ -64,10 +64,6 @@ actor MacNodeRuntime {
|
|||||||
return try await self.handleSystemWhich(req)
|
return try await self.handleSystemWhich(req)
|
||||||
case ClawdbotSystemCommand.notify.rawValue:
|
case ClawdbotSystemCommand.notify.rawValue:
|
||||||
return try await self.handleSystemNotify(req)
|
return try await self.handleSystemNotify(req)
|
||||||
case ClawdbotSystemCommand.execApprovalsGet.rawValue:
|
|
||||||
return try await self.handleSystemExecApprovalsGet(req)
|
|
||||||
case ClawdbotSystemCommand.execApprovalsSet.rawValue:
|
|
||||||
return try await self.handleSystemExecApprovalsSet(req)
|
|
||||||
default:
|
default:
|
||||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: unknown command")
|
||||||
}
|
}
|
||||||
@@ -436,24 +432,19 @@ 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 trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
|
||||||
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
let security = approvals.agent.security
|
let security = approvals.agent.security
|
||||||
let ask = approvals.agent.ask
|
let ask = approvals.agent.ask
|
||||||
|
let askFallback = approvals.agent.askFallback
|
||||||
let autoAllowSkills = approvals.agent.autoAllowSkills
|
let autoAllowSkills = approvals.agent.autoAllowSkills
|
||||||
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
: self.mainSessionKey
|
: self.mainSessionKey
|
||||||
let runId = UUID().uuidString
|
let runId = UUID().uuidString
|
||||||
let env = Self.sanitizedEnv(params.env)
|
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
|
||||||
let resolution = ExecCommandResolution.resolve(
|
|
||||||
command: command,
|
|
||||||
rawCommand: params.rawCommand,
|
|
||||||
cwd: params.cwd,
|
|
||||||
env: env)
|
|
||||||
let allowlistMatch = security == .allowlist
|
let allowlistMatch = security == .allowlist
|
||||||
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
|
||||||
: nil
|
: nil
|
||||||
@@ -472,7 +463,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
reason: "security=deny"))
|
reason: "security=deny"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -486,11 +477,12 @@ actor MacNodeRuntime {
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var approvedByAsk = false
|
|
||||||
if requiresAsk {
|
if requiresAsk {
|
||||||
let decision = await ExecApprovalsPromptPresenter.prompt(
|
let decision = await ExecApprovalsSocketClient.requestDecision(
|
||||||
ExecApprovalPromptRequest(
|
socketPath: approvals.socketPath,
|
||||||
command: displayCommand,
|
token: approvals.token,
|
||||||
|
request: ExecApprovalPromptRequest(
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
host: "node",
|
host: "node",
|
||||||
security: security.rawValue,
|
security: security.rawValue,
|
||||||
@@ -499,21 +491,35 @@ actor MacNodeRuntime {
|
|||||||
resolvedPath: resolution?.resolvedPath))
|
resolvedPath: resolution?.resolvedPath))
|
||||||
|
|
||||||
switch decision {
|
switch decision {
|
||||||
case .deny:
|
case .deny?:
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.denied",
|
"exec.denied",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
reason: "user-denied"))
|
reason: "user-denied"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
code: .unavailable,
|
code: .unavailable,
|
||||||
message: "SYSTEM_RUN_DENIED: user denied")
|
message: "SYSTEM_RUN_DENIED: user denied")
|
||||||
case .allowAlways:
|
case nil:
|
||||||
approvedByAsk = true
|
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
|
||||||
|
await self.emitExecEvent(
|
||||||
|
"exec.denied",
|
||||||
|
payload: ExecEventPayload(
|
||||||
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
|
host: "node",
|
||||||
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
|
reason: "approval-required"))
|
||||||
|
return Self.errorResponse(
|
||||||
|
req,
|
||||||
|
code: .unavailable,
|
||||||
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
|
}
|
||||||
|
case .allowAlways?:
|
||||||
if security == .allowlist {
|
if security == .allowlist {
|
||||||
let pattern = resolution?.resolvedPath ??
|
let pattern = resolution?.resolvedPath ??
|
||||||
resolution?.rawExecutable ??
|
resolution?.rawExecutable ??
|
||||||
@@ -523,34 +529,21 @@ actor MacNodeRuntime {
|
|||||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .allowOnce:
|
case .allowOnce?:
|
||||||
approvedByAsk = true
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
|
||||||
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 {
|
if let match = allowlistMatch {
|
||||||
ExecApprovalsStore.recordAllowlistUse(
|
ExecApprovalsStore.recordAllowlistUse(
|
||||||
agentId: agentId,
|
agentId: agentId,
|
||||||
pattern: match.pattern,
|
pattern: match.pattern,
|
||||||
command: displayCommand,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
resolvedPath: resolution?.resolvedPath)
|
resolvedPath: resolution?.resolvedPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let env = Self.sanitizedEnv(params.env)
|
||||||
|
|
||||||
if params.needsScreenRecording == true {
|
if params.needsScreenRecording == true {
|
||||||
let authorized = await PermissionManager
|
let authorized = await PermissionManager
|
||||||
.status([.screenRecording])[.screenRecording] ?? false
|
.status([.screenRecording])[.screenRecording] ?? false
|
||||||
@@ -561,7 +554,7 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
reason: "permission:screenRecording"))
|
reason: "permission:screenRecording"))
|
||||||
return Self.errorResponse(
|
return Self.errorResponse(
|
||||||
req,
|
req,
|
||||||
@@ -577,23 +570,20 @@ actor MacNodeRuntime {
|
|||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand))
|
command: ExecCommandFormatter.displayString(for: command)))
|
||||||
let result = await ShellExecutor.runDetailed(
|
let result = await ShellExecutor.runDetailed(
|
||||||
command: command,
|
command: command,
|
||||||
cwd: params.cwd,
|
cwd: params.cwd,
|
||||||
env: env,
|
env: env,
|
||||||
timeout: timeoutSec)
|
timeout: timeoutSec)
|
||||||
let combined = [result.stdout, result.stderr, result.errorMessage]
|
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
|
||||||
.compactMap { $0 }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
.joined(separator: "\n")
|
|
||||||
await self.emitExecEvent(
|
await self.emitExecEvent(
|
||||||
"exec.finished",
|
"exec.finished",
|
||||||
payload: ExecEventPayload(
|
payload: ExecEventPayload(
|
||||||
sessionKey: sessionKey,
|
sessionKey: sessionKey,
|
||||||
runId: runId,
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
command: displayCommand,
|
command: ExecCommandFormatter.displayString(for: command),
|
||||||
exitCode: result.exitCode,
|
exitCode: result.exitCode,
|
||||||
timedOut: result.timedOut,
|
timedOut: result.timedOut,
|
||||||
success: result.success,
|
success: result.success,
|
||||||
@@ -645,72 +635,6 @@ actor MacNodeRuntime {
|
|||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handleSystemExecApprovalsGet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
_ = ExecApprovalsStore.ensureFile()
|
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
let redacted = ExecApprovalsSnapshot(
|
|
||||||
path: snapshot.path,
|
|
||||||
exists: snapshot.exists,
|
|
||||||
hash: snapshot.hash,
|
|
||||||
file: ExecApprovalsStore.redactForSnapshot(snapshot.file))
|
|
||||||
let payload = try Self.encodePayload(redacted)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func handleSystemExecApprovalsSet(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
|
||||||
struct SetParams: Decodable {
|
|
||||||
var file: ExecApprovalsFile
|
|
||||||
var baseHash: String?
|
|
||||||
}
|
|
||||||
|
|
||||||
let params = try Self.decodeParams(SetParams.self, from: req.paramsJSON)
|
|
||||||
let current = ExecApprovalsStore.ensureFile()
|
|
||||||
let snapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
if snapshot.exists {
|
|
||||||
if snapshot.hash.isEmpty {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals base hash unavailable; reload and retry")
|
|
||||||
}
|
|
||||||
let baseHash = params.baseHash?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
if baseHash.isEmpty {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals base hash required; reload and retry")
|
|
||||||
}
|
|
||||||
if baseHash != snapshot.hash {
|
|
||||||
return Self.errorResponse(
|
|
||||||
req,
|
|
||||||
code: .invalidRequest,
|
|
||||||
message: "INVALID_REQUEST: exec approvals changed; reload and retry")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = ExecApprovalsStore.normalizeIncoming(params.file)
|
|
||||||
let socketPath = normalized.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let token = normalized.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let resolvedPath = (socketPath?.isEmpty == false)
|
|
||||||
? socketPath!
|
|
||||||
: current.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
|
||||||
ExecApprovalsStore.socketPath()
|
|
||||||
let resolvedToken = (token?.isEmpty == false)
|
|
||||||
? token!
|
|
||||||
: current.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
normalized.socket = ExecApprovalsSocketConfig(path: resolvedPath, token: resolvedToken)
|
|
||||||
|
|
||||||
ExecApprovalsStore.saveFile(normalized)
|
|
||||||
let nextSnapshot = ExecApprovalsStore.readSnapshot()
|
|
||||||
let redacted = ExecApprovalsSnapshot(
|
|
||||||
path: nextSnapshot.path,
|
|
||||||
exists: nextSnapshot.exists,
|
|
||||||
hash: nextSnapshot.hash,
|
|
||||||
file: ExecApprovalsStore.redactForSnapshot(nextSnapshot.file))
|
|
||||||
let payload = try Self.encodePayload(redacted)
|
|
||||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
|
||||||
}
|
|
||||||
|
|
||||||
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
|
||||||
guard let sender = self.eventSender else { return }
|
guard let sender = self.eventSender else { return }
|
||||||
guard let data = try? JSONEncoder().encode(payload),
|
guard let data = try? JSONEncoder().encode(payload),
|
||||||
|
|||||||
@@ -1,150 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import OSLog
|
|
||||||
|
|
||||||
enum NodeServiceManager {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "node.service")
|
|
||||||
|
|
||||||
static func start() async -> String? {
|
|
||||||
let result = await self.runServiceCommandResult(
|
|
||||||
["node", "start"],
|
|
||||||
timeout: 20,
|
|
||||||
quiet: false)
|
|
||||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: true) {
|
|
||||||
self.logger.error("node service start failed: \(error, privacy: .public)")
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
static func stop() async -> String? {
|
|
||||||
let result = await self.runServiceCommandResult(
|
|
||||||
["node", "stop"],
|
|
||||||
timeout: 15,
|
|
||||||
quiet: false)
|
|
||||||
if let error = self.errorMessage(from: result, treatNotLoadedAsError: false) {
|
|
||||||
self.logger.error("node service stop failed: \(error, privacy: .public)")
|
|
||||||
return error
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension NodeServiceManager {
|
|
||||||
private struct CommandResult {
|
|
||||||
let success: Bool
|
|
||||||
let payload: Data?
|
|
||||||
let message: String?
|
|
||||||
let parsed: ParsedServiceJson?
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct ParsedServiceJson {
|
|
||||||
let text: String
|
|
||||||
let object: [String: Any]
|
|
||||||
let ok: Bool?
|
|
||||||
let result: String?
|
|
||||||
let message: String?
|
|
||||||
let error: String?
|
|
||||||
let hints: [String]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func runServiceCommandResult(
|
|
||||||
_ args: [String],
|
|
||||||
timeout: Double,
|
|
||||||
quiet: Bool) async -> CommandResult
|
|
||||||
{
|
|
||||||
let command = CommandResolver.clawdbotCommand(
|
|
||||||
subcommand: "service",
|
|
||||||
extraArgs: self.withJsonFlag(args),
|
|
||||||
// Service management must always run locally, even if remote mode is configured.
|
|
||||||
configRoot: ["gateway": ["mode": "local"]])
|
|
||||||
var env = ProcessInfo.processInfo.environment
|
|
||||||
env["PATH"] = CommandResolver.preferredPaths().joined(separator: ":")
|
|
||||||
let response = await ShellExecutor.runDetailed(command: command, cwd: nil, env: env, timeout: timeout)
|
|
||||||
let parsed = self.parseServiceJson(from: response.stdout) ?? self.parseServiceJson(from: response.stderr)
|
|
||||||
let ok = parsed?.ok
|
|
||||||
let message = parsed?.error ?? parsed?.message
|
|
||||||
let payload = parsed?.text.data(using: .utf8)
|
|
||||||
?? (response.stdout.isEmpty ? response.stderr : response.stdout).data(using: .utf8)
|
|
||||||
let success = ok ?? response.success
|
|
||||||
if success {
|
|
||||||
return CommandResult(success: true, payload: payload, message: nil, parsed: parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
if quiet {
|
|
||||||
return CommandResult(success: false, payload: payload, message: message, parsed: parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
let detail = message ?? self.summarize(response.stderr) ?? self.summarize(response.stdout)
|
|
||||||
let exit = response.exitCode.map { "exit \($0)" } ?? (response.errorMessage ?? "failed")
|
|
||||||
let fullMessage = detail.map { "Node service command failed (\(exit)): \($0)" }
|
|
||||||
?? "Node service command failed (\(exit))"
|
|
||||||
self.logger.error("\(fullMessage, privacy: .public)")
|
|
||||||
return CommandResult(success: false, payload: payload, message: detail, parsed: parsed)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func errorMessage(from result: CommandResult, treatNotLoadedAsError: Bool) -> String? {
|
|
||||||
if !result.success {
|
|
||||||
return result.message ?? "Node service command failed"
|
|
||||||
}
|
|
||||||
guard let parsed = result.parsed else { return nil }
|
|
||||||
if parsed.ok == false {
|
|
||||||
return self.mergeHints(message: parsed.error ?? parsed.message, hints: parsed.hints)
|
|
||||||
}
|
|
||||||
if treatNotLoadedAsError, parsed.result == "not-loaded" {
|
|
||||||
let base = parsed.message ?? "Node service not loaded."
|
|
||||||
return self.mergeHints(message: base, hints: parsed.hints)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func withJsonFlag(_ args: [String]) -> [String] {
|
|
||||||
if args.contains("--json") { return args }
|
|
||||||
return args + ["--json"]
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func parseServiceJson(from raw: String) -> ParsedServiceJson? {
|
|
||||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
guard let start = trimmed.firstIndex(of: "{"),
|
|
||||||
let end = trimmed.lastIndex(of: "}")
|
|
||||||
else {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
let jsonText = String(trimmed[start...end])
|
|
||||||
guard let data = jsonText.data(using: .utf8) else { return nil }
|
|
||||||
guard let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil }
|
|
||||||
let ok = object["ok"] as? Bool
|
|
||||||
let result = object["result"] as? String
|
|
||||||
let message = object["message"] as? String
|
|
||||||
let error = object["error"] as? String
|
|
||||||
let hints = (object["hints"] as? [String]) ?? []
|
|
||||||
return ParsedServiceJson(
|
|
||||||
text: jsonText,
|
|
||||||
object: object,
|
|
||||||
ok: ok,
|
|
||||||
result: result,
|
|
||||||
message: message,
|
|
||||||
error: error,
|
|
||||||
hints: hints)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mergeHints(message: String?, hints: [String]) -> String? {
|
|
||||||
let trimmed = message?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
||||||
let nonEmpty = trimmed?.isEmpty == false ? trimmed : nil
|
|
||||||
guard !hints.isEmpty else { return nonEmpty }
|
|
||||||
let hintText = hints.prefix(2).joined(separator: " · ")
|
|
||||||
if let nonEmpty {
|
|
||||||
return "\(nonEmpty) (\(hintText))"
|
|
||||||
}
|
|
||||||
return hintText
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func summarize(_ text: String) -> String? {
|
|
||||||
let lines = text
|
|
||||||
.split(whereSeparator: \.isNewline)
|
|
||||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
||||||
.filter { !$0.isEmpty }
|
|
||||||
guard let last = lines.last else { return nil }
|
|
||||||
let normalized = last.replacingOccurrences(of: "\\s+", with: " ", options: .regularExpression)
|
|
||||||
return normalized.count > 200 ? String(normalized.prefix(199)) + "…" : normalized
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,9 +35,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
if let platform = self.platformText(entry) {
|
if let platform = self.platformText(entry) {
|
||||||
parts.append("platform \(platform)")
|
parts.append("platform \(platform)")
|
||||||
}
|
}
|
||||||
let versionLabels = self.versionLabels(entry)
|
if let version = entry.version?.nonEmpty {
|
||||||
if !versionLabels.isEmpty {
|
parts.append("app \(self.compactVersion(version))")
|
||||||
parts.append(versionLabels.joined(separator: " · "))
|
|
||||||
}
|
}
|
||||||
parts.append("status \(self.roleText(entry))")
|
parts.append("status \(self.roleText(entry))")
|
||||||
return parts.joined(separator: " · ")
|
return parts.joined(separator: " · ")
|
||||||
@@ -61,9 +60,8 @@ struct NodeMenuEntryFormatter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
static func detailRightVersion(_ entry: NodeInfo) -> String? {
|
||||||
let labels = self.versionLabels(entry, compact: false)
|
guard let version = entry.version?.nonEmpty else { return nil }
|
||||||
if labels.isEmpty { return nil }
|
return self.shortVersionLabel(version)
|
||||||
return labels.joined(separator: " · ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func platformText(_ entry: NodeInfo) -> String? {
|
static func platformText(_ entry: NodeInfo) -> String? {
|
||||||
@@ -129,39 +127,6 @@ struct NodeMenuEntryFormatter {
|
|||||||
return compact
|
return compact
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func versionLabels(_ entry: NodeInfo, compact: Bool = true) -> [String] {
|
|
||||||
let (core, ui) = self.resolveVersions(entry)
|
|
||||||
var labels: [String] = []
|
|
||||||
if let core {
|
|
||||||
let label = compact ? self.compactVersion(core) : self.shortVersionLabel(core)
|
|
||||||
labels.append("core \(label)")
|
|
||||||
}
|
|
||||||
if let ui {
|
|
||||||
let label = compact ? self.compactVersion(ui) : self.shortVersionLabel(ui)
|
|
||||||
labels.append("ui \(label)")
|
|
||||||
}
|
|
||||||
return labels
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func resolveVersions(_ entry: NodeInfo) -> (core: String?, ui: String?) {
|
|
||||||
let core = entry.coreVersion?.nonEmpty
|
|
||||||
let ui = entry.uiVersion?.nonEmpty
|
|
||||||
if core != nil || ui != nil {
|
|
||||||
return (core, ui)
|
|
||||||
}
|
|
||||||
guard let legacy = entry.version?.nonEmpty else { return (nil, nil) }
|
|
||||||
if self.isHeadlessPlatform(entry) {
|
|
||||||
return (legacy, nil)
|
|
||||||
}
|
|
||||||
return (nil, legacy)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func isHeadlessPlatform(_ entry: NodeInfo) -> Bool {
|
|
||||||
let raw = entry.platform?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() ?? ""
|
|
||||||
if raw == "darwin" || raw == "linux" || raw == "win32" || raw == "windows" { return true }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
static func leadingSymbol(_ entry: NodeInfo) -> String {
|
||||||
if self.isGateway(entry) {
|
if self.isGateway(entry) {
|
||||||
return self.safeSystemSymbol(
|
return self.safeSystemSymbol(
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ struct NodeInfo: Identifiable, Codable {
|
|||||||
let displayName: String?
|
let displayName: String?
|
||||||
let platform: String?
|
let platform: String?
|
||||||
let version: String?
|
let version: String?
|
||||||
let coreVersion: String?
|
|
||||||
let uiVersion: String?
|
|
||||||
let deviceFamily: String?
|
let deviceFamily: String?
|
||||||
let modelIdentifier: String?
|
let modelIdentifier: String?
|
||||||
let remoteIp: String?
|
let remoteIp: String?
|
||||||
|
|||||||
@@ -210,7 +210,6 @@ extension OnboardingView {
|
|||||||
title: String,
|
title: String,
|
||||||
subtitle: String,
|
subtitle: String,
|
||||||
systemImage: String,
|
systemImage: String,
|
||||||
buttonTitle: String,
|
|
||||||
action: @escaping () -> Void) -> some View
|
action: @escaping () -> Void) -> some View
|
||||||
{
|
{
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
@@ -223,7 +222,7 @@ extension OnboardingView {
|
|||||||
Text(subtitle)
|
Text(subtitle)
|
||||||
.font(.subheadline)
|
.font(.subheadline)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
Button(buttonTitle, action: action)
|
Button("Open Settings → Skills", action: action)
|
||||||
.buttonStyle(.link)
|
.buttonStyle(.link)
|
||||||
.padding(.top, 2)
|
.padding(.top, 2)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -695,8 +695,7 @@ extension OnboardingView {
|
|||||||
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 → Channels to link channels and monitor status.",
|
||||||
systemImage: "link",
|
systemImage: "link")
|
||||||
buttonTitle: "Open Settings → Channels")
|
|
||||||
{
|
{
|
||||||
self.openSettings(tab: .channels)
|
self.openSettings(tab: .channels)
|
||||||
}
|
}
|
||||||
@@ -712,8 +711,7 @@ extension OnboardingView {
|
|||||||
self.featureActionRow(
|
self.featureActionRow(
|
||||||
title: "Give your agent more powers",
|
title: "Give your agent more powers",
|
||||||
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
subtitle: "Enable optional skills (Peekaboo, oracle, camsnap, …) from Settings → Skills.",
|
||||||
systemImage: "sparkles",
|
systemImage: "sparkles")
|
||||||
buttonTitle: "Open Settings → Skills")
|
|
||||||
{
|
{
|
||||||
self.openSettings(tab: .skills)
|
self.openSettings(tab: .skills)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,6 @@ extension OnboardingView {
|
|||||||
title: "Action",
|
title: "Action",
|
||||||
subtitle: "Action subtitle",
|
subtitle: "Action subtitle",
|
||||||
systemImage: "gearshape",
|
systemImage: "gearshape",
|
||||||
buttonTitle: "Action",
|
|
||||||
action: {})
|
action: {})
|
||||||
_ = view.gatewaySubtitle(for: gateway)
|
_ = view.gatewaySubtitle(for: gateway)
|
||||||
_ = view.isSelectedGateway(gateway)
|
_ = view.isSelectedGateway(gateway)
|
||||||
|
|||||||
@@ -3,13 +3,13 @@ import ClawdbotKit
|
|||||||
import OSLog
|
import OSLog
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct SessionPreviewItem: Identifiable, Sendable {
|
private struct SessionPreviewItem: Identifiable, Sendable {
|
||||||
let id: String
|
let id: String
|
||||||
let role: PreviewRole
|
let role: PreviewRole
|
||||||
let text: String
|
let text: String
|
||||||
}
|
}
|
||||||
|
|
||||||
enum PreviewRole: String, Sendable {
|
private enum PreviewRole: String, Sendable {
|
||||||
case user
|
case user
|
||||||
case assistant
|
case assistant
|
||||||
case tool
|
case tool
|
||||||
@@ -27,7 +27,7 @@ enum PreviewRole: String, Sendable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actor SessionPreviewCache {
|
private actor SessionPreviewCache {
|
||||||
static let shared = SessionPreviewCache()
|
static let shared = SessionPreviewCache()
|
||||||
|
|
||||||
private struct CacheEntry {
|
private struct CacheEntry {
|
||||||
@@ -52,33 +52,25 @@ actor SessionPreviewCache {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#if DEBUG
|
|
||||||
extension SessionPreviewCache {
|
|
||||||
func _testSet(items: [SessionPreviewItem], for sessionKey: String, updatedAt: Date = Date()) {
|
|
||||||
self.entries[sessionKey] = CacheEntry(items: items, updatedAt: updatedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
func _testReset() {
|
|
||||||
self.entries = [:]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
struct SessionMenuPreviewSnapshot: Sendable {
|
|
||||||
let items: [SessionPreviewItem]
|
|
||||||
let status: SessionMenuPreviewView.LoadStatus
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SessionMenuPreviewView: View {
|
struct SessionMenuPreviewView: View {
|
||||||
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
||||||
|
private static let previewTimeoutSeconds: Double = 4
|
||||||
|
|
||||||
|
let sessionKey: String
|
||||||
let width: CGFloat
|
let width: CGFloat
|
||||||
|
let maxItems: Int
|
||||||
let maxLines: Int
|
let maxLines: Int
|
||||||
let title: String
|
let title: String
|
||||||
let items: [SessionPreviewItem]
|
|
||||||
let status: LoadStatus
|
|
||||||
|
|
||||||
@Environment(\.menuItemHighlighted) private var isHighlighted
|
@Environment(\.menuItemHighlighted) private var isHighlighted
|
||||||
|
@State private var items: [SessionPreviewItem] = []
|
||||||
|
@State private var status: LoadStatus = .loading
|
||||||
|
|
||||||
enum LoadStatus: Equatable {
|
private struct PreviewTimeoutError: LocalizedError {
|
||||||
|
var errorDescription: String? { "preview timeout" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum LoadStatus: Equatable {
|
||||||
case loading
|
case loading
|
||||||
case ready
|
case ready
|
||||||
case empty
|
case empty
|
||||||
@@ -86,17 +78,15 @@ struct SessionMenuPreviewView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var primaryColor: Color {
|
private var primaryColor: Color {
|
||||||
if self.isHighlighted {
|
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor) : .primary
|
||||||
return Color(nsColor: .selectedMenuItemTextColor)
|
|
||||||
}
|
|
||||||
return Color(nsColor: .labelColor)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private var secondaryColor: Color {
|
private var secondaryColor: Color {
|
||||||
if self.isHighlighted {
|
self.isHighlighted ? Color(nsColor: .selectedMenuItemTextColor).opacity(0.85) : .secondary
|
||||||
return Color(nsColor: .selectedMenuItemTextColor).opacity(0.85)
|
}
|
||||||
}
|
|
||||||
return Color(nsColor: .secondaryLabelColor)
|
private var previewLimit: Int {
|
||||||
|
min(max(self.maxItems * 3, 20), 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -110,19 +100,21 @@ struct SessionMenuPreviewView: View {
|
|||||||
|
|
||||||
switch self.status {
|
switch self.status {
|
||||||
case .loading:
|
case .loading:
|
||||||
self.placeholder("Loading preview…")
|
Text("Loading preview…")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
case .empty:
|
case .empty:
|
||||||
self.placeholder("No recent messages")
|
Text("No recent messages")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
case let .error(message):
|
case let .error(message):
|
||||||
self.placeholder(message)
|
Text(message)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.secondaryColor)
|
||||||
case .ready:
|
case .ready:
|
||||||
if self.items.isEmpty {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
self.placeholder("No recent messages")
|
ForEach(self.items) { item in
|
||||||
} else {
|
self.previewRow(item)
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
ForEach(self.items) { item in
|
|
||||||
self.previewRow(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,6 +123,9 @@ struct SessionMenuPreviewView: View {
|
|||||||
.padding(.leading, 16)
|
.padding(.leading, 16)
|
||||||
.padding(.trailing, 11)
|
.padding(.trailing, 11)
|
||||||
.frame(width: max(1, self.width), alignment: .leading)
|
.frame(width: max(1, self.width), alignment: .leading)
|
||||||
|
.task(id: self.sessionKey) {
|
||||||
|
await self.loadPreview()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -162,66 +157,55 @@ struct SessionMenuPreviewView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
private func loadPreview() async {
|
||||||
private func placeholder(_ text: String) -> some View {
|
if let cached = await SessionPreviewCache.shared.cachedItems(for: self.sessionKey, maxAge: 12) {
|
||||||
Text(text)
|
await MainActor.run {
|
||||||
.font(.caption)
|
self.items = cached
|
||||||
.foregroundStyle(self.primaryColor)
|
self.status = cached.isEmpty ? .empty : .ready
|
||||||
}
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
}
|
await MainActor.run {
|
||||||
|
self.status = .loading
|
||||||
enum SessionMenuPreviewLoader {
|
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "SessionPreview")
|
|
||||||
private static let previewTimeoutSeconds: Double = 4
|
|
||||||
private static let cacheMaxAgeSeconds: TimeInterval = 30
|
|
||||||
|
|
||||||
private struct PreviewTimeoutError: LocalizedError {
|
|
||||||
var errorDescription: String? { "preview timeout" }
|
|
||||||
}
|
|
||||||
|
|
||||||
static func load(sessionKey: String, maxItems: Int) async -> SessionMenuPreviewSnapshot {
|
|
||||||
if let cached = await SessionPreviewCache.shared.cachedItems(for: sessionKey, maxAge: cacheMaxAgeSeconds) {
|
|
||||||
return Self.snapshot(from: cached)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let timeoutMs = Int(self.previewTimeoutSeconds * 1000)
|
let timeoutMs = Int(Self.previewTimeoutSeconds * 1000)
|
||||||
let payload = try await AsyncTimeout.withTimeout(
|
let payload = try await AsyncTimeout.withTimeout(
|
||||||
seconds: self.previewTimeoutSeconds,
|
seconds: Self.previewTimeoutSeconds,
|
||||||
onTimeout: { PreviewTimeoutError() },
|
onTimeout: { PreviewTimeoutError() },
|
||||||
operation: {
|
operation: {
|
||||||
try await GatewayConnection.shared.chatHistory(
|
try await GatewayConnection.shared.chatHistory(
|
||||||
sessionKey: sessionKey,
|
sessionKey: self.sessionKey,
|
||||||
limit: self.previewLimit(for: maxItems),
|
limit: self.previewLimit,
|
||||||
timeoutMs: timeoutMs)
|
timeoutMs: timeoutMs)
|
||||||
})
|
})
|
||||||
let built = Self.previewItems(from: payload, maxItems: maxItems)
|
let built = Self.previewItems(from: payload, maxItems: self.maxItems)
|
||||||
await SessionPreviewCache.shared.store(items: built, for: sessionKey)
|
await SessionPreviewCache.shared.store(items: built, for: self.sessionKey)
|
||||||
return Self.snapshot(from: built)
|
await MainActor.run {
|
||||||
|
self.items = built
|
||||||
|
self.status = built.isEmpty ? .empty : .ready
|
||||||
|
}
|
||||||
} catch is CancellationError {
|
} catch is CancellationError {
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .loading)
|
return
|
||||||
} catch {
|
} catch {
|
||||||
let fallback = await SessionPreviewCache.shared.lastItems(for: sessionKey)
|
let fallback = await SessionPreviewCache.shared.lastItems(for: self.sessionKey)
|
||||||
if let fallback {
|
await MainActor.run {
|
||||||
return Self.snapshot(from: fallback)
|
if let fallback {
|
||||||
|
self.items = fallback
|
||||||
|
self.status = fallback.isEmpty ? .empty : .ready
|
||||||
|
} else {
|
||||||
|
self.status = .error("Preview unavailable")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
let errorDescription = String(describing: error)
|
let errorDescription = String(describing: error)
|
||||||
Self.logger.warning(
|
Self.logger.warning(
|
||||||
"Session preview failed session=\(sessionKey, privacy: .public) " +
|
"Session preview failed session=\(self.sessionKey, privacy: .public) " +
|
||||||
"error=\(errorDescription, privacy: .public)")
|
"error=\(errorDescription, privacy: .public)")
|
||||||
return SessionMenuPreviewSnapshot(items: [], status: .error("Preview unavailable"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func snapshot(from items: [SessionPreviewItem]) -> SessionMenuPreviewSnapshot {
|
|
||||||
SessionMenuPreviewSnapshot(items: items, status: items.isEmpty ? .empty : .ready)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewLimit(for maxItems: Int) -> Int {
|
|
||||||
min(max(maxItems * 3, 20), 120)
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func previewItems(
|
private static func previewItems(
|
||||||
from payload: ClawdbotChatHistoryPayload,
|
from payload: ClawdbotChatHistoryPayload,
|
||||||
maxItems: Int) -> [SessionPreviewItem]
|
maxItems: Int) -> [SessionPreviewItem]
|
||||||
|
|||||||
@@ -13,16 +13,18 @@ struct SystemRunSettingsView: View {
|
|||||||
Text("Exec approvals")
|
Text("Exec approvals")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
Picker("Agent", selection: Binding(
|
if self.model.agentIds.count > 1 {
|
||||||
get: { self.model.selectedAgentId },
|
Picker("Agent", selection: Binding(
|
||||||
set: { self.model.selectAgent($0) }))
|
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)
|
ForEach(self.model.agentIds, id: \.self) { id in
|
||||||
|
Text(id).tag(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
.frame(width: 160, alignment: .trailing)
|
||||||
}
|
}
|
||||||
.pickerStyle(.menu)
|
|
||||||
.frame(width: 180, alignment: .trailing)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Picker("", selection: self.$tab) {
|
Picker("", selection: self.$tab) {
|
||||||
@@ -80,9 +82,7 @@ struct SystemRunSettingsView: View {
|
|||||||
.labelsHidden()
|
.labelsHidden()
|
||||||
.pickerStyle(.menu)
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
Text(self.model.isDefaultsScope
|
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
|
||||||
? "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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
@@ -101,37 +101,31 @@ struct SystemRunSettingsView: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.model.isDefaultsScope {
|
HStack(spacing: 8) {
|
||||||
Text("Allowlists are per-agent. Select an agent to edit its allowlist.")
|
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)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 8) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
|
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
|
||||||
.textFieldStyle(.roundedBorder)
|
ExecAllowlistRow(
|
||||||
Button("Add") {
|
entry: Binding(
|
||||||
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
get: { self.model.entries[index] },
|
||||||
guard !pattern.isEmpty else { return }
|
set: { self.model.updateEntry($0, at: index) }),
|
||||||
self.model.addEntry(pattern)
|
onRemove: { self.model.removeEntry(at: index) })
|
||||||
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) })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -183,16 +177,8 @@ struct ExecAllowlistRow: View {
|
|||||||
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
|
||||||
|
Text("Last used: \(lastUsedCommand)")
|
||||||
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)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
@@ -215,7 +201,6 @@ struct ExecAllowlistRow: View {
|
|||||||
@MainActor
|
@MainActor
|
||||||
@Observable
|
@Observable
|
||||||
final class ExecApprovalsSettingsModel {
|
final class ExecApprovalsSettingsModel {
|
||||||
private static let defaultsScopeId = "__defaults__"
|
|
||||||
var agentIds: [String] = []
|
var agentIds: [String] = []
|
||||||
var selectedAgentId: String = "main"
|
var selectedAgentId: String = "main"
|
||||||
var defaultAgentId: String = "main"
|
var defaultAgentId: String = "main"
|
||||||
@@ -226,19 +211,6 @@ final class ExecApprovalsSettingsModel {
|
|||||||
var entries: [ExecAllowlistEntry] = []
|
var entries: [ExecAllowlistEntry] = []
|
||||||
var skillBins: [String] = []
|
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 {
|
func refresh() async {
|
||||||
await self.refreshAgents()
|
await self.refreshAgents()
|
||||||
self.loadSettings(for: self.selectedAgentId)
|
self.loadSettings(for: self.selectedAgentId)
|
||||||
@@ -270,9 +242,6 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
self.agentIds = ids
|
self.agentIds = ids
|
||||||
self.defaultAgentId = defaultId ?? "main"
|
self.defaultAgentId = defaultId ?? "main"
|
||||||
if self.selectedAgentId == Self.defaultsScopeId {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if !self.agentIds.contains(self.selectedAgentId) {
|
if !self.agentIds.contains(self.selectedAgentId) {
|
||||||
self.selectedAgentId = self.defaultAgentId
|
self.selectedAgentId = self.defaultAgentId
|
||||||
}
|
}
|
||||||
@@ -285,15 +254,6 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func loadSettings(for agentId: String) {
|
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)
|
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
|
||||||
self.security = resolved.agent.security
|
self.security = resolved.agent.security
|
||||||
self.ask = resolved.agent.ask
|
self.ask = resolved.agent.ask
|
||||||
@@ -305,61 +265,36 @@ final class ExecApprovalsSettingsModel {
|
|||||||
|
|
||||||
func setSecurity(_ security: ExecSecurity) {
|
func setSecurity(_ security: ExecSecurity) {
|
||||||
self.security = security
|
self.security = security
|
||||||
if self.isDefaultsScope {
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
entry.security = security
|
||||||
defaults.security = security
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.security = security
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAsk(_ ask: ExecAsk) {
|
func setAsk(_ ask: ExecAsk) {
|
||||||
self.ask = ask
|
self.ask = ask
|
||||||
if self.isDefaultsScope {
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
entry.ask = ask
|
||||||
defaults.ask = ask
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.ask = ask
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
self.syncQuickMode()
|
self.syncQuickMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAskFallback(_ mode: ExecSecurity) {
|
func setAskFallback(_ mode: ExecSecurity) {
|
||||||
self.askFallback = mode
|
self.askFallback = mode
|
||||||
if self.isDefaultsScope {
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
entry.askFallback = mode
|
||||||
defaults.askFallback = mode
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.askFallback = mode
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func setAutoAllowSkills(_ enabled: Bool) {
|
func setAutoAllowSkills(_ enabled: Bool) {
|
||||||
self.autoAllowSkills = enabled
|
self.autoAllowSkills = enabled
|
||||||
if self.isDefaultsScope {
|
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
||||||
ExecApprovalsStore.updateDefaults { defaults in
|
entry.autoAllowSkills = enabled
|
||||||
defaults.autoAllowSkills = enabled
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
|
|
||||||
entry.autoAllowSkills = enabled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Task { await self.refreshSkillBins(force: enabled) }
|
Task { await self.refreshSkillBins(force: enabled) }
|
||||||
}
|
}
|
||||||
|
|
||||||
func addEntry(_ pattern: String) {
|
func addEntry(_ pattern: String) {
|
||||||
guard !self.isDefaultsScope else { return }
|
|
||||||
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
guard !trimmed.isEmpty else { return }
|
guard !trimmed.isEmpty else { return }
|
||||||
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
|
||||||
@@ -367,14 +302,12 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
|
||||||
guard !self.isDefaultsScope else { return }
|
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries[index] = entry
|
self.entries[index] = entry
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeEntry(at index: Int) {
|
func removeEntry(at index: Int) {
|
||||||
guard !self.isDefaultsScope else { return }
|
|
||||||
guard self.entries.indices.contains(index) else { return }
|
guard self.entries.indices.contains(index) else { return }
|
||||||
self.entries.remove(at: index)
|
self.entries.remove(at: index)
|
||||||
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
|
||||||
@@ -390,10 +323,6 @@ final class ExecApprovalsSettingsModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func syncQuickMode() {
|
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 {
|
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
|
||||||
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
struct GatewayCostUsageTotals: Codable {
|
|
||||||
let input: Int
|
|
||||||
let output: Int
|
|
||||||
let cacheRead: Int
|
|
||||||
let cacheWrite: Int
|
|
||||||
let totalTokens: Int
|
|
||||||
let totalCost: Double
|
|
||||||
let missingCostEntries: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GatewayCostUsageDay: Codable {
|
|
||||||
let date: String
|
|
||||||
let input: Int
|
|
||||||
let output: Int
|
|
||||||
let cacheRead: Int
|
|
||||||
let cacheWrite: Int
|
|
||||||
let totalTokens: Int
|
|
||||||
let totalCost: Double
|
|
||||||
let missingCostEntries: Int
|
|
||||||
}
|
|
||||||
|
|
||||||
struct GatewayCostUsageSummary: Codable {
|
|
||||||
let updatedAt: Double
|
|
||||||
let days: Int
|
|
||||||
let daily: [GatewayCostUsageDay]
|
|
||||||
let totals: GatewayCostUsageTotals
|
|
||||||
}
|
|
||||||
|
|
||||||
enum CostUsageFormatting {
|
|
||||||
static func formatUsd(_ value: Double?) -> String? {
|
|
||||||
guard let value, value.isFinite else { return nil }
|
|
||||||
if value >= 1 { return String(format: "$%.2f", value) }
|
|
||||||
if value >= 0.01 { return String(format: "$%.2f", value) }
|
|
||||||
return String(format: "$%.4f", value)
|
|
||||||
}
|
|
||||||
|
|
||||||
static func formatTokenCount(_ value: Int?) -> String? {
|
|
||||||
guard let value else { return nil }
|
|
||||||
let safe = max(0, value)
|
|
||||||
if safe >= 1_000_000 { return String(format: "%.1fm", Double(safe) / 1_000_000.0) }
|
|
||||||
if safe >= 1000 { return safe >= 10000
|
|
||||||
? String(format: "%.0fk", Double(safe) / 1000.0)
|
|
||||||
: String(format: "%.1fk", Double(safe) / 1000.0)
|
|
||||||
}
|
|
||||||
return String(safe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
|
||||||
enum CostUsageLoader {
|
|
||||||
static func loadSummary() async throws -> GatewayCostUsageSummary {
|
|
||||||
let data = try await ControlChannel.shared.request(
|
|
||||||
method: "usage.cost",
|
|
||||||
params: nil,
|
|
||||||
timeoutMs: 7000)
|
|
||||||
return try JSONDecoder().decode(GatewayCostUsageSummary.self, from: data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -75,6 +75,18 @@ struct UsageRow: Identifiable {
|
|||||||
extension GatewayUsageSummary {
|
extension GatewayUsageSummary {
|
||||||
func primaryRows() -> [UsageRow] {
|
func primaryRows() -> [UsageRow] {
|
||||||
self.providers.compactMap { provider in
|
self.providers.compactMap { provider in
|
||||||
|
if let error = provider.error, provider.windows.isEmpty {
|
||||||
|
return UsageRow(
|
||||||
|
id: provider.provider,
|
||||||
|
providerId: provider.provider,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
plan: provider.plan,
|
||||||
|
windowLabel: nil,
|
||||||
|
usedPercent: nil,
|
||||||
|
resetAt: nil,
|
||||||
|
error: error)
|
||||||
|
}
|
||||||
|
|
||||||
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
guard let window = provider.windows.max(by: { $0.usedPercent < $1.usedPercent }) else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -87,7 +99,7 @@ extension GatewayUsageSummary {
|
|||||||
windowLabel: window.label,
|
windowLabel: window.label,
|
||||||
usedPercent: window.usedPercent,
|
usedPercent: window.usedPercent,
|
||||||
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
resetAt: window.resetAt.map { Date(timeIntervalSince1970: $0 / 1000) },
|
||||||
error: nil)
|
error: provider.error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,14 @@ struct UsageMenuLabelView: View {
|
|||||||
|
|
||||||
Spacer(minLength: 4)
|
Spacer(minLength: 4)
|
||||||
|
|
||||||
Text(self.row.detailText())
|
if !self.row.hasError {
|
||||||
.font(.caption.monospacedDigit())
|
Text(self.row.detailText())
|
||||||
.foregroundStyle(self.secondaryTextColor)
|
.font(.caption.monospacedDigit())
|
||||||
.lineLimit(1)
|
.foregroundStyle(self.secondaryTextColor)
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
.layoutPriority(2)
|
.truncationMode(.tail)
|
||||||
|
.layoutPriority(2)
|
||||||
|
}
|
||||||
|
|
||||||
if self.showsChevron {
|
if self.showsChevron {
|
||||||
Image(systemName: "chevron.right")
|
Image(systemName: "chevron.right")
|
||||||
@@ -52,6 +54,15 @@ struct UsageMenuLabelView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let error = self.row.error?.nonEmpty {
|
||||||
|
Text(error)
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(self.secondaryTextColor)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 10)
|
||||||
.padding(.leading, self.paddingLeading)
|
.padding(.leading, self.paddingLeading)
|
||||||
|
|||||||
@@ -424,18 +424,14 @@ public struct PollParams: Codable, Sendable {
|
|||||||
|
|
||||||
public struct AgentParams: Codable, Sendable {
|
public struct AgentParams: Codable, Sendable {
|
||||||
public let message: String
|
public let message: String
|
||||||
public let agentid: String?
|
|
||||||
public let to: String?
|
public let to: String?
|
||||||
public let replyto: String?
|
|
||||||
public let sessionid: String?
|
public let sessionid: String?
|
||||||
public let sessionkey: String?
|
public let sessionkey: String?
|
||||||
public let thinking: String?
|
public let thinking: String?
|
||||||
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 replychannel: String?
|
|
||||||
public let accountid: String?
|
public let accountid: String?
|
||||||
public let replyaccountid: 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?
|
||||||
@@ -445,18 +441,14 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
|
|
||||||
public init(
|
public init(
|
||||||
message: String,
|
message: String,
|
||||||
agentid: String?,
|
|
||||||
to: String?,
|
to: String?,
|
||||||
replyto: String?,
|
|
||||||
sessionid: String?,
|
sessionid: String?,
|
||||||
sessionkey: String?,
|
sessionkey: String?,
|
||||||
thinking: String?,
|
thinking: String?,
|
||||||
deliver: Bool?,
|
deliver: Bool?,
|
||||||
attachments: [AnyCodable]?,
|
attachments: [AnyCodable]?,
|
||||||
channel: String?,
|
channel: String?,
|
||||||
replychannel: String?,
|
|
||||||
accountid: String?,
|
accountid: String?,
|
||||||
replyaccountid: String?,
|
|
||||||
timeout: Int?,
|
timeout: Int?,
|
||||||
lane: String?,
|
lane: String?,
|
||||||
extrasystemprompt: String?,
|
extrasystemprompt: String?,
|
||||||
@@ -465,18 +457,14 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
spawnedby: String?
|
spawnedby: String?
|
||||||
) {
|
) {
|
||||||
self.message = message
|
self.message = message
|
||||||
self.agentid = agentid
|
|
||||||
self.to = to
|
self.to = to
|
||||||
self.replyto = replyto
|
|
||||||
self.sessionid = sessionid
|
self.sessionid = sessionid
|
||||||
self.sessionkey = sessionkey
|
self.sessionkey = sessionkey
|
||||||
self.thinking = thinking
|
self.thinking = thinking
|
||||||
self.deliver = deliver
|
self.deliver = deliver
|
||||||
self.attachments = attachments
|
self.attachments = attachments
|
||||||
self.channel = channel
|
self.channel = channel
|
||||||
self.replychannel = replychannel
|
|
||||||
self.accountid = accountid
|
self.accountid = accountid
|
||||||
self.replyaccountid = replyaccountid
|
|
||||||
self.timeout = timeout
|
self.timeout = timeout
|
||||||
self.lane = lane
|
self.lane = lane
|
||||||
self.extrasystemprompt = extrasystemprompt
|
self.extrasystemprompt = extrasystemprompt
|
||||||
@@ -486,18 +474,14 @@ public struct AgentParams: Codable, Sendable {
|
|||||||
}
|
}
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case message
|
case message
|
||||||
case agentid = "agentId"
|
|
||||||
case to
|
case to
|
||||||
case replyto = "replyTo"
|
|
||||||
case sessionid = "sessionId"
|
case sessionid = "sessionId"
|
||||||
case sessionkey = "sessionKey"
|
case sessionkey = "sessionKey"
|
||||||
case thinking
|
case thinking
|
||||||
case deliver
|
case deliver
|
||||||
case attachments
|
case attachments
|
||||||
case channel
|
case channel
|
||||||
case replychannel = "replyChannel"
|
|
||||||
case accountid = "accountId"
|
case accountid = "accountId"
|
||||||
case replyaccountid = "replyAccountId"
|
|
||||||
case timeout
|
case timeout
|
||||||
case lane
|
case lane
|
||||||
case extrasystemprompt = "extraSystemPrompt"
|
case extrasystemprompt = "extraSystemPrompt"
|
||||||
@@ -546,8 +530,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
public let displayname: String?
|
public let displayname: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreversion: String?
|
|
||||||
public let uiversion: String?
|
|
||||||
public let devicefamily: String?
|
public let devicefamily: String?
|
||||||
public let modelidentifier: String?
|
public let modelidentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -560,8 +542,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
displayname: String?,
|
displayname: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreversion: String?,
|
|
||||||
uiversion: String?,
|
|
||||||
devicefamily: String?,
|
devicefamily: String?,
|
||||||
modelidentifier: String?,
|
modelidentifier: String?,
|
||||||
caps: [String]?,
|
caps: [String]?,
|
||||||
@@ -573,8 +553,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
self.displayname = displayname
|
self.displayname = displayname
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreversion = coreversion
|
|
||||||
self.uiversion = uiversion
|
|
||||||
self.devicefamily = devicefamily
|
self.devicefamily = devicefamily
|
||||||
self.modelidentifier = modelidentifier
|
self.modelidentifier = modelidentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -587,8 +565,6 @@ public struct NodePairRequestParams: Codable, Sendable {
|
|||||||
case displayname = "displayName"
|
case displayname = "displayName"
|
||||||
case platform
|
case platform
|
||||||
case version
|
case version
|
||||||
case coreversion = "coreVersion"
|
|
||||||
case uiversion = "uiVersion"
|
|
||||||
case devicefamily = "deviceFamily"
|
case devicefamily = "deviceFamily"
|
||||||
case modelidentifier = "modelIdentifier"
|
case modelidentifier = "modelIdentifier"
|
||||||
case caps
|
case caps
|
||||||
@@ -1656,85 +1632,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 ExecApprovalsNodeGetParams: Codable, Sendable {
|
|
||||||
public let nodeid: String
|
|
||||||
|
|
||||||
public init(
|
|
||||||
nodeid: String
|
|
||||||
) {
|
|
||||||
self.nodeid = nodeid
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case nodeid = "nodeId"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public struct ExecApprovalsNodeSetParams: Codable, Sendable {
|
|
||||||
public let nodeid: String
|
|
||||||
public let file: [String: AnyCodable]
|
|
||||||
public let basehash: String?
|
|
||||||
|
|
||||||
public init(
|
|
||||||
nodeid: String,
|
|
||||||
file: [String: AnyCodable],
|
|
||||||
basehash: String?
|
|
||||||
) {
|
|
||||||
self.nodeid = nodeid
|
|
||||||
self.file = file
|
|
||||||
self.basehash = basehash
|
|
||||||
}
|
|
||||||
private enum CodingKeys: String, CodingKey {
|
|
||||||
case nodeid = "nodeId"
|
|
||||||
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?
|
||||||
|
|||||||
@@ -134,27 +134,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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
import Testing
|
|
||||||
@testable import Clawdbot
|
|
||||||
|
|
||||||
@Suite(.serialized)
|
|
||||||
struct SessionMenuPreviewTests {
|
|
||||||
@Test func loaderReturnsCachedItems() async {
|
|
||||||
await SessionPreviewCache.shared._testReset()
|
|
||||||
let items = [SessionPreviewItem(id: "1", role: .user, text: "Hi")]
|
|
||||||
await SessionPreviewCache.shared._testSet(items: items, for: "main")
|
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
|
||||||
#expect(snapshot.status == .ready)
|
|
||||||
#expect(snapshot.items.count == 1)
|
|
||||||
#expect(snapshot.items.first?.text == "Hi")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test func loaderReturnsEmptyWhenCachedEmpty() async {
|
|
||||||
await SessionPreviewCache.shared._testReset()
|
|
||||||
await SessionPreviewCache.shared._testSet(items: [], for: "main")
|
|
||||||
|
|
||||||
let snapshot = await SessionMenuPreviewLoader.load(sessionKey: "main", maxItems: 10)
|
|
||||||
#expect(snapshot.status == .empty)
|
|
||||||
#expect(snapshot.items.isEmpty)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -63,8 +63,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
public let token: String?
|
public let token: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreVersion: String?
|
|
||||||
public let uiVersion: String?
|
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -78,8 +76,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
token: String?,
|
token: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreVersion: String? = nil,
|
|
||||||
uiVersion: String? = nil,
|
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -92,8 +88,6 @@ public struct BridgeHello: Codable, Sendable {
|
|||||||
self.token = token
|
self.token = token
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreVersion = coreVersion
|
|
||||||
self.uiVersion = uiVersion
|
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
@@ -127,8 +121,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
public let displayName: String?
|
public let displayName: String?
|
||||||
public let platform: String?
|
public let platform: String?
|
||||||
public let version: String?
|
public let version: String?
|
||||||
public let coreVersion: String?
|
|
||||||
public let uiVersion: String?
|
|
||||||
public let deviceFamily: String?
|
public let deviceFamily: String?
|
||||||
public let modelIdentifier: String?
|
public let modelIdentifier: String?
|
||||||
public let caps: [String]?
|
public let caps: [String]?
|
||||||
@@ -143,8 +135,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
displayName: String?,
|
displayName: String?,
|
||||||
platform: String?,
|
platform: String?,
|
||||||
version: String?,
|
version: String?,
|
||||||
coreVersion: String? = nil,
|
|
||||||
uiVersion: String? = nil,
|
|
||||||
deviceFamily: String? = nil,
|
deviceFamily: String? = nil,
|
||||||
modelIdentifier: String? = nil,
|
modelIdentifier: String? = nil,
|
||||||
caps: [String]? = nil,
|
caps: [String]? = nil,
|
||||||
@@ -158,8 +148,6 @@ public struct BridgePairRequest: Codable, Sendable {
|
|||||||
self.displayName = displayName
|
self.displayName = displayName
|
||||||
self.platform = platform
|
self.platform = platform
|
||||||
self.version = version
|
self.version = version
|
||||||
self.coreVersion = coreVersion
|
|
||||||
self.uiVersion = uiVersion
|
|
||||||
self.deviceFamily = deviceFamily
|
self.deviceFamily = deviceFamily
|
||||||
self.modelIdentifier = modelIdentifier
|
self.modelIdentifier = modelIdentifier
|
||||||
self.caps = caps
|
self.caps = caps
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
|||||||
case run = "system.run"
|
case run = "system.run"
|
||||||
case which = "system.which"
|
case which = "system.which"
|
||||||
case notify = "system.notify"
|
case notify = "system.notify"
|
||||||
case execApprovalsGet = "system.execApprovals.get"
|
|
||||||
case execApprovalsSet = "system.execApprovals.set"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
public enum ClawdbotNotificationPriority: String, Codable, Sendable {
|
||||||
@@ -22,7 +20,6 @@ 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?
|
||||||
@@ -32,7 +29,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
|
|
||||||
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,
|
||||||
@@ -41,7 +37,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
sessionKey: 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
|
||||||
|
|||||||
-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`
|
|
||||||
@@ -287,7 +287,7 @@ ack reaction after the bot replies.
|
|||||||
|
|
||||||
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
- `dm.enabled`: set `false` to ignore all DMs (default `true`).
|
||||||
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
- `dm.policy`: DM access control (`pairing` recommended). `"open"` requires `dm.allowFrom=["*"]`.
|
||||||
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation. The wizard accepts usernames and resolves them to ids when the bot can search members.
|
- `dm.allowFrom`: DM allowlist (user ids or names). Used by `dm.policy="allowlist"` and for `dm.policy="open"` validation.
|
||||||
- `dm.groupEnabled`: enable group DMs (default `false`).
|
- `dm.groupEnabled`: enable group DMs (default `false`).
|
||||||
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
- `dm.groupChannels`: optional allowlist for group DM channel ids or slugs.
|
||||||
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
|
- `groupPolicy`: controls guild channel handling (`open|disabled|allowlist`); `allowlist` requires channel allowlists.
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ Provider options:
|
|||||||
- `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).
|
||||||
- `channels.imessage.allowFrom`: DM allowlist (handles, emails, E.164 numbers, or `chat_id:*`). `open` requires `"*"`. iMessage has no usernames; use handles or chat targets.
|
- `channels.imessage.allowFrom`: DM allowlist (handles or `chat_id:*`). `open` requires `"*"`.
|
||||||
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
- `channels.imessage.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||||
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
- `channels.imessage.groupAllowFrom`: group sender allowlist.
|
||||||
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
- `channels.imessage.historyLimit` / `channels.imessage.accounts.*.historyLimit`: max group messages to include as context (0 disables).
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ 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. The wizard resolves display names to user IDs when directory search is available.
|
- `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). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
@@ -121,7 +121,7 @@ Provider options:
|
|||||||
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
- `channels.matrix.threadReplies`: `off | inbound | always` (default: inbound).
|
||||||
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
- `channels.matrix.textChunkLimit`: outbound text chunk size (chars).
|
||||||
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.matrix.dm.policy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.matrix.dm.allowFrom`: DM allowlist (user IDs or display names). `open` requires `"*"`. The wizard resolves names to IDs when possible.
|
- `channels.matrix.dm.allowFrom`: DM allowlist. `open` requires `"*"`.
|
||||||
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
- `channels.matrix.groupPolicy`: `allowlist | open | disabled` (default: allowlist).
|
||||||
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
- `channels.matrix.allowlistOnly`: force allowlist rules for DMs + rooms.
|
||||||
- `channels.matrix.rooms`: per-room settings and allowlist.
|
- `channels.matrix.rooms`: per-room settings and allowlist.
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ 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. The wizard resolves names to IDs via Microsoft Graph when credentials allow.
|
- `channels.msteams.allowFrom` accepts AAD object IDs, UPNs, or display names (resolved at startup when Graph allows).
|
||||||
|
|
||||||
**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`). Use `channels.defaults.groupPolicy` to override the default when unset.
|
||||||
@@ -413,7 +413,7 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
|
|||||||
- `channels.msteams.webhook.port` (default `3978`)
|
- `channels.msteams.webhook.port` (default `3978`)
|
||||||
- `channels.msteams.webhook.path` (default `/api/messages`)
|
- `channels.msteams.webhook.path` (default `/api/messages`)
|
||||||
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
- `channels.msteams.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing)
|
||||||
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs, UPNs, or display names). The wizard resolves names to IDs during setup when Graph access is available.
|
- `channels.msteams.allowFrom`: allowlist for DMs (AAD object IDs or UPNs).
|
||||||
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
- `channels.msteams.textChunkLimit`: outbound text chunk size.
|
||||||
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
- `channels.msteams.mediaAllowHosts`: allowlist for inbound attachment hosts (defaults to Microsoft/Teams domains).
|
||||||
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
- `channels.msteams.requireMention`: require @mention in channels/groups (default true).
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ Provider options:
|
|||||||
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
- `channels.signal.ignoreStories`: ignore stories from the daemon.
|
||||||
- `channels.signal.sendReadReceipts`: forward read receipts.
|
- `channels.signal.sendReadReceipts`: forward read receipts.
|
||||||
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.signal.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`. Signal has no usernames; use phone/UUID ids.
|
- `channels.signal.allowFrom`: DM allowlist (E.164 or `uuid:<id>`). `open` requires `"*"`.
|
||||||
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
- `channels.signal.groupPolicy`: `open | allowlist | disabled` (default: allowlist).
|
||||||
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
- `channels.signal.groupAllowFrom`: group sender allowlist.
|
||||||
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
- `channels.signal.historyLimit`: max group messages to include as context (0 disables).
|
||||||
|
|||||||
+11
-46
@@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
summary: "Slack setup for socket or HTTP webhook mode"
|
summary: "Slack socket mode setup and Clawdbot config"
|
||||||
read_when: "Setting up Slack or debugging Slack socket/HTTP mode"
|
read_when: "Setting up Slack or debugging Slack socket mode"
|
||||||
---
|
---
|
||||||
|
|
||||||
# Slack
|
# Slack (socket mode)
|
||||||
|
|
||||||
## Socket mode (default)
|
## Quick setup (beginner)
|
||||||
|
|
||||||
### Quick setup (beginner)
|
|
||||||
1) Create a Slack app and enable **Socket Mode**.
|
1) Create a Slack app and enable **Socket Mode**.
|
||||||
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
2) Create an **App Token** (`xapp-...`) and **Bot Token** (`xoxb-...`).
|
||||||
3) Set tokens for Clawdbot and start the gateway.
|
3) Set tokens for Clawdbot and start the gateway.
|
||||||
@@ -25,7 +23,7 @@ Minimal config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Setup
|
## Setup
|
||||||
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
1) Create a Slack app (From scratch) in https://api.channels.slack.com/apps.
|
||||||
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
2) **Socket Mode** → toggle on. Then go to **Basic Information** → **App-Level Tokens** → **Generate Token and Scopes** with scope `connections:write`. Copy the **App Token** (`xapp-...`).
|
||||||
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
3) **OAuth & Permissions** → add bot token scopes (use the manifest below). Click **Install to Workspace**. Copy the **Bot User OAuth Token** (`xoxb-...`).
|
||||||
@@ -45,7 +43,7 @@ Use the manifest below so scopes and events stay in sync.
|
|||||||
|
|
||||||
Multi-account support: use `channels.slack.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.slack.accounts` with per-account tokens and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
|
||||||
|
|
||||||
### Clawdbot config (minimal)
|
## Clawdbot config (minimal)
|
||||||
|
|
||||||
Set tokens via env vars (recommended):
|
Set tokens via env vars (recommended):
|
||||||
- `SLACK_APP_TOKEN=xapp-...`
|
- `SLACK_APP_TOKEN=xapp-...`
|
||||||
@@ -65,7 +63,7 @@ Or via config:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### User token (optional)
|
## User token (optional)
|
||||||
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
Clawdbot can use a Slack user token (`xoxp-...`) for read operations (history,
|
||||||
pins, reactions, emoji, member info). By default this stays read-only: reads
|
pins, reactions, emoji, member info). By default this stays read-only: reads
|
||||||
prefer the user token when present, and writes still use the bot token unless
|
prefer the user token when present, and writes still use the bot token unless
|
||||||
@@ -104,51 +102,18 @@ Example with userTokenReadOnly explicitly set (allow user token writes):
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Token usage
|
### Token usage
|
||||||
- Read operations (history, reactions list, pins list, emoji list, member info,
|
- Read operations (history, reactions list, pins list, emoji list, member info,
|
||||||
search) prefer the user token when configured, otherwise the bot token.
|
search) prefer the user token when configured, otherwise the bot token.
|
||||||
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
- Write operations (send/edit/delete messages, add/remove reactions, pin/unpin,
|
||||||
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
file uploads) use the bot token by default. If `userTokenReadOnly: false` and
|
||||||
no bot token is available, Clawdbot falls back to the user token.
|
no bot token is available, Clawdbot falls back to the user token.
|
||||||
|
|
||||||
### History context
|
## History context
|
||||||
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
- `channels.slack.historyLimit` (or `channels.slack.accounts.*.historyLimit`) controls how many recent channel/group messages are wrapped into the prompt.
|
||||||
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
- Falls back to `messages.groupChat.historyLimit`. Set `0` to disable (default 50).
|
||||||
|
|
||||||
## HTTP mode (Events API)
|
## Manifest (optional)
|
||||||
Use HTTP webhook mode when your Gateway is reachable by Slack over HTTPS (typical for server deployments).
|
|
||||||
HTTP mode uses the Events API + Interactivity + Slash Commands with a shared request URL.
|
|
||||||
|
|
||||||
### Setup
|
|
||||||
1) Create a Slack app and **disable Socket Mode** (optional if you only use HTTP).
|
|
||||||
2) **Basic Information** → copy the **Signing Secret**.
|
|
||||||
3) **OAuth & Permissions** → install the app and copy the **Bot User OAuth Token** (`xoxb-...`).
|
|
||||||
4) **Event Subscriptions** → enable events and set the **Request URL** to your gateway webhook path (default `/slack/events`).
|
|
||||||
5) **Interactivity & Shortcuts** → enable and set the same **Request URL**.
|
|
||||||
6) **Slash Commands** → set the same **Request URL** for your command(s).
|
|
||||||
|
|
||||||
Example request URL:
|
|
||||||
`https://gateway-host/slack/events`
|
|
||||||
|
|
||||||
### Clawdbot config (minimal)
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
channels: {
|
|
||||||
slack: {
|
|
||||||
enabled: true,
|
|
||||||
mode: "http",
|
|
||||||
botToken: "xoxb-...",
|
|
||||||
signingSecret: "your-signing-secret",
|
|
||||||
webhookPath: "/slack/events"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Multi-account HTTP mode: set `channels.slack.accounts.<id>.mode = "http"` and provide a unique
|
|
||||||
`webhookPath` per account so each Slack app can point to its own URL.
|
|
||||||
|
|
||||||
### Manifest (optional)
|
|
||||||
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
Use this Slack app manifest to create the app quickly (adjust the name/command if you want). Include the
|
||||||
user scopes if you plan to configure a user token.
|
user scopes if you plan to configure a user token.
|
||||||
|
|
||||||
@@ -378,7 +343,7 @@ 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). The wizard accepts usernames and resolves them to ids during setup when tokens allow.
|
- `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`).
|
||||||
|
|||||||
@@ -305,7 +305,7 @@ Use the global setting when all Telegram bots/accounts should behave the same. U
|
|||||||
- `clawdbot pairing list telegram`
|
- `clawdbot pairing list telegram`
|
||||||
- `clawdbot pairing approve telegram <CODE>`
|
- `clawdbot pairing approve telegram <CODE>`
|
||||||
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
- Pairing is the default token exchange used for Telegram DMs. Details: [Pairing](/start/pairing)
|
||||||
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID. The wizard accepts `@username` and resolves it to the numeric ID when possible.
|
- `channels.telegram.allowFrom` accepts numeric user IDs (recommended) or `@username` entries. It is **not** the bot username; use the human sender’s ID.
|
||||||
|
|
||||||
#### Finding your Telegram user ID
|
#### Finding your Telegram user ID
|
||||||
Safer (no third-party bot):
|
Safer (no third-party bot):
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ WhatsApp can automatically send emoji reactions to incoming messages immediately
|
|||||||
## Config quick map
|
## Config quick map
|
||||||
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
- `channels.whatsapp.dmPolicy` (DM policy: pairing/allowlist/open/disabled).
|
||||||
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
- `channels.whatsapp.selfChatMode` (same-phone setup; bot uses your personal WhatsApp number).
|
||||||
- `channels.whatsapp.allowFrom` (DM allowlist). WhatsApp uses E.164 phone numbers (no usernames).
|
- `channels.whatsapp.allowFrom` (DM allowlist).
|
||||||
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
|
- `channels.whatsapp.mediaMaxMb` (inbound media save cap).
|
||||||
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
- `channels.whatsapp.ackReaction` (auto-reaction on message receipt: `{emoji, direct, group}`).
|
||||||
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
- `channels.whatsapp.accounts.<accountId>.*` (per-account settings + optional `authDir`).
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ Multi-account support: use `channels.zalo.accounts` with per-account tokens and
|
|||||||
- `clawdbot pairing list zalo`
|
- `clawdbot pairing list zalo`
|
||||||
- `clawdbot pairing approve zalo <CODE>`
|
- `clawdbot pairing approve zalo <CODE>`
|
||||||
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
|
||||||
- `channels.zalo.allowFrom` accepts numeric user IDs (no username lookup available).
|
- `channels.zalo.allowFrom` accepts numeric user IDs.
|
||||||
|
|
||||||
## Long-polling vs webhook
|
## Long-polling vs webhook
|
||||||
- Default: long-polling (no public URL required).
|
- Default: long-polling (no public URL required).
|
||||||
@@ -147,7 +147,7 @@ Provider options:
|
|||||||
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
|
||||||
- `channels.zalo.tokenFile`: read token from file path.
|
- `channels.zalo.tokenFile`: read token from file path.
|
||||||
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
|
||||||
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. The wizard will ask for numeric IDs.
|
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
|
||||||
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
|
||||||
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
|
||||||
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ clawdbot directory groups list --channel zalouser --query "work"
|
|||||||
|
|
||||||
## Access control (DMs)
|
## Access control (DMs)
|
||||||
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
`channels.zalouser.dmPolicy` supports: `pairing | allowlist | open | disabled` (default: `pairing`).
|
||||||
`channels.zalouser.allowFrom` accepts user IDs or names. The wizard resolves names to IDs via `zca friend find` when available.
|
`channels.zalouser.allowFrom` accepts user IDs or names (resolved at startup when available).
|
||||||
|
|
||||||
Approve via:
|
Approve via:
|
||||||
- `clawdbot pairing list zalouser`
|
- `clawdbot pairing list zalouser`
|
||||||
|
|||||||
-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.
|
|
||||||
+1
-3
@@ -7,7 +7,6 @@ read_when:
|
|||||||
# `clawdbot agent`
|
# `clawdbot agent`
|
||||||
|
|
||||||
Run an agent turn via the Gateway (use `--local` for embedded).
|
Run an agent turn via the Gateway (use `--local` for embedded).
|
||||||
Use `--agent <id>` to target a configured agent directly.
|
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Agent send tool: [Agent send](/tools/agent-send)
|
- Agent send tool: [Agent send](/tools/agent-send)
|
||||||
@@ -16,7 +15,6 @@ Related:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
clawdbot agent --to +15555550123 --message "status update" --deliver
|
clawdbot agent --to +15555550123 --message "status update" --deliver
|
||||||
clawdbot agent --agent ops --message "Summarize logs"
|
|
||||||
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
clawdbot agent --session-id 1234 --message "Summarize inbox" --thinking medium
|
||||||
clawdbot agent --agent ops --message "Generate report" --deliver --reply-channel slack --reply-to "#reports"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot approvals` (exec approvals for gateway or node hosts)"
|
|
||||||
read_when:
|
|
||||||
- You want to edit exec approvals from the CLI
|
|
||||||
- You need to manage allowlists on gateway or node hosts
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot approvals`
|
|
||||||
|
|
||||||
Manage exec approvals for the **gateway host** or a **node host**.
|
|
||||||
By default, commands target the gateway. Use `--node` to edit a node’s approvals.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- Exec approvals: [Exec approvals](/tools/exec-approvals)
|
|
||||||
- Nodes: [Nodes](/nodes)
|
|
||||||
|
|
||||||
## Common commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals get
|
|
||||||
clawdbot approvals get --node <id|name|ip>
|
|
||||||
```
|
|
||||||
|
|
||||||
## Replace approvals from a file
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals set --file ./exec-approvals.json
|
|
||||||
clawdbot approvals set --node <id|name|ip> --file ./exec-approvals.json
|
|
||||||
```
|
|
||||||
|
|
||||||
## Allowlist helpers
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot approvals allowlist add "~/Projects/**/bin/rg"
|
|
||||||
clawdbot approvals allowlist add --agent main --node <id|name|ip> "/usr/bin/uptime"
|
|
||||||
|
|
||||||
clawdbot approvals allowlist remove "~/Projects/**/bin/rg"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- `--node` uses the same resolver as `clawdbot nodes` (id, name, ip, or id prefix).
|
|
||||||
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
|
|
||||||
- Approvals files are stored per host at `~/.clawdbot/exec-approvals.json`.
|
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -9,9 +9,6 @@ read_when:
|
|||||||
|
|
||||||
Manage the Gateway daemon (background service).
|
Manage the Gateway daemon (background service).
|
||||||
|
|
||||||
Note: `clawdbot service gateway …` is the preferred surface; `daemon` remains
|
|
||||||
as a legacy alias for compatibility.
|
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Gateway CLI: [Gateway](/cli/gateway)
|
- Gateway CLI: [Gateway](/cli/gateway)
|
||||||
- macOS platform notes: [macOS](/platforms/macos)
|
- macOS platform notes: [macOS](/platforms/macos)
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ clawdbot gateway
|
|||||||
Notes:
|
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 when authorized (enable `commands.restart` or use the gateway tool/config apply/update).
|
- `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.
|
- `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
|
||||||
|
|||||||
+4
-19
@@ -7,7 +7,7 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot hooks`
|
# `clawdbot hooks`
|
||||||
|
|
||||||
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, and gateway startup).
|
Manage agent hooks (event-driven automations for commands like `/new`, `/reset`, etc.).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Hooks: [Hooks](/hooks)
|
- Hooks: [Hooks](/hooks)
|
||||||
@@ -29,10 +29,9 @@ List all discovered hooks from workspace, managed, and bundled directories.
|
|||||||
**Example output:**
|
**Example output:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Hooks (4/4 ready)
|
Hooks (3/3 ready)
|
||||||
|
|
||||||
Ready:
|
Ready:
|
||||||
🚀 boot-md ✓ - Run BOOT.md on gateway startup
|
|
||||||
📝 command-logger ✓ - Log all command events to a centralized audit file
|
📝 command-logger ✓ - Log all command events to a centralized audit file
|
||||||
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
💾 session-memory ✓ - Save session context to memory when /new command is issued
|
||||||
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
😈 soul-evil ✓ - Swap injected SOUL content during a purge window or by random chance
|
||||||
@@ -108,8 +107,8 @@ Show summary of hook eligibility status (how many are ready vs. not ready).
|
|||||||
```
|
```
|
||||||
Hooks Status
|
Hooks Status
|
||||||
|
|
||||||
Total hooks: 4
|
Total hooks: 2
|
||||||
Ready: 4
|
Ready: 2
|
||||||
Not ready: 0
|
Not ready: 0
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -274,17 +273,3 @@ clawdbot hooks enable soul-evil
|
|||||||
```
|
```
|
||||||
|
|
||||||
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
**See:** [SOUL Evil Hook](/hooks/soul-evil)
|
||||||
|
|
||||||
### boot-md
|
|
||||||
|
|
||||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
|
||||||
|
|
||||||
**Events**: `gateway:startup`
|
|
||||||
|
|
||||||
**Enable**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable boot-md
|
|
||||||
```
|
|
||||||
|
|
||||||
**See:** [boot-md documentation](/hooks#boot-md)
|
|
||||||
+16
-59
@@ -23,19 +23,15 @@ 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)
|
||||||
- [`gateway`](/cli/gateway)
|
- [`gateway`](/cli/gateway)
|
||||||
- [`daemon`](/cli/daemon)
|
- [`daemon`](/cli/daemon)
|
||||||
- [`service`](/cli/service)
|
|
||||||
- [`logs`](/cli/logs)
|
- [`logs`](/cli/logs)
|
||||||
- [`models`](/cli/models)
|
- [`models`](/cli/models)
|
||||||
- [`memory`](/cli/memory)
|
- [`memory`](/cli/memory)
|
||||||
- [`nodes`](/cli/nodes)
|
- [`nodes`](/cli/nodes)
|
||||||
- [`node`](/cli/node)
|
|
||||||
- [`approvals`](/cli/approvals)
|
|
||||||
- [`sandbox`](/cli/sandbox)
|
- [`sandbox`](/cli/sandbox)
|
||||||
- [`tui`](/cli/tui)
|
- [`tui`](/cli/tui)
|
||||||
- [`browser`](/cli/browser)
|
- [`browser`](/cli/browser)
|
||||||
@@ -129,7 +125,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
list
|
list
|
||||||
add
|
add
|
||||||
delete
|
delete
|
||||||
acp
|
|
||||||
status
|
status
|
||||||
health
|
health
|
||||||
sessions
|
sessions
|
||||||
@@ -145,21 +140,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
|||||||
start
|
start
|
||||||
stop
|
stop
|
||||||
restart
|
restart
|
||||||
service
|
|
||||||
gateway
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
node
|
|
||||||
status
|
|
||||||
install
|
|
||||||
uninstall
|
|
||||||
start
|
|
||||||
stop
|
|
||||||
restart
|
|
||||||
logs
|
logs
|
||||||
models
|
models
|
||||||
list
|
list
|
||||||
@@ -188,19 +168,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
|
||||||
approvals
|
notify
|
||||||
get
|
camera list|snap|clip
|
||||||
set
|
canvas snapshot|present|hide|navigate|eval
|
||||||
allowlist add|remove
|
canvas a2ui push|reset
|
||||||
|
screen record
|
||||||
|
location get
|
||||||
browser
|
browser
|
||||||
status
|
status
|
||||||
start
|
start
|
||||||
@@ -524,11 +506,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.
|
||||||
|
|
||||||
@@ -541,9 +518,6 @@ Options:
|
|||||||
- `--verbose`
|
- `--verbose`
|
||||||
- `--debug` (alias for `--verbose`)
|
- `--debug` (alias for `--verbose`)
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Overview includes Gateway + Node service status when available.
|
|
||||||
|
|
||||||
### Usage tracking
|
### Usage tracking
|
||||||
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
|
||||||
|
|
||||||
@@ -798,23 +772,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 service status`
|
|
||||||
- `node service install [--host <gateway-host>] [--port <port>] [--tls] [--tls-fingerprint <sha256>] [--node-id <id>] [--display-name <name>] [--runtime <node|bun>] [--force]`
|
|
||||||
- `node service uninstall`
|
|
||||||
- `node service start`
|
|
||||||
- `node service stop`
|
|
||||||
- `node service restart`
|
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
- `node daemon …` (same as `node service …`)
|
|
||||||
|
|
||||||
## 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).
|
||||||
@@ -831,7 +788,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:
|
||||||
|
|||||||
+3
-14
@@ -7,8 +7,8 @@ read_when:
|
|||||||
|
|
||||||
# `clawdbot memory`
|
# `clawdbot memory`
|
||||||
|
|
||||||
Manage semantic memory indexing and search.
|
Memory search tools (semantic memory status/index/search).
|
||||||
Provided by the active memory plugin (default: `memory-core`; set `plugins.slots.memory = "none"` to disable).
|
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)
|
||||||
@@ -22,20 +22,9 @@ clawdbot memory status --deep
|
|||||||
clawdbot memory status --deep --index
|
clawdbot memory status --deep --index
|
||||||
clawdbot memory status --deep --index --verbose
|
clawdbot memory status --deep --index --verbose
|
||||||
clawdbot memory index
|
clawdbot memory index
|
||||||
clawdbot memory index --verbose
|
|
||||||
clawdbot memory search "release checklist"
|
clawdbot memory search "release checklist"
|
||||||
clawdbot memory status --agent main
|
|
||||||
clawdbot memory index --agent main --verbose
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Options
|
## Options
|
||||||
|
|
||||||
Common:
|
- `--verbose`: emit debug logs during memory probes and indexing.
|
||||||
|
|
||||||
- `--agent <id>`: scope to a single agent (default: all configured agents).
|
|
||||||
- `--verbose`: emit detailed logs during probes and indexing.
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `memory status --deep` probes vector + embedding availability.
|
|
||||||
- `memory status --deep --index` runs a reindex if the store is dirty.
|
|
||||||
- `memory index --verbose` prints per-phase details (provider, model, sources, batch activity).
|
|
||||||
@@ -1,96 +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
|
|
||||||
|
|
||||||
## Service (background)
|
|
||||||
|
|
||||||
Install a headless node host as a user service.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node service install --host <gateway-host> --port 18790
|
|
||||||
# or
|
|
||||||
clawdbot service node 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 status
|
|
||||||
clawdbot service node status
|
|
||||||
clawdbot node service status
|
|
||||||
clawdbot node service start
|
|
||||||
clawdbot node service stop
|
|
||||||
clawdbot node service restart
|
|
||||||
clawdbot node service uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Legacy alias:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node daemon status
|
|
||||||
```
|
|
||||||
|
|
||||||
## 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)
|
|
||||||
- `clawdbot approvals --node <id|name|ip>` (edit from the Gateway)
|
|
||||||
@@ -89,15 +89,6 @@ clawdbot sandbox recreate --all
|
|||||||
clawdbot sandbox recreate --all
|
clawdbot sandbox recreate --all
|
||||||
```
|
```
|
||||||
|
|
||||||
### After changing setupCommand
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot sandbox recreate --all
|
|
||||||
# or just one agent:
|
|
||||||
clawdbot sandbox recreate --agent family
|
|
||||||
```
|
|
||||||
|
|
||||||
|
|
||||||
### For a specific agent only
|
### For a specific agent only
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "CLI reference for `clawdbot service` (manage gateway + node services)"
|
|
||||||
read_when:
|
|
||||||
- You want to manage Gateway or node services cross-platform
|
|
||||||
- You want a single surface for start/stop/install/uninstall
|
|
||||||
---
|
|
||||||
|
|
||||||
# `clawdbot service`
|
|
||||||
|
|
||||||
Manage the **Gateway** service and **node host** services.
|
|
||||||
|
|
||||||
Related:
|
|
||||||
- Gateway daemon (legacy alias): [Daemon](/cli/daemon)
|
|
||||||
- Node host: [Node](/cli/node)
|
|
||||||
|
|
||||||
## Gateway service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot service gateway status
|
|
||||||
clawdbot service gateway install --port 18789
|
|
||||||
clawdbot service gateway start
|
|
||||||
clawdbot service gateway stop
|
|
||||||
clawdbot service gateway restart
|
|
||||||
clawdbot service gateway uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `service gateway status` supports `--json` and `--deep` for system checks.
|
|
||||||
- `service gateway install` supports `--runtime node|bun` and `--token`.
|
|
||||||
|
|
||||||
## Node host service
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot service node status
|
|
||||||
clawdbot service node install --host <gateway-host> --port 18790
|
|
||||||
clawdbot service node start
|
|
||||||
clawdbot service node stop
|
|
||||||
clawdbot service node restart
|
|
||||||
clawdbot service node uninstall
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `service node install` supports `--runtime node|bun`, `--node-id`, `--display-name`,
|
|
||||||
and TLS options (`--tls`, `--tls-fingerprint`).
|
|
||||||
|
|
||||||
## Aliases
|
|
||||||
|
|
||||||
- `clawdbot daemon …` → `clawdbot service gateway …`
|
|
||||||
- `clawdbot node service …` → `clawdbot service node …`
|
|
||||||
- `clawdbot node status` → `clawdbot service node status`
|
|
||||||
- `clawdbot node daemon …` → `clawdbot service node …` (legacy)
|
|
||||||
@@ -19,5 +19,4 @@ clawdbot status --usage
|
|||||||
Notes:
|
Notes:
|
||||||
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
- `--deep` runs live probes (WhatsApp Web + Telegram + Discord + Slack + Signal).
|
||||||
- Output includes per-agent session stores when multiple agents are configured.
|
- Output includes per-agent session stores when multiple agents are configured.
|
||||||
- Overview includes Gateway + Node service install/runtime status when available.
|
|
||||||
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
- Update info surfaces in the Overview; if an update is available, status prints a hint to run `clawdbot update` (see [Updating](/install/updating)).
|
||||||
@@ -5,19 +5,13 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Agent Loop (Clawdbot)
|
# Agent Loop (Clawdbot)
|
||||||
|
|
||||||
An agentic loop is the full “real” run of an agent: intake → context assembly → model inference →
|
Short, exact flow of one agent run.
|
||||||
tool execution → streaming replies → persistence. It’s the authoritative path that turns a message
|
|
||||||
into actions and a final reply, while keeping session state consistent.
|
|
||||||
|
|
||||||
In Clawdbot, a loop is a single, serialized run per session that emits lifecycle and stream events
|
|
||||||
as the model thinks, calls tools, and streams output. This doc explains how that authentic loop is
|
|
||||||
wired end-to-end.
|
|
||||||
|
|
||||||
## Entry points
|
## Entry points
|
||||||
- Gateway RPC: `agent` and `agent.wait`.
|
- Gateway RPC: `agent` and `agent.wait`.
|
||||||
- CLI: `agent` command.
|
- CLI: `agent` command.
|
||||||
|
|
||||||
## How it works (high-level)
|
## High-level flow
|
||||||
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
1) `agent` RPC validates params, resolves session (sessionKey/sessionId), persists session metadata, returns `{ runId, acceptedAt }` immediately.
|
||||||
2) `agentCommand` runs the agent:
|
2) `agentCommand` runs the agent:
|
||||||
- resolves model + thinking/verbose defaults
|
- resolves model + thinking/verbose defaults
|
||||||
@@ -25,9 +19,8 @@ wired end-to-end.
|
|||||||
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
- calls `runEmbeddedPiAgent` (pi-agent-core runtime)
|
||||||
- emits **lifecycle end/error** if the embedded loop does not emit one
|
- emits **lifecycle end/error** if the embedded loop does not emit one
|
||||||
3) `runEmbeddedPiAgent`:
|
3) `runEmbeddedPiAgent`:
|
||||||
- serializes runs via per-session + global queues
|
- builds `AgentSession` and subscribes to pi events
|
||||||
- resolves model + auth profile and builds the pi session
|
- streams assistant deltas + tool events
|
||||||
- subscribes to pi events and streams assistant/tool deltas
|
|
||||||
- enforces timeout -> aborts run if exceeded
|
- enforces timeout -> aborts run if exceeded
|
||||||
- returns payloads + usage metadata
|
- returns payloads + usage metadata
|
||||||
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
4) `subscribeEmbeddedPiSession` bridges pi-agent-core events to Clawdbot `agent` stream:
|
||||||
@@ -38,73 +31,6 @@ wired end-to-end.
|
|||||||
- waits for **lifecycle end/error** for `runId`
|
- waits for **lifecycle end/error** for `runId`
|
||||||
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
- returns `{ status: ok|error|timeout, startedAt, endedAt, error? }`
|
||||||
|
|
||||||
## Queueing + concurrency
|
|
||||||
- Runs are serialized per session key (session lane) and optionally through a global lane.
|
|
||||||
- This prevents tool/session races and keeps session history consistent.
|
|
||||||
- Messaging channels can choose queue modes (collect/steer/followup) that feed this lane system.
|
|
||||||
See [Command Queue](/concepts/queue).
|
|
||||||
|
|
||||||
## Session + workspace preparation
|
|
||||||
- Workspace is resolved and created; sandboxed runs may redirect to a sandbox workspace root.
|
|
||||||
- Skills are loaded (or reused from a snapshot) and injected into env and prompt.
|
|
||||||
- Bootstrap/context files are resolved and injected into the system prompt report.
|
|
||||||
- A session write lock is acquired; `SessionManager` is opened and prepared before streaming.
|
|
||||||
|
|
||||||
## Prompt assembly + system prompt
|
|
||||||
- System prompt is built from Clawdbot’s base prompt, skills prompt, bootstrap context, and per-run overrides.
|
|
||||||
- Model-specific limits and compaction reserve tokens are enforced.
|
|
||||||
- See [System prompt](/concepts/system-prompt) for what the model sees.
|
|
||||||
|
|
||||||
## Hook points (where you can intercept)
|
|
||||||
Clawdbot has two hook systems:
|
|
||||||
- **Internal hooks** (Gateway hooks): event-driven scripts for commands and lifecycle events.
|
|
||||||
- **Plugin hooks**: extension points inside the agent/tool lifecycle and gateway pipeline.
|
|
||||||
|
|
||||||
### Internal hooks (Gateway hooks)
|
|
||||||
- **`agent:bootstrap`**: runs while building bootstrap files before the system prompt is finalized.
|
|
||||||
Use this to add/remove bootstrap context files.
|
|
||||||
- **Command hooks**: `/new`, `/reset`, `/stop`, and other command events (see Hooks doc).
|
|
||||||
|
|
||||||
See [Hooks](/hooks) for setup and examples.
|
|
||||||
|
|
||||||
### Plugin hooks (agent + gateway lifecycle)
|
|
||||||
These run inside the agent loop or gateway pipeline:
|
|
||||||
- **`before_agent_start`**: inject context or override system prompt before the run starts.
|
|
||||||
- **`agent_end`**: inspect the final message list and run metadata after completion.
|
|
||||||
- **`before_compaction` / `after_compaction`**: observe or annotate compaction cycles.
|
|
||||||
- **`before_tool_call` / `after_tool_call`**: intercept tool params/results.
|
|
||||||
- **`message_received` / `message_sending` / `message_sent`**: inbound + outbound message hooks.
|
|
||||||
- **`session_start` / `session_end`**: session lifecycle boundaries.
|
|
||||||
- **`gateway_start` / `gateway_stop`**: gateway lifecycle events.
|
|
||||||
|
|
||||||
See [Plugins](/plugin#plugin-hooks) for the hook API and registration details.
|
|
||||||
|
|
||||||
## Streaming + partial replies
|
|
||||||
- Assistant deltas are streamed from pi-agent-core and emitted as `assistant` events.
|
|
||||||
- Block streaming can emit partial replies either on `text_end` or `message_end`.
|
|
||||||
- Reasoning streaming can be emitted as a separate stream or as block replies.
|
|
||||||
- See [Streaming](/concepts/streaming) for chunking and block reply behavior.
|
|
||||||
|
|
||||||
## Tool execution + messaging tools
|
|
||||||
- Tool start/update/end events are emitted on the `tool` stream.
|
|
||||||
- Tool results are sanitized for size and image payloads before logging/emitting.
|
|
||||||
- Messaging tool sends are tracked to suppress duplicate assistant confirmations.
|
|
||||||
|
|
||||||
## Reply shaping + suppression
|
|
||||||
- Final payloads are assembled from:
|
|
||||||
- assistant text (and optional reasoning)
|
|
||||||
- inline tool summaries (when verbose + allowed)
|
|
||||||
- assistant error text when the model errors
|
|
||||||
- `NO_REPLY` is treated as a silent token and filtered from outgoing payloads.
|
|
||||||
- Messaging tool duplicates are removed from the final payload list.
|
|
||||||
- If no renderable payloads remain and a tool errored, a fallback tool error reply is emitted
|
|
||||||
(unless a messaging tool already sent a user-visible reply).
|
|
||||||
|
|
||||||
## Compaction + retries
|
|
||||||
- Auto-compaction emits `compaction` stream events and can trigger a retry.
|
|
||||||
- On retry, in-memory buffers and tool summaries are reset to avoid duplicate output.
|
|
||||||
- See [Compaction](/concepts/compaction) for the compaction pipeline.
|
|
||||||
|
|
||||||
## Event streams (today)
|
## Event streams (today)
|
||||||
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
- `lifecycle`: emitted by `subscribeEmbeddedPiSession` (and as a fallback by `agentCommand`)
|
||||||
- `assistant`: streamed deltas from pi-agent-core
|
- `assistant`: streamed deltas from pi-agent-core
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ These are the standard files Clawdbot expects inside the workspace:
|
|||||||
- Optional tiny checklist for heartbeat runs.
|
- Optional tiny checklist for heartbeat runs.
|
||||||
- Keep it short to avoid token burn.
|
- Keep it short to avoid token burn.
|
||||||
|
|
||||||
- `BOOT.md`
|
|
||||||
- Optional startup checklist executed on gateway restart when internal hooks are enabled.
|
|
||||||
- Keep it short; use the message tool for outbound sends.
|
|
||||||
|
|
||||||
- `BOOTSTRAP.md`
|
- `BOOTSTRAP.md`
|
||||||
- One-time first-run ritual.
|
- One-time first-run ritual.
|
||||||
- Only created for a brand-new workspace.
|
- Only created for a brand-new workspace.
|
||||||
|
|||||||
+13
-46
@@ -79,46 +79,18 @@ semantic queries can find related notes even when wording differs.
|
|||||||
Defaults:
|
Defaults:
|
||||||
- Enabled by default.
|
- Enabled by default.
|
||||||
- Watches memory files for changes (debounced).
|
- Watches memory files for changes (debounced).
|
||||||
- Uses remote embeddings by default. If `memorySearch.provider` is not set, Clawdbot auto-selects:
|
- Uses remote embeddings (OpenAI) unless configured for local.
|
||||||
1. `local` if a `memorySearch.local.modelPath` is configured and the file exists.
|
|
||||||
2. `openai` if an OpenAI key can be resolved.
|
|
||||||
3. `gemini` if a Gemini key can be resolved.
|
|
||||||
4. Otherwise memory search stays disabled until configured.
|
|
||||||
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
- Local mode uses node-llama-cpp and may require `pnpm approve-builds`.
|
||||||
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
- Uses sqlite-vec (when available) to accelerate vector search inside SQLite.
|
||||||
|
|
||||||
Remote embeddings **require** an API key for the embedding provider. Clawdbot
|
Remote embeddings **require** an API key for the embedding provider. By default
|
||||||
resolves keys from auth profiles, `models.providers.*.apiKey`, or environment
|
this is OpenAI (`OPENAI_API_KEY` or `models.providers.openai.apiKey`). Codex
|
||||||
variables. Codex OAuth only covers chat/completions and does **not** satisfy
|
OAuth only covers chat/completions and does **not** satisfy embeddings for
|
||||||
embeddings for memory search. For Gemini, use `GEMINI_API_KEY` or
|
memory search. When using a custom OpenAI-compatible endpoint, set
|
||||||
`models.providers.google.apiKey`. When using a custom OpenAI-compatible endpoint,
|
`memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
||||||
set `memorySearch.remote.apiKey` (and optional `memorySearch.remote.headers`).
|
|
||||||
|
|
||||||
### Gemini embeddings (native)
|
If you want to use a **custom OpenAI-compatible endpoint** (like Gemini, OpenRouter, or a proxy),
|
||||||
|
you can use the `remote` configuration:
|
||||||
Set the provider to `gemini` to use the Gemini embeddings API directly:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
memorySearch: {
|
|
||||||
provider: "gemini",
|
|
||||||
model: "gemini-embedding-001",
|
|
||||||
remote: {
|
|
||||||
apiKey: "YOUR_GEMINI_API_KEY"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `remote.baseUrl` is optional (defaults to the Gemini API base URL).
|
|
||||||
- `remote.headers` lets you add extra headers if needed.
|
|
||||||
- Default model: `gemini-embedding-001`.
|
|
||||||
|
|
||||||
If you want to use a **custom OpenAI-compatible endpoint** (OpenRouter, vLLM, or a proxy),
|
|
||||||
you can use the `remote` configuration with the OpenAI provider:
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
agents: {
|
agents: {
|
||||||
@@ -127,8 +99,8 @@ agents: {
|
|||||||
provider: "openai",
|
provider: "openai",
|
||||||
model: "text-embedding-3-small",
|
model: "text-embedding-3-small",
|
||||||
remote: {
|
remote: {
|
||||||
baseUrl: "https://api.example.com/v1/",
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
apiKey: "YOUR_OPENAI_COMPAT_API_KEY",
|
apiKey: "YOUR_GEMINI_API_KEY",
|
||||||
headers: { "X-Custom-Header": "value" }
|
headers: { "X-Custom-Header": "value" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -139,16 +111,11 @@ agents: {
|
|||||||
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
If you don't want to set an API key, use `memorySearch.provider = "local"` or set
|
||||||
`memorySearch.fallback = "none"`.
|
`memorySearch.fallback = "none"`.
|
||||||
|
|
||||||
Fallbacks:
|
Batch indexing (OpenAI only):
|
||||||
- `memorySearch.fallback` can be `openai`, `gemini`, `local`, or `none`.
|
- Enabled by default for OpenAI embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
||||||
- The fallback provider is only used when the primary embedding provider fails.
|
|
||||||
|
|
||||||
Batch indexing (OpenAI + Gemini):
|
|
||||||
- Enabled by default for OpenAI and Gemini embeddings. Set `agents.defaults.memorySearch.remote.batch.enabled = false` to disable.
|
|
||||||
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
- Default behavior waits for batch completion; tune `remote.batch.wait`, `remote.batch.pollIntervalMs`, and `remote.batch.timeoutMinutes` if needed.
|
||||||
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
- Set `remote.batch.concurrency` to control how many batch jobs we submit in parallel (default: 2).
|
||||||
- Batch mode applies when `memorySearch.provider = "openai"` or `"gemini"` and uses the corresponding API key.
|
- Batch mode currently applies only when `memorySearch.provider = "openai"` and uses your OpenAI API key.
|
||||||
- Gemini batch jobs use the async embeddings batch endpoint and require Gemini Batch API availability.
|
|
||||||
|
|
||||||
Why OpenAI batch is fast + cheap:
|
Why OpenAI batch is fast + cheap:
|
||||||
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
- For large backfills, OpenAI is typically the fastest option we support because we can submit many embedding requests in a single batch job and let OpenAI process them asynchronously.
|
||||||
|
|||||||
@@ -59,11 +59,6 @@ It does **not** rotate on every request. The pinned profile is reused until:
|
|||||||
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
Manual selection via `/model …@<profileId>` sets a **user override** for that session
|
||||||
and is not auto‑rotated until a new session starts.
|
and is not auto‑rotated until a new session starts.
|
||||||
|
|
||||||
Auto‑pinned profiles (selected by the session router) are treated as a **preference**:
|
|
||||||
they are tried first, but Clawdbot may rotate to another profile on rate limits/timeouts.
|
|
||||||
User‑pinned profiles stay locked to that profile; if it fails and model fallbacks
|
|
||||||
are configured, Clawdbot moves to the next model instead of switching profiles.
|
|
||||||
|
|
||||||
### Why OAuth can “look lost”
|
### Why OAuth can “look lost”
|
||||||
|
|
||||||
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
If you have both an OAuth profile and an API key profile for the same provider, round‑robin can switch between them across messages unless pinned. To force a single profile:
|
||||||
|
|||||||
@@ -293,9 +293,6 @@ Starting with v2026.1.6, each agent can have its own sandbox and tool restrictio
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Note: `setupCommand` lives under `sandbox.docker` and runs once on container creation.
|
|
||||||
Per-agent `sandbox.docker.*` overrides are ignored when the resolved scope is `"shared"`.
|
|
||||||
|
|
||||||
**Benefits:**
|
**Benefits:**
|
||||||
- **Security isolation**: Restrict tools for untrusted agents
|
- **Security isolation**: Restrict tools for untrusted agents
|
||||||
- **Resource control**: Sandbox specific agents while keeping others on host
|
- **Resource control**: Sandbox specific agents while keeping others on host
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ The prompt is intentionally compact and uses fixed sections:
|
|||||||
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
- **Skills** (when available): tells the model how to load skill instructions on demand.
|
||||||
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
- **Clawdbot Self-Update**: how to run `config.apply` and `update.run`.
|
||||||
- **Workspace**: working directory (`agents.defaults.workspace`).
|
- **Workspace**: working directory (`agents.defaults.workspace`).
|
||||||
- **Documentation**: local path to Clawdbot docs (repo or npm package) and when to read them.
|
|
||||||
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
- **Workspace Files (injected)**: indicates bootstrap files are included below.
|
||||||
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
- **Sandbox** (when enabled): indicates sandboxed runtime, sandbox paths, and whether elevated exec is available.
|
||||||
- **Current Date & Time**: user-local time, timezone, and time format.
|
- **Current Date & Time**: user-local time, timezone, and time format.
|
||||||
@@ -99,12 +98,3 @@ Skills section is omitted.
|
|||||||
```
|
```
|
||||||
|
|
||||||
This keeps the base prompt small while still enabling targeted skill usage.
|
This keeps the base prompt small while still enabling targeted skill usage.
|
||||||
|
|
||||||
## Documentation
|
|
||||||
|
|
||||||
When available, the system prompt includes a **Documentation** section that points to the
|
|
||||||
local Clawdbot docs directory (either `docs/` in the repo workspace or the bundled npm
|
|
||||||
package docs) and also notes the public mirror, source repo, community Discord, and
|
|
||||||
ClawdHub (https://clawdhub.com) for skills discovery. The prompt instructs the model to consult local docs first
|
|
||||||
for Clawdbot behavior, commands, configuration, or architecture, and to run
|
|
||||||
`clawdbot status` itself when possible (asking the user only when it lacks access).
|
|
||||||
@@ -9,7 +9,7 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||||
|
|
||||||
## Message envelopes (UTC by default)
|
## Message envelopes (UTC)
|
||||||
|
|
||||||
Inbound messages are wrapped in an envelope like:
|
Inbound messages are wrapped in an envelope like:
|
||||||
|
|
||||||
@@ -17,46 +17,7 @@ Inbound messages are wrapped in an envelope like:
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
The timestamp in the envelope is **always UTC**, with minutes precision.
|
||||||
|
|
||||||
You can override this with:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
|
||||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
|
||||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
|
||||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
**UTC (default):**
|
|
||||||
|
|
||||||
```
|
|
||||||
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
|
||||||
```
|
|
||||||
|
|
||||||
**Fixed timezone:**
|
|
||||||
|
|
||||||
```
|
|
||||||
[Signal Alice +1555 2026-01-18 06:19 GMT+1] hello
|
|
||||||
```
|
|
||||||
|
|
||||||
**Elapsed time:**
|
|
||||||
|
|
||||||
```
|
|
||||||
[Signal Alice +1555 +2m 2026-01-18T05:19Z] follow-up
|
|
||||||
```
|
|
||||||
|
|
||||||
## Tool payloads (raw provider data + normalized fields)
|
## Tool payloads (raw provider data + normalized fields)
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ read_when:
|
|||||||
## Where it shows up
|
## Where it shows up
|
||||||
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
|
||||||
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
|
||||||
- `/usage cost` in chats: local cost summary aggregated from Clawdbot session logs.
|
|
||||||
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
|
||||||
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
|
||||||
- macOS menu bar: “Usage” section under Context (only if available).
|
- macOS menu bar: “Usage” section under Context (only if available).
|
||||||
|
|||||||
+4
-44
@@ -7,10 +7,10 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
Clawdbot uses **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||||
Provider timestamps are preserved so tools keep their native semantics.
|
We avoid rewriting provider timestamps so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (UTC by default)
|
## Message envelopes (UTC)
|
||||||
|
|
||||||
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||||
|
|
||||||
@@ -18,47 +18,7 @@ Inbound messages are wrapped with a UTC timestamp (minute precision):
|
|||||||
[Provider ... 2026-01-05T21:26Z] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
This envelope timestamp is **always UTC**, regardless of the host timezone.
|
||||||
|
|
||||||
You can override this behavior:
|
|
||||||
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
agents: {
|
|
||||||
defaults: {
|
|
||||||
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
- `envelopeTimezone: "local"` uses the host timezone.
|
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
|
||||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
|
||||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
|
||||||
- `envelopeElapsed: "off"` removes elapsed time suffixes (the `+2m` style).
|
|
||||||
|
|
||||||
### Examples
|
|
||||||
|
|
||||||
**UTC (default):**
|
|
||||||
|
|
||||||
```
|
|
||||||
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
|
||||||
```
|
|
||||||
|
|
||||||
**User timezone:**
|
|
||||||
|
|
||||||
```
|
|
||||||
[WhatsApp +1555 2026-01-18 00:19 CST] hello
|
|
||||||
```
|
|
||||||
|
|
||||||
**Elapsed time enabled:**
|
|
||||||
|
|
||||||
```
|
|
||||||
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
|
||||||
```
|
|
||||||
|
|
||||||
## System prompt: Current Date & Time
|
## System prompt: Current Date & Time
|
||||||
|
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
summary: Node + tsx "__name is not a function" crash notes and workarounds
|
|
||||||
read_when:
|
|
||||||
- Debugging Node-only dev scripts or watch mode failures
|
|
||||||
- Investigating tsx/esbuild loader crashes in Clawdbot
|
|
||||||
---
|
|
||||||
|
|
||||||
# Node + tsx "__name is not a function" crash
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
Running Clawdbot via Node with `tsx` fails at startup with:
|
|
||||||
|
|
||||||
```
|
|
||||||
[clawdbot] Failed to start CLI: TypeError: __name is not a function
|
|
||||||
at createSubsystemLogger (.../src/logging/subsystem.ts:203:25)
|
|
||||||
at .../src/agents/auth-profiles/constants.ts:25:20
|
|
||||||
```
|
|
||||||
|
|
||||||
This began after switching dev scripts from Bun to `tsx` (commit `2871657e`, 2026-01-06). The same runtime path worked with Bun.
|
|
||||||
|
|
||||||
## Environment
|
|
||||||
- Node: v25.x (observed on v25.3.0)
|
|
||||||
- tsx: 4.21.0
|
|
||||||
- OS: macOS (repro also likely on other platforms that run Node 25)
|
|
||||||
|
|
||||||
## Repro (Node-only)
|
|
||||||
```bash
|
|
||||||
# in repo root
|
|
||||||
node --version
|
|
||||||
pnpm install
|
|
||||||
node --import tsx src/entry.ts status
|
|
||||||
```
|
|
||||||
|
|
||||||
## Minimal repro in repo
|
|
||||||
```bash
|
|
||||||
node --import tsx scripts/repro/tsx-name-repro.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
## Node version check
|
|
||||||
- Node 25.3.0: fails
|
|
||||||
- Node 22.22.0 (Homebrew `node@22`): fails
|
|
||||||
- Node 24: not installed here yet; needs verification
|
|
||||||
|
|
||||||
## Notes / hypothesis
|
|
||||||
- `tsx` uses esbuild to transform TS/ESM. esbuild’s `keepNames` emits a `__name` helper and wraps function definitions with `__name(...)`.
|
|
||||||
- The crash indicates `__name` exists but is not a function at runtime, which implies the helper is missing or overwritten for this module in the Node 25 loader path.
|
|
||||||
- Similar `__name` helper issues have been reported in other esbuild consumers when the helper is missing or rewritten.
|
|
||||||
|
|
||||||
## Regression history
|
|
||||||
- `2871657e` (2026-01-06): scripts changed from Bun to tsx to make Bun optional.
|
|
||||||
- Before that (Bun path), `pnpm clawdbot status` and `gateway:watch` worked.
|
|
||||||
|
|
||||||
## Workarounds
|
|
||||||
- Use Bun for dev scripts (current temporary revert).
|
|
||||||
- Use Node + tsc watch, then run compiled output:
|
|
||||||
```bash
|
|
||||||
pnpm exec tsc --watch --preserveWatchOutput
|
|
||||||
node --watch dist/entry.js status
|
|
||||||
```
|
|
||||||
- Confirmed locally: `pnpm exec tsc -p tsconfig.json` + `node dist/entry.js status` works on Node 25.
|
|
||||||
- Disable esbuild keepNames in the TS loader if possible (prevents `__name` helper insertion); tsx does not currently expose this.
|
|
||||||
- Test Node LTS (22/24) with `tsx` to see if the issue is Node 25–specific.
|
|
||||||
|
|
||||||
## References
|
|
||||||
- https://opennext.js.org/cloudflare/howtos/keep_names
|
|
||||||
- https://esbuild.github.io/api/#keep-names
|
|
||||||
- https://github.com/evanw/esbuild/issues/1031
|
|
||||||
|
|
||||||
## Next steps
|
|
||||||
- Repro on Node 22/24 to confirm Node 25 regression.
|
|
||||||
- Test `tsx` nightly or pin to earlier version if a known regression exists.
|
|
||||||
- If reproduces on Node LTS, file a minimal repro upstream with the `__name` stack trace.
|
|
||||||
@@ -77,7 +77,6 @@ What this does:
|
|||||||
- Seeds the workspace files if missing:
|
- Seeds the workspace files if missing:
|
||||||
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
`AGENTS.md`, `SOUL.md`, `TOOLS.md`, `IDENTITY.md`, `USER.md`, `HEARTBEAT.md`.
|
||||||
- Default identity: **C3‑PO** (protocol droid).
|
- Default identity: **C3‑PO** (protocol droid).
|
||||||
- Skips channel providers in dev mode (`CLAWDBOT_SKIP_CHANNELS=1`).
|
|
||||||
|
|
||||||
Reset flow (fresh start):
|
Reset flow (fresh start):
|
||||||
|
|
||||||
|
|||||||
@@ -657,10 +657,6 @@
|
|||||||
"source": "/templates/AGENTS",
|
"source": "/templates/AGENTS",
|
||||||
"destination": "/reference/templates/AGENTS"
|
"destination": "/reference/templates/AGENTS"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"source": "/templates/BOOT",
|
|
||||||
"destination": "/reference/templates/BOOT"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"source": "/templates/BOOTSTRAP",
|
"source": "/templates/BOOTSTRAP",
|
||||||
"destination": "/reference/templates/BOOTSTRAP"
|
"destination": "/reference/templates/BOOTSTRAP"
|
||||||
@@ -826,10 +822,8 @@
|
|||||||
"cli/models",
|
"cli/models",
|
||||||
"cli/logs",
|
"cli/logs",
|
||||||
"cli/nodes",
|
"cli/nodes",
|
||||||
"cli/approvals",
|
|
||||||
"cli/gateway",
|
"cli/gateway",
|
||||||
"cli/daemon",
|
"cli/daemon",
|
||||||
"cli/service",
|
|
||||||
"cli/tui",
|
"cli/tui",
|
||||||
"cli/voicecall",
|
"cli/voicecall",
|
||||||
"cli/wake",
|
"cli/wake",
|
||||||
@@ -1057,7 +1051,6 @@
|
|||||||
"reference/RELEASING",
|
"reference/RELEASING",
|
||||||
"reference/AGENTS.default",
|
"reference/AGENTS.default",
|
||||||
"reference/templates/AGENTS",
|
"reference/templates/AGENTS",
|
||||||
"reference/templates/BOOT",
|
|
||||||
"reference/templates/BOOTSTRAP",
|
"reference/templates/BOOTSTRAP",
|
||||||
"reference/templates/HEARTBEAT",
|
"reference/templates/HEARTBEAT",
|
||||||
"reference/templates/IDENTITY",
|
"reference/templates/IDENTITY",
|
||||||
|
|||||||
@@ -46,8 +46,8 @@ When TLS is enabled, discovery TXT records include `bridgeTls=1` plus
|
|||||||
## Frames
|
## Frames
|
||||||
|
|
||||||
Client → Gateway:
|
Client → Gateway:
|
||||||
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake, skills.bins)
|
- `req` / `res`: scoped gateway RPC (chat, sessions, config, health, voicewake)
|
||||||
- `event`: node signals (voice transcript, agent request, chat subscribe, exec lifecycle)
|
- `event`: node signals (voice transcript, agent request, chat subscribe)
|
||||||
|
|
||||||
Gateway → Client:
|
Gateway → Client:
|
||||||
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
- `invoke` / `invoke-res`: node commands (`canvas.*`, `camera.*`, `screen.record`,
|
||||||
@@ -57,18 +57,6 @@ Gateway → Client:
|
|||||||
|
|
||||||
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
Exact allowlist is enforced in `src/gateway/server-bridge.ts`.
|
||||||
|
|
||||||
## Exec lifecycle events
|
|
||||||
|
|
||||||
Nodes can emit `exec.started`, `exec.finished`, or `exec.denied` events to surface
|
|
||||||
system.run activity. These are mapped to system events in the gateway.
|
|
||||||
|
|
||||||
Payload fields (all optional unless noted):
|
|
||||||
- `sessionKey` (required): agent session to receive the system event.
|
|
||||||
- `runId`: unique exec id for grouping.
|
|
||||||
- `command`: raw or formatted command string.
|
|
||||||
- `exitCode`, `timedOut`, `success`, `output`: completion details (finished only).
|
|
||||||
- `reason`: denial reason (denied only).
|
|
||||||
|
|
||||||
## Tailnet usage
|
## Tailnet usage
|
||||||
|
|
||||||
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
- Bind the bridge to a tailnet IP: `bridge.bind: "tailnet"` in
|
||||||
|
|||||||
@@ -261,9 +261,10 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
|
|||||||
ackMaxChars: 300
|
ackMaxChars: 300
|
||||||
},
|
},
|
||||||
memorySearch: {
|
memorySearch: {
|
||||||
provider: "gemini",
|
provider: "openai",
|
||||||
model: "gemini-embedding-001",
|
model: "text-embedding-004",
|
||||||
remote: {
|
remote: {
|
||||||
|
baseUrl: "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||||
apiKey: "${GEMINI_API_KEY}"
|
apiKey: "${GEMINI_API_KEY}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -17,19 +17,6 @@ If the file is missing, Clawdbot uses safe-ish defaults (embedded Pi agent + per
|
|||||||
|
|
||||||
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
|
> **New to configuration?** Check out the [Configuration Examples](/gateway/configuration-examples) guide for complete examples with detailed explanations!
|
||||||
|
|
||||||
## Strict config validation
|
|
||||||
|
|
||||||
Clawdbot only accepts configurations that fully match the schema.
|
|
||||||
Unknown keys, malformed types, or invalid values cause the Gateway to **refuse to start** for safety.
|
|
||||||
|
|
||||||
When validation fails:
|
|
||||||
- The Gateway does not boot.
|
|
||||||
- Only diagnostic commands are allowed (for example: `clawdbot doctor`, `clawdbot logs`, `clawdbot health`, `clawdbot status`, `clawdbot service`, `clawdbot help`).
|
|
||||||
- Run `clawdbot doctor` to see the exact issues.
|
|
||||||
- Run `clawdbot doctor --fix` (or `--yes`) to apply migrations/repairs.
|
|
||||||
|
|
||||||
Doctor never writes changes unless you explicitly opt into `--fix`/`--yes`.
|
|
||||||
|
|
||||||
## Schema + UI hints
|
## Schema + UI hints
|
||||||
|
|
||||||
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
The Gateway exposes a JSON Schema representation of the config via `config.schema` for UI editors.
|
||||||
@@ -2003,9 +1990,6 @@ cross-session isolation. Use `scope: "session"` for per-session isolation.
|
|||||||
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
Legacy: `perSession` is still supported (`true` → `scope: "session"`,
|
||||||
`false` → `scope: "shared"`).
|
`false` → `scope: "shared"`).
|
||||||
|
|
||||||
`setupCommand` runs **once** after the container is created (inside the container via `sh -lc`).
|
|
||||||
For package installs, ensure network egress, a writable root FS, and a root user.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pnpm gateway:watch
|
|||||||
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
- Pass `--verbose` to mirror debug logging (handshakes, req/res, events) from the log file into stdio when troubleshooting.
|
||||||
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
- `--force` uses `lsof` to find listeners on the chosen port, sends SIGTERM, logs what it killed, then starts the gateway (fails fast if `lsof` is missing).
|
||||||
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
- If you run under a supervisor (launchd/systemd/mac app child-process mode), a stop/restart typically sends **SIGTERM**; older builds may surface this as `pnpm` `ELIFECYCLE` exit code **143** (SIGTERM), which is a normal shutdown, not a crash.
|
||||||
- **SIGUSR1** triggers an in-process restart when authorized (gateway tool/config apply/update, or enable `commands.restart` for manual restarts).
|
- **SIGUSR1** triggers an in-process restart (no external supervisor required). This is what the `gateway` agent tool uses.
|
||||||
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
|
- Gateway auth: set `gateway.auth.mode=token` + `gateway.auth.token` (or pass `--token <value>` / `CLAWDBOT_GATEWAY_TOKEN`) to require clients to send `connect.params.auth.token`.
|
||||||
- The wizard now generates a token by default, even on loopback.
|
- The wizard now generates a token by default, even on loopback.
|
||||||
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
- Port precedence: `--port` > `CLAWDBOT_GATEWAY_PORT` > `gateway.port` > default `18789`.
|
||||||
|
|||||||
@@ -116,20 +116,6 @@ Override with `agents.defaults.sandbox.docker.network`.
|
|||||||
Docker installs and the containerized gateway live here:
|
Docker installs and the containerized gateway live here:
|
||||||
[Docker](/install/docker)
|
[Docker](/install/docker)
|
||||||
|
|
||||||
## setupCommand (one-time container setup)
|
|
||||||
`setupCommand` runs **once** after the sandbox container is created (not on every run).
|
|
||||||
It executes inside the container via `sh -lc`.
|
|
||||||
|
|
||||||
Paths:
|
|
||||||
- Global: `agents.defaults.sandbox.docker.setupCommand`
|
|
||||||
- Per-agent: `agents.list[].sandbox.docker.setupCommand`
|
|
||||||
|
|
||||||
|
|
||||||
Common pitfalls:
|
|
||||||
- Default `docker.network` is `"none"` (no egress), so package installs will fail.
|
|
||||||
- `readOnlyRoot: true` prevents writes; set `readOnlyRoot: false` or bake a custom image.
|
|
||||||
- `user` must be root for package installs (omit `user` or set `user: "0:0"`).
|
|
||||||
|
|
||||||
## Tool policy + escape hatches
|
## Tool policy + escape hatches
|
||||||
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
Tool allow/deny policies still apply before sandbox rules. If a tool is denied
|
||||||
globally or per-agent, sandboxing doesn’t bring it back.
|
globally or per-agent, sandboxing doesn’t bring it back.
|
||||||
|
|||||||
@@ -5,6 +5,13 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Security 🔒
|
# Security 🔒
|
||||||
|
|
||||||
|
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
||||||
|
|
||||||
|
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
||||||
|
- who can talk to your bot
|
||||||
|
- where the bot is allowed to act
|
||||||
|
- what the bot can touch
|
||||||
|
|
||||||
## Quick check: `clawdbot security audit`
|
## Quick check: `clawdbot security audit`
|
||||||
|
|
||||||
Run this regularly (especially after changing config or exposing network surfaces):
|
Run this regularly (especially after changing config or exposing network surfaces):
|
||||||
@@ -22,13 +29,6 @@ It flags common footguns (Gateway auth exposure, browser control exposure, eleva
|
|||||||
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
- Turn `logging.redactSensitive="off"` back to `"tools"`.
|
||||||
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
|
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`, plus common state files like `credentials/*.json`, `agents/*/agent/auth-profiles.json`, and `agents/*/sessions/sessions.json`).
|
||||||
|
|
||||||
Running an AI agent with shell access on your machine is... *spicy*. Here’s how to not get pwned.
|
|
||||||
|
|
||||||
Clawdbot is both a product and an experiment: you’re wiring frontier-model behavior into real messaging surfaces and real tools. **There is no “perfectly secure” setup.** The goal is to be deliberate about:
|
|
||||||
- who can talk to your bot
|
|
||||||
- where the bot is allowed to act
|
|
||||||
- what the bot can touch
|
|
||||||
|
|
||||||
### What the audit checks (high level)
|
### What the audit checks (high level)
|
||||||
|
|
||||||
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
|
||||||
@@ -65,8 +65,8 @@ stronger isolation between agents, run them under separate OS users or separate
|
|||||||
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
If a macOS node is paired, the Gateway can invoke `system.run` on that node. This is **remote code execution** on the Mac:
|
||||||
|
|
||||||
- Requires node pairing (approval + token).
|
- Requires node pairing (approval + token).
|
||||||
- Controlled on the Mac via **Settings → Exec approvals** (security + ask + allowlist).
|
- Controlled on the Mac via **Settings → "Node Run Commands"**: "Always Ask" (default), "Always Allow", or "Never".
|
||||||
- If you don’t want remote execution, set security to **deny** and remove node pairing for that Mac.
|
- If you don’t want remote execution, set the policy to "Never" and remove node pairing for that Mac.
|
||||||
|
|
||||||
## Dynamic skills (watcher / remote nodes)
|
## Dynamic skills (watcher / remote nodes)
|
||||||
|
|
||||||
|
|||||||
@@ -326,22 +326,6 @@ Clawdbot keeps conversation history in memory.
|
|||||||
|
|
||||||
## Common troubleshooting
|
## Common troubleshooting
|
||||||
|
|
||||||
### “Gateway won’t start — configuration invalid”
|
|
||||||
|
|
||||||
Clawdbot now refuses to start when the config contains unknown keys, malformed values, or invalid types.
|
|
||||||
This is intentional for safety.
|
|
||||||
|
|
||||||
Fix it with Doctor:
|
|
||||||
```bash
|
|
||||||
clawdbot doctor
|
|
||||||
clawdbot doctor --fix
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- `clawdbot doctor` reports every invalid entry.
|
|
||||||
- `clawdbot doctor --fix` applies migrations/repairs and rewrites the config.
|
|
||||||
- Diagnostic commands like `clawdbot logs`, `clawdbot health`, `clawdbot status`, and `clawdbot service` still run even if the config is invalid.
|
|
||||||
|
|
||||||
### “All models failed” — what should I check first?
|
### “All models failed” — what should I check first?
|
||||||
|
|
||||||
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
- **Credentials** present for the provider(s) being tried (auth profiles + env vars).
|
||||||
|
|||||||
+2
-30
@@ -37,11 +37,10 @@ The hooks system allows you to:
|
|||||||
|
|
||||||
### Bundled Hooks
|
### Bundled Hooks
|
||||||
|
|
||||||
Clawdbot ships with four bundled hooks that are automatically discovered:
|
Clawdbot ships with three bundled hooks that are automatically discovered:
|
||||||
|
|
||||||
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
- **💾 session-memory**: Saves session context to your agent workspace (default `~/clawd/memory/`) when you issue `/new`
|
||||||
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
- **📝 command-logger**: Logs all command events to `~/.clawdbot/logs/commands.log`
|
||||||
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
|
|
||||||
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
- **😈 soul-evil**: Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance
|
||||||
|
|
||||||
List available hooks:
|
List available hooks:
|
||||||
@@ -196,7 +195,7 @@ Each event includes:
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
{
|
{
|
||||||
type: 'command' | 'session' | 'agent' | 'gateway',
|
type: 'command' | 'session' | 'agent',
|
||||||
action: string, // e.g., 'new', 'reset', 'stop'
|
action: string, // e.g., 'new', 'reset', 'stop'
|
||||||
sessionKey: string, // Session identifier
|
sessionKey: string, // Session identifier
|
||||||
timestamp: Date, // When the event occurred
|
timestamp: Date, // When the event occurred
|
||||||
@@ -229,12 +228,6 @@ Triggered when agent commands are issued:
|
|||||||
|
|
||||||
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
|
||||||
|
|
||||||
### Gateway Events
|
|
||||||
|
|
||||||
Triggered when the gateway starts:
|
|
||||||
|
|
||||||
- **`gateway:startup`**: After channels start and hooks are loaded
|
|
||||||
|
|
||||||
### Future Events
|
### Future Events
|
||||||
|
|
||||||
Planned event types:
|
Planned event types:
|
||||||
@@ -549,26 +542,6 @@ clawdbot hooks enable soul-evil
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### boot-md
|
|
||||||
|
|
||||||
Runs `BOOT.md` when the gateway starts (after channels start).
|
|
||||||
Internal hooks must be enabled for this to run.
|
|
||||||
|
|
||||||
**Events**: `gateway:startup`
|
|
||||||
|
|
||||||
**Requirements**: `workspace.dir` must be configured
|
|
||||||
|
|
||||||
**What it does**:
|
|
||||||
1. Reads `BOOT.md` from your workspace
|
|
||||||
2. Runs the instructions via the agent runner
|
|
||||||
3. Sends any requested outbound messages via the message tool
|
|
||||||
|
|
||||||
**Enable**:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot hooks enable boot-md
|
|
||||||
```
|
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### Keep Handlers Fast
|
### Keep Handlers Fast
|
||||||
@@ -641,7 +614,6 @@ The gateway logs hook loading at startup:
|
|||||||
```
|
```
|
||||||
Registered hook: session-memory -> command:new
|
Registered hook: session-memory -> command:new
|
||||||
Registered hook: command-logger -> command
|
Registered hook: command-logger -> command
|
||||||
Registered hook: boot-md -> gateway:startup
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Check Discovery
|
### Check Discovery
|
||||||
|
|||||||
@@ -254,14 +254,6 @@ precedence, and troubleshooting.
|
|||||||
|
|
||||||
### Enable sandboxing
|
### Enable sandboxing
|
||||||
|
|
||||||
If you plan to install packages in `setupCommand`, note:
|
|
||||||
- Default `docker.network` is `"none"` (no egress).
|
|
||||||
- `readOnlyRoot: true` blocks package installs.
|
|
||||||
- `user` must be root for `apt-get` (omit `user` or set `user: "0:0"`).
|
|
||||||
Clawdbot auto-recreates containers when `setupCommand` (or docker config) changes
|
|
||||||
unless the container was **recently used** (within ~5 minutes). Hot containers
|
|
||||||
log a warning with the exact `clawdbot sandbox recreate ...` command.
|
|
||||||
|
|
||||||
```json5
|
```json5
|
||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Clawdbot ships two installer scripts (served from `clawd.bot`):
|
|||||||
|
|
||||||
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
- `https://clawd.bot/install.sh` — “recommended” installer (global npm install by default; can also install from a GitHub checkout)
|
||||||
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
- `https://clawd.bot/install-cli.sh` — non-root-friendly CLI installer (installs into a prefix with its own Node)
|
||||||
- `https://clawd.bot/install.ps1` — Windows PowerShell installer (npm by default; optional git install)
|
|
||||||
|
|
||||||
To see the current flags/behavior, run:
|
To see the current flags/behavior, run:
|
||||||
|
|
||||||
@@ -20,12 +19,6 @@ To see the current flags/behavior, run:
|
|||||||
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
Windows (PowerShell) help:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
& ([scriptblock]::Create((iwr -useb https://clawd.bot/install.ps1))) -?
|
|
||||||
```
|
|
||||||
|
|
||||||
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
If the installer completes but `clawdbot` is not found in a new terminal, it’s usually a Node/npm PATH issue. See: [Install](/install#nodejs--npm-path-sanity).
|
||||||
|
|
||||||
## install.sh (recommended)
|
## install.sh (recommended)
|
||||||
@@ -80,37 +73,3 @@ Help:
|
|||||||
```bash
|
```bash
|
||||||
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
curl -fsSL https://clawd.bot/install-cli.sh | bash -s -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
## install.ps1 (Windows PowerShell)
|
|
||||||
|
|
||||||
What it does (high level):
|
|
||||||
|
|
||||||
- Ensure Node.js **22+** (winget/Chocolatey/Scoop or manual).
|
|
||||||
- Choose install method:
|
|
||||||
- `npm` (default): `npm install -g clawdbot@latest`
|
|
||||||
- `git`: clone/build a source checkout and install a wrapper script
|
|
||||||
- Runs `clawdbot doctor --non-interactive` on upgrades and git installs (best effort).
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
iwr -useb https://clawd.bot/install.ps1 | iex
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git
|
|
||||||
```
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
iwr -useb https://clawd.bot/install.ps1 | iex -InstallMethod git -GitDir "C:\\clawdbot"
|
|
||||||
```
|
|
||||||
|
|
||||||
Environment variables:
|
|
||||||
|
|
||||||
- `CLAWDBOT_INSTALL_METHOD=git|npm`
|
|
||||||
- `CLAWDBOT_GIT_DIR=...`
|
|
||||||
|
|
||||||
Git requirement:
|
|
||||||
|
|
||||||
If you choose `-InstallMethod git` and Git is missing, the installer will print the
|
|
||||||
Git for Windows link (`https://git-scm.com/download/win`) and exit.
|
|
||||||
@@ -18,9 +18,6 @@ This allows you to run multiple agents with different security profiles:
|
|||||||
- Family/work agents with restricted tools
|
- Family/work agents with restricted tools
|
||||||
- Public-facing agents in sandboxes
|
- Public-facing agents in sandboxes
|
||||||
|
|
||||||
`setupCommand` belongs under `sandbox.docker` (global or per-agent) and runs once
|
|
||||||
when the container is created.
|
|
||||||
|
|
||||||
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
+3
-50
@@ -147,10 +147,9 @@ Notes:
|
|||||||
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
- The permission prompt must be accepted on the Android device before the capability is advertised.
|
||||||
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
- Wi-Fi-only devices without telephony will not advertise `sms.send`.
|
||||||
|
|
||||||
## System commands (node host / mac node)
|
## System commands (mac node)
|
||||||
|
|
||||||
The macOS node exposes `system.run`, `system.notify`, and `system.execApprovals.get/set`.
|
The macOS node exposes `system.run` and `system.notify`.
|
||||||
The headless node host exposes `system.run`, `system.which`, and `system.execApprovals.get/set`.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
@@ -164,58 +163,12 @@ Notes:
|
|||||||
- `system.notify` respects notification permission state on the macOS app.
|
- `system.notify` respects notification permission state on the macOS app.
|
||||||
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
- `system.run` supports `--cwd`, `--env KEY=VAL`, `--command-timeout`, and `--needs-screen-recording`.
|
||||||
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
- `system.notify` supports `--priority <passive|active|timeSensitive>` and `--delivery <system|overlay|auto>`.
|
||||||
- On macOS node mode, `system.run` is gated by exec approvals in the macOS app (Settings → Exec approvals).
|
- `system.run` is gated by the macOS app policy (Settings → "Node Run Commands"): "Always Ask" prompts per command, "Always Allow" runs without prompts, and "Never" disables the tool. Denied prompts return `SYSTEM_RUN_DENIED`; disabled returns `SYSTEM_RUN_DISABLED`.
|
||||||
Ask/allowlist/full behave the same as the headless node host; denied prompts return `SYSTEM_RUN_DENIED`.
|
|
||||||
- On headless node host, `system.run` is gated by exec approvals (`~/.clawdbot/exec-approvals.json`).
|
|
||||||
|
|
||||||
## Exec node binding
|
|
||||||
|
|
||||||
When multiple nodes are available, you can bind exec to a specific node.
|
|
||||||
This sets the default node for `exec host=node` (and can be overridden per agent).
|
|
||||||
|
|
||||||
Global default:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config set tools.exec.node "node-id-or-name"
|
|
||||||
```
|
|
||||||
|
|
||||||
Per-agent override:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config get agents.list
|
|
||||||
clawdbot config set agents.list[0].tools.exec.node "node-id-or-name"
|
|
||||||
```
|
|
||||||
|
|
||||||
Unset to allow any node:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot config unset tools.exec.node
|
|
||||||
clawdbot config unset agents.list[0].tools.exec.node
|
|
||||||
```
|
|
||||||
|
|
||||||
## Permissions map
|
## Permissions map
|
||||||
|
|
||||||
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
Nodes may include a `permissions` map in `node.list` / `node.describe`, keyed by permission name (e.g. `screenRecording`, `accessibility`) with boolean values (`true` = granted).
|
||||||
|
|
||||||
## Headless node host (cross-platform)
|
|
||||||
|
|
||||||
Clawdbot can run a **headless node host** (no UI) that connects to the Gateway
|
|
||||||
bridge and exposes `system.run` / `system.which`. This is useful on Linux/Windows
|
|
||||||
or for running a minimal node alongside a server.
|
|
||||||
|
|
||||||
Start it:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
clawdbot node start --host <gateway-host> --port 18790
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
- Pairing is still required (the Gateway will show a node approval prompt).
|
|
||||||
- The node host stores its node id + pairing token in `~/.clawdbot/node.json`.
|
|
||||||
- Exec approvals are enforced locally via `~/.clawdbot/exec-approvals.json`
|
|
||||||
(see [Exec approvals](/tools/exec-approvals)).
|
|
||||||
- Add `--tls` / `--tls-fingerprint` when the bridge requires TLS.
|
|
||||||
|
|
||||||
## Mac node mode
|
## Mac node mode
|
||||||
|
|
||||||
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
- The macOS menubar app connects to the Gateway bridge as a node (so `clawdbot nodes …` works against this Mac).
|
||||||
|
|||||||
@@ -104,29 +104,6 @@ Rules:
|
|||||||
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
- If `<capability>.enabled: true` but no models are configured, Clawdbot tries the
|
||||||
**active reply model** when its provider supports the capability.
|
**active reply model** when its provider supports the capability.
|
||||||
|
|
||||||
### Auto-enable audio (when keys exist)
|
|
||||||
If `tools.media.audio.enabled` is **not** set to `false` and you have any supported
|
|
||||||
audio provider keys configured, Clawdbot will **auto-enable audio transcription**
|
|
||||||
even when you haven’t listed models explicitly.
|
|
||||||
|
|
||||||
Providers checked (in order):
|
|
||||||
1) OpenAI
|
|
||||||
2) Groq
|
|
||||||
3) Deepgram
|
|
||||||
|
|
||||||
To disable this behavior, set:
|
|
||||||
```json5
|
|
||||||
{
|
|
||||||
tools: {
|
|
||||||
media: {
|
|
||||||
audio: {
|
|
||||||
enabled: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Capabilities (optional)
|
## Capabilities (optional)
|
||||||
If you set `capabilities`, the entry only runs for those media types. For shared
|
If you set `capabilities`, the entry only runs for those media types. For shared
|
||||||
lists, Clawdbot can infer defaults:
|
lists, Clawdbot can infer defaults:
|
||||||
|
|||||||
@@ -9,9 +9,8 @@ read_when:
|
|||||||
# Gateway on macOS (external launchd)
|
# Gateway on macOS (external launchd)
|
||||||
|
|
||||||
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
Clawdbot.app no longer bundles Node/Bun or the Gateway runtime. The macOS app
|
||||||
expects an **external** `clawdbot` CLI install, does not spawn the Gateway as a
|
expects an **external** `clawdbot` CLI install and manages a per‑user launchd
|
||||||
child process, and manages a per‑user launchd service to keep the Gateway
|
service to keep the Gateway running.
|
||||||
running (or attaches to an existing local Gateway if one is already running).
|
|
||||||
|
|
||||||
## Install the CLI (required for local mode)
|
## Install the CLI (required for local mode)
|
||||||
|
|
||||||
@@ -39,8 +38,6 @@ Manager:
|
|||||||
Behavior:
|
Behavior:
|
||||||
- “Clawdbot Active” enables/disables the LaunchAgent.
|
- “Clawdbot Active” enables/disables the LaunchAgent.
|
||||||
- App quit does **not** stop the gateway (launchd keeps it alive).
|
- App quit does **not** stop the gateway (launchd keeps it alive).
|
||||||
- If a Gateway is already running on the configured port, the app attaches to
|
|
||||||
it instead of starting a new one.
|
|
||||||
|
|
||||||
Logging:
|
Logging:
|
||||||
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
- launchd stdout/err: `/tmp/clawdbot/clawdbot-gateway.log`
|
||||||
|
|||||||
@@ -5,11 +5,9 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Gateway lifecycle on macOS
|
# Gateway lifecycle on macOS
|
||||||
|
|
||||||
The macOS app **manages the Gateway via launchd** by default and does not spawn
|
The macOS app **manages the Gateway via launchd** by default. The launchd job
|
||||||
the Gateway as a child process. It first tries to attach to an already‑running
|
uses the external `clawdbot` CLI (no embedded runtime). This gives you reliable
|
||||||
Gateway on the configured port; if none is reachable, it enables the launchd
|
auto‑start at login and restart on crashes.
|
||||||
service via the external `clawdbot` CLI (no embedded runtime). This gives you
|
|
||||||
reliable auto‑start at login and restart on crashes.
|
|
||||||
|
|
||||||
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
Child‑process mode (Gateway spawned directly by the app) is **not in use** today.
|
||||||
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
If you need tighter coupling to the UI, run the Gateway manually in a terminal.
|
||||||
|
|||||||
@@ -14,7 +14,15 @@ Before building the app, ensure you have the following installed:
|
|||||||
1. **Xcode 26.2+**: Required for Swift development.
|
1. **Xcode 26.2+**: Required for Swift development.
|
||||||
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
2. **Node.js 22+ & pnpm**: Required for the gateway, CLI, and packaging scripts.
|
||||||
|
|
||||||
## 1. Install Dependencies
|
## 1. Initialize Submodules
|
||||||
|
|
||||||
|
Clawdbot depends on several submodules (like `Peekaboo`). You must initialize these recursively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git submodule update --init --recursive
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Install Dependencies
|
||||||
|
|
||||||
Install the project-wide dependencies:
|
Install the project-wide dependencies:
|
||||||
|
|
||||||
@@ -22,7 +30,7 @@ Install the project-wide dependencies:
|
|||||||
pnpm install
|
pnpm install
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2. Build and Package the App
|
## 3. Build and Package the App
|
||||||
|
|
||||||
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
||||||
|
|
||||||
@@ -32,12 +40,9 @@ To build the macOS app and package it into `dist/Clawdbot.app`, run:
|
|||||||
|
|
||||||
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
If you don't have an Apple Developer ID certificate, the script will automatically use **ad-hoc signing** (`-`).
|
||||||
|
|
||||||
For dev run modes, signing flags, and Team ID troubleshooting, see the macOS app README:
|
|
||||||
https://github.com/clawdbot/clawdbot/blob/main/apps/macos/README.md
|
|
||||||
|
|
||||||
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
> **Note**: Ad-hoc signed apps may trigger security prompts. If the app crashes immediately with "Abort trap 6", see the [Troubleshooting](#troubleshooting) section.
|
||||||
|
|
||||||
## 3. Install the CLI
|
## 4. Install the CLI
|
||||||
|
|
||||||
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
The macOS app expects a global `clawdbot` CLI install to manage background tasks.
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
summary: "PeekabooBridge integration for macOS UI automation"
|
summary: "PeekabooBridge integration for macOS UI automation"
|
||||||
read_when:
|
read_when:
|
||||||
- Hosting PeekabooBridge in Clawdbot.app
|
- Hosting PeekabooBridge in Clawdbot.app
|
||||||
- Integrating Peekaboo via Swift Package Manager
|
- Integrating Peekaboo as a submodule
|
||||||
- Changing PeekabooBridge protocol/paths
|
- Changing PeekabooBridge protocol/paths
|
||||||
---
|
---
|
||||||
# Peekaboo Bridge (macOS UI automation)
|
# Peekaboo Bridge (macOS UI automation)
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ This app is usually built from [`scripts/package-mac-app.sh`](https://github.com
|
|||||||
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
- inject build metadata into Info.plist: `ClawdbotBuildTimestamp` (UTC) and `ClawdbotGitCommit` (short hash) so the About pane can show build, git, and debug/release channel.
|
||||||
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
- **Packaging requires Node 22+**: the script runs TS builds and the Control UI build.
|
||||||
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
- reads `SIGN_IDENTITY` from the environment. Add `export SIGN_IDENTITY="Apple Development: Your Name (TEAMID)"` (or your Developer ID Application cert) to your shell rc to always sign with your cert. Ad-hoc signing requires explicit opt-in via `ALLOW_ADHOC_SIGNING=1` or `SIGN_IDENTITY="-"` (not recommended for permission testing).
|
||||||
- runs a Team ID audit after signing and fails if any Mach-O inside the app bundle is signed by a different Team ID. Set `SKIP_TEAM_ID_CHECK=1` to bypass.
|
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -24,7 +23,6 @@ scripts/package-mac-app.sh # auto-selects identity; errors if none
|
|||||||
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
SIGN_IDENTITY="Developer ID Application: Your Name" scripts/package-mac-app.sh # real cert
|
||||||
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
ALLOW_ADHOC_SIGNING=1 scripts/package-mac-app.sh # ad-hoc (permissions will not stick)
|
||||||
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
SIGN_IDENTITY="-" scripts/package-mac-app.sh # explicit ad-hoc (same caveat)
|
||||||
DISABLE_LIBRARY_VALIDATION=1 scripts/package-mac-app.sh # dev-only Sparkle Team ID mismatch workaround
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Ad-hoc Signing Note
|
### Ad-hoc Signing Note
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Clawdbot macOS IPC architecture
|
# Clawdbot macOS IPC architecture
|
||||||
|
|
||||||
**Current model:** a local Unix socket connects the **node service** to the **macOS app** for exec approvals + `system.run`. There is no `clawdbot-mac` CLI; agent actions still flow through the Gateway WebSocket and `node.invoke`. UI automation uses PeekabooBridge.
|
**Current model:** there is **no local control socket** and no `clawdbot-mac` CLI. All agent actions go through the Gateway WebSocket and `node.invoke`. UI automation still uses PeekabooBridge.
|
||||||
|
|
||||||
## Goals
|
## Goals
|
||||||
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
- Single GUI app instance that owns all TCC-facing work (notifications, screen recording, mic, speech, AppleScript).
|
||||||
@@ -13,29 +13,19 @@ read_when:
|
|||||||
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
- Predictable permissions: always the same signed bundle ID, launched by launchd, so TCC grants stick.
|
||||||
|
|
||||||
## How it works
|
## How it works
|
||||||
### Gateway + node bridge
|
### Gateway + node bridge (current)
|
||||||
- The app runs the Gateway (local mode) and connects to it as a node.
|
- The app runs the Gateway (local mode) and connects to it as a node.
|
||||||
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
- Agent actions are performed via `node.invoke` (e.g. `system.run`, `system.notify`, `canvas.*`).
|
||||||
|
|
||||||
### Node service + app IPC
|
|
||||||
- A headless node service connects to the Gateway bridge.
|
|
||||||
- `system.run` requests are forwarded to the macOS app over a local Unix socket.
|
|
||||||
- The app performs the exec in UI context, prompts if needed, and returns output.
|
|
||||||
|
|
||||||
Diagram (SCI):
|
|
||||||
```
|
|
||||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
### PeekabooBridge (UI automation)
|
### PeekabooBridge (UI automation)
|
||||||
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
- UI automation uses a separate UNIX socket named `bridge.sock` and the PeekabooBridge JSON protocol.
|
||||||
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
- Host preference order (client-side): Peekaboo.app → Claude.app → Clawdbot.app → local execution.
|
||||||
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
|
- Security: bridge hosts require an allowed TeamID; DEBUG-only same-UID escape hatch is guarded by `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (Peekaboo convention).
|
||||||
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
|
- See: [PeekabooBridge usage](/platforms/mac/peekaboo) for details.
|
||||||
|
|
||||||
|
### Mach/XPC
|
||||||
|
- Not required for automation; `node.invoke` + PeekabooBridge cover current needs.
|
||||||
|
|
||||||
## Operational flows
|
## Operational flows
|
||||||
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
- Restart/rebuild: `SIGN_IDENTITY="Apple Development: <Developer Name> (<TEAMID>)" scripts/restart-mac.sh`
|
||||||
- Kills existing instances
|
- Kills existing instances
|
||||||
@@ -48,4 +38,3 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
|||||||
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
- PeekabooBridge: `PEEKABOO_ALLOW_UNSIGNED_SOCKET_CLIENTS=1` (DEBUG-only) may allow same-UID callers for local development.
|
||||||
- All communication remains local-only; no network sockets are exposed.
|
- All communication remains local-only; no network sockets are exposed.
|
||||||
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
- TCC prompts originate only from the GUI app bundle; keep the signed bundle ID stable across rebuilds.
|
||||||
- IPC hardening: socket mode `0600`, token, peer-UID checks, HMAC challenge/response, short TTL.
|
|
||||||
+18
-35
@@ -7,8 +7,8 @@ read_when:
|
|||||||
# Clawdbot macOS Companion (menu bar + gateway broker)
|
# Clawdbot macOS Companion (menu bar + gateway broker)
|
||||||
|
|
||||||
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
The macOS app is the **menu‑bar companion** for Clawdbot. It owns permissions,
|
||||||
manages/attaches to the Gateway locally (launchd or manual), and exposes macOS
|
manages the Gateway locally, and exposes macOS capabilities to the agent as a
|
||||||
capabilities to the agent as a node.
|
node.
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
|
|
||||||
@@ -17,18 +17,16 @@ capabilities to the agent as a node.
|
|||||||
Speech Recognition, Automation/AppleScript).
|
Speech Recognition, Automation/AppleScript).
|
||||||
- Runs or connects to the Gateway (local or remote).
|
- Runs or connects to the Gateway (local or remote).
|
||||||
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
- Exposes macOS‑only tools (Canvas, Camera, Screen Recording, `system.run`).
|
||||||
- Starts the local node host service in **remote** mode (launchd), and stops it in **local** mode.
|
|
||||||
- Optionally hosts **PeekabooBridge** for UI automation.
|
- Optionally hosts **PeekabooBridge** for UI automation.
|
||||||
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
- Installs the global CLI (`clawdbot`) via npm/pnpm on request (bun not recommended for the Gateway runtime).
|
||||||
|
|
||||||
## Local vs remote mode
|
## Local vs remote mode
|
||||||
|
|
||||||
- **Local** (default): the app attaches to a running local Gateway if present;
|
- **Local** (default): the app ensures a local Gateway is running via launchd.
|
||||||
otherwise it enables the launchd service via `clawdbot daemon`.
|
|
||||||
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
- **Remote**: the app connects to a Gateway over SSH/Tailscale and never starts
|
||||||
a local process.
|
a local process.
|
||||||
The app starts the local **node host service** so the remote Gateway can reach this Mac.
|
- **Attach‑only** (debug): the app connects to an already‑running local Gateway
|
||||||
The app does not spawn the Gateway as a child process.
|
and never spawns its own.
|
||||||
|
|
||||||
## Launchd control
|
## Launchd control
|
||||||
|
|
||||||
@@ -56,44 +54,29 @@ The macOS app presents itself as a node. Common commands:
|
|||||||
|
|
||||||
The node reports a `permissions` map so agents can decide what’s allowed.
|
The node reports a `permissions` map so agents can decide what’s allowed.
|
||||||
|
|
||||||
Node service + app IPC:
|
## Node run policy + allowlist
|
||||||
- When the headless node service is running (remote mode), it connects to the Gateway bridge.
|
|
||||||
- `system.run` executes in the macOS app (UI/TCC context) over a local Unix socket; prompts + output stay in-app.
|
|
||||||
|
|
||||||
Diagram (SCI):
|
`system.run` is controlled by the macOS app **Node Run Commands** policy:
|
||||||
```
|
|
||||||
Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Exec approvals (system.run)
|
- `Always Ask`: prompt per command (default).
|
||||||
|
- `Always Allow`: run without prompts.
|
||||||
|
- `Never`: disable `system.run` (tool not advertised).
|
||||||
|
|
||||||
`system.run` is controlled by **Exec approvals** in the macOS app (Settings → Exec approvals).
|
The policy + allowlist live on the Mac in:
|
||||||
Security + ask + allowlist are stored locally on the Mac in:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
~/.clawdbot/exec-approvals.json
|
~/.clawdbot/macos-node.json
|
||||||
```
|
```
|
||||||
|
|
||||||
Example:
|
Schema:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"version": 1,
|
"systemRun": {
|
||||||
"defaults": {
|
"policy": "ask",
|
||||||
"security": "deny",
|
"allowlist": [
|
||||||
"ask": "on-miss"
|
"[\"/bin/echo\",\"hello\"]"
|
||||||
},
|
]
|
||||||
"agents": {
|
|
||||||
"main": {
|
|
||||||
"security": "allowlist",
|
|
||||||
"ask": "on-miss",
|
|
||||||
"allowlist": [
|
|
||||||
{ "pattern": "/opt/homebrew/bin/rg" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-2
@@ -37,7 +37,6 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
|
|
||||||
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
- Microsoft Teams is plugin-only as of 2026.1.15; install `@clawdbot/msteams` if you use Teams.
|
||||||
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
- Memory (Core) — bundled memory search plugin (enabled by default via `plugins.slots.memory`)
|
||||||
- Memory (LanceDB) — bundled long-term memory plugin (auto-recall/capture; set `plugins.slots.memory = "memory-lancedb"`)
|
|
||||||
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
- [Voice Call](/plugins/voice-call) — `@clawdbot/voice-call`
|
||||||
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
- [Zalo Personal](/plugins/zalouser) — `@clawdbot/zalouser`
|
||||||
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
- [Matrix](/channels/matrix) — `@clawdbot/matrix`
|
||||||
@@ -46,7 +45,7 @@ See [Voice Call](/plugins/voice-call) for a concrete example plugin.
|
|||||||
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
- Google Antigravity OAuth (provider auth) — bundled as `google-antigravity-auth` (disabled by default)
|
||||||
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
- Gemini CLI OAuth (provider auth) — bundled as `google-gemini-cli-auth` (disabled by default)
|
||||||
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
- Qwen OAuth (provider auth) — bundled as `qwen-portal-auth` (disabled by default)
|
||||||
- Copilot Proxy (provider auth) — local VS Code Copilot Proxy bridge; distinct from built-in `github-copilot` device login (bundled, disabled by default)
|
- Copilot Proxy (provider auth) — bundled as `copilot-proxy` (disabled by default)
|
||||||
|
|
||||||
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
|
||||||
register:
|
register:
|
||||||
|
|||||||
@@ -6,27 +6,6 @@ read_when:
|
|||||||
---
|
---
|
||||||
# Github Copilot
|
# Github Copilot
|
||||||
|
|
||||||
## What is GitHub Copilot?
|
|
||||||
|
|
||||||
GitHub Copilot is GitHub's AI coding assistant. It provides access to Copilot
|
|
||||||
models for your GitHub account and plan. Clawdbot can use Copilot as a model
|
|
||||||
provider in two different ways.
|
|
||||||
|
|
||||||
## Two ways to use Copilot in Clawdbot
|
|
||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
|
||||||
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
|
||||||
because it does not require VS Code.
|
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
|
||||||
|
|
||||||
Use the **Copilot Proxy** VS Code extension as a local bridge. Clawdbot talks to
|
|
||||||
the proxy’s `/v1` endpoint and uses the model list you configure there. Choose
|
|
||||||
this when you already run Copilot Proxy in VS Code or need to route through it.
|
|
||||||
You must enable the plugin and keep the VS Code extension running.
|
|
||||||
|
|
||||||
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
Use GitHub Copilot as a model provider (`github-copilot`). The login command runs
|
||||||
the GitHub device flow, saves an auth profile, and updates your config to use that
|
the GitHub device flow, saves an auth profile, and updates your config to use that
|
||||||
profile.
|
profile.
|
||||||
|
|||||||
+11
-25
@@ -29,9 +29,6 @@ read_when:
|
|||||||
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
|
||||||
- **Node identity:** use existing `nodeId`.
|
- **Node identity:** use existing `nodeId`.
|
||||||
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
|
||||||
- **Node host state:** `~/.clawdbot/node.json` (node id + pairing token).
|
|
||||||
- **macOS exec host:** run `system.run` inside the macOS app; node service forwards requests over local IPC.
|
|
||||||
- **No XPC helper:** stick to Unix socket + token + peer checks.
|
|
||||||
|
|
||||||
## Key concepts
|
## Key concepts
|
||||||
### Host
|
### Host
|
||||||
@@ -141,30 +138,19 @@ Notes:
|
|||||||
|
|
||||||
## UI integration (macOS app)
|
## UI integration (macOS app)
|
||||||
### IPC
|
### IPC
|
||||||
- Unix socket at `~/.clawdbot/exec-approvals.sock` (0600).
|
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
|
||||||
- Token stored in `exec-approvals.json` (0600).
|
- Runner connects and sends an approval request; UI responds with a decision.
|
||||||
- Peer checks: same-UID only.
|
- Token stored in `exec-approvals.json`.
|
||||||
- Challenge/response: nonce + HMAC(token, request-hash) to prevent replay.
|
|
||||||
- Short TTL (e.g., 10s) + max payload + rate limit.
|
|
||||||
|
|
||||||
### Ask flow (macOS app exec host)
|
### Ask flow
|
||||||
1) Node service receives `system.run` from gateway.
|
1) Runner receives `system.run` from gateway.
|
||||||
2) Node service connects to the local socket and sends the prompt/exec request.
|
2) If ask required, runner connects to the socket and sends a prompt request.
|
||||||
3) App validates peer + token + HMAC + TTL, then shows dialog if needed.
|
3) UI shows dialog; returns decision.
|
||||||
4) App executes the command in UI context and returns output.
|
4) Runner enforces decision and proceeds.
|
||||||
5) Node service returns output to gateway.
|
|
||||||
|
|
||||||
If UI missing:
|
If UI missing:
|
||||||
- Apply `askFallback` (`deny|allowlist|full`).
|
- Apply `askFallback` (`deny|allowlist|full`).
|
||||||
|
|
||||||
### Diagram (SCI)
|
|
||||||
```
|
|
||||||
Agent -> Gateway -> Bridge -> Node Service (TS)
|
|
||||||
| IPC (UDS + token + HMAC + TTL)
|
|
||||||
v
|
|
||||||
Mac App (UI + TCC + system.run)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Node identity + binding
|
## Node identity + binding
|
||||||
- Use existing `nodeId` from Bridge pairing.
|
- Use existing `nodeId` from Bridge pairing.
|
||||||
- Binding model:
|
- Binding model:
|
||||||
@@ -182,9 +168,9 @@ Agent -> Gateway -> Bridge -> Node Service (TS)
|
|||||||
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
|
||||||
|
|
||||||
### Event text
|
### Event text
|
||||||
- `Exec started (node=<id>, id=<runId>)`
|
- `Exec started (host=node, node=<id>, id=<runId>)`
|
||||||
- `Exec finished (node=<id>, id=<runId>, code=<code>)` + optional output tail
|
- `Exec finished (exit=<code>, tail=<...>)`
|
||||||
- `Exec denied (node=<id>, id=<runId>, <reason>)`
|
- `Exec denied (policy=<...>, reason=<...>)`
|
||||||
|
|
||||||
### Transport
|
### Transport
|
||||||
Option A (recommended):
|
Option A (recommended):
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
---
|
|
||||||
summary: "Strict config validation + doctor-only migrations"
|
|
||||||
read_when:
|
|
||||||
- Designing or implementing config validation behavior
|
|
||||||
- Working on config migrations or doctor workflows
|
|
||||||
- Handling plugin config schemas or plugin load gating
|
|
||||||
---
|
|
||||||
# Strict config validation (doctor-only migrations)
|
|
||||||
|
|
||||||
## Goals
|
|
||||||
- **Reject unknown config keys everywhere** (root + nested).
|
|
||||||
- **Reject plugin config without a schema**; don’t load that plugin.
|
|
||||||
- **Remove legacy auto-migration on load**; migrations run via doctor only.
|
|
||||||
- **Auto-run doctor (dry-run) on startup**; if invalid, block non-diagnostic commands.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
- Backward compatibility on load (legacy keys do not auto-migrate).
|
|
||||||
- Silent drops of unrecognized keys.
|
|
||||||
|
|
||||||
## Strict validation rules
|
|
||||||
- Config must match the schema exactly at every level.
|
|
||||||
- Unknown keys are validation errors (no passthrough at root or nested).
|
|
||||||
- `plugins.entries.<id>.config` must be validated by the plugin’s schema.
|
|
||||||
- If a plugin lacks a schema, **reject plugin load** and surface a clear error.
|
|
||||||
|
|
||||||
## Plugin schema enforcement
|
|
||||||
- Each plugin provides a strict schema for its config (no passthrough).
|
|
||||||
- Plugin load flow:
|
|
||||||
1) Resolve plugin schema by plugin id.
|
|
||||||
2) Validate config against the schema.
|
|
||||||
3) If missing schema or invalid config: block plugin load, record error.
|
|
||||||
- Error message includes:
|
|
||||||
- Plugin id
|
|
||||||
- Reason (missing schema / invalid config)
|
|
||||||
- Path(s) that failed validation
|
|
||||||
|
|
||||||
## Doctor flow
|
|
||||||
- Doctor runs **every time** config is loaded (dry-run by default).
|
|
||||||
- If config invalid:
|
|
||||||
- Print a summary + actionable errors.
|
|
||||||
- Instruct: `clawdbot doctor --fix`.
|
|
||||||
- `clawdbot doctor --fix`:
|
|
||||||
- Applies migrations.
|
|
||||||
- Removes unknown keys.
|
|
||||||
- Writes updated config.
|
|
||||||
|
|
||||||
## Command gating (when config is invalid)
|
|
||||||
Allowed (diagnostic-only):
|
|
||||||
- `clawdbot doctor`
|
|
||||||
- `clawdbot logs`
|
|
||||||
- `clawdbot health`
|
|
||||||
- `clawdbot help`
|
|
||||||
- `clawdbot status`
|
|
||||||
- `clawdbot service`
|
|
||||||
|
|
||||||
Everything else must hard-fail with: “Config invalid. Run `clawdbot doctor --fix`.”
|
|
||||||
|
|
||||||
## Error UX format
|
|
||||||
- Single summary header.
|
|
||||||
- Grouped sections:
|
|
||||||
- Unknown keys (full paths)
|
|
||||||
- Legacy keys / migrations needed
|
|
||||||
- Plugin load failures (plugin id + reason + path)
|
|
||||||
|
|
||||||
## Implementation touchpoints
|
|
||||||
- `src/config/zod-schema.ts`: remove root passthrough; strict objects everywhere.
|
|
||||||
- `src/config/zod-schema.providers.ts`: ensure strict channel schemas.
|
|
||||||
- `src/config/validation.ts`: fail on unknown keys; do not apply legacy migrations.
|
|
||||||
- `src/config/io.ts`: remove legacy auto-migrations; always run doctor dry-run.
|
|
||||||
- `src/config/legacy*.ts`: move usage to doctor only.
|
|
||||||
- `src/plugins/*`: add schema registry + gating.
|
|
||||||
- CLI command gating in `src/cli`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Unknown key rejection (root + nested).
|
|
||||||
- Plugin missing schema → plugin load blocked with clear error.
|
|
||||||
- Invalid config → gateway startup blocked except diagnostic commands.
|
|
||||||
- Doctor dry-run auto; `doctor --fix` writes corrected config.
|
|
||||||
Loaded 100 of 719 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user