Compare commits

..
Author SHA1 Message Date
Peter Steinberger 9cf4fc3867 fix: honor send path/filePath inputs (#1444) (thanks @hopyky) 2026-01-23 02:10:05 +00:00
Matt mini fb7159be5e Fix: Support path and filePath parameters in message send action
The message tool accepts path and filePath parameters in its schema,
but these were never converted to mediaUrl, causing local files to
be ignored when sending messages.

Changes:
- src/agents/tools/message-tool.ts: Convert path/filePath to media with file:// URL
- src/infra/outbound/message-action-runner.ts: Allow hydrateSendAttachmentParams for "send" action

Fixes issue where local audio files (and other media) couldn't be sent
via the message tool with the path parameter.

Users can now use:
  message({ path: "/tmp/file.ogg" })
  message({ filePath: "/tmp/file.ogg" })
2026-01-23 02:03:37 +00:00
157 changed files with 4980 additions and 5738 deletions

No files matched your search

+58 -36
View File
@@ -2,23 +2,24 @@
Docs: https://docs.clawd.bot Docs: https://docs.clawd.bot
## 2026.1.22 ## 2026.1.22 (unreleased)
### Changes ### Changes
- Highlight: Mattermost plugin channel support with pairing + allowlist gating. (#1428) Thanks @damoahdominic.
- Highlight: OpenProse plugin skill pack with `/prose` slash command, plugin-shipped skills, and docs. https://docs.clawd.bot/prose
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. - Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early. - Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting - Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405) - Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky. - Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla. - UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead). - Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli. - Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. - MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update - CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
### Breaking ### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert. - **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
@@ -31,19 +32,15 @@ Docs: https://docs.clawd.bot
- Agents: surface concrete API error details instead of generic AI service errors. - Agents: surface concrete API error details instead of generic AI service errors.
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c. <<<<<<< Updated upstream
- Agents: make tool summaries more readable and only show optional params when set. - Agents: make tool summaries more readable and only show optional params when set.
- Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.
- Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436) Thanks @sibbl.
- CLI: prefer `~` for home paths in output.
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. - Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
||||||| Stash base
=======
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact. - Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik. >>>>>>> Stashed changes
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 2026.1.21-2 ## 2026.1.21-2
@@ -53,38 +50,63 @@ Docs: https://docs.clawd.bot
## 2026.1.21 ## 2026.1.21
### Highlights
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
### Changes ### Changes
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker. - Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output. - Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
- CLI: exec approvals mutations render tables instead of raw JSON. - Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
- Exec approvals: support wildcard agent allowlists (`*`) across all agents. - CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing. - Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution. - CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs. - Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability. - Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot. - Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.
### Breaking ### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http - **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes ### Fixes
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman. - Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380) - Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x. - Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
- Cache: restore the 1h cache TTL option and reset the pruning window.
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell. - Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging. - Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
- Model picker: list the full catalog when no model allowlist is configured.
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
## 2026.1.20 ## 2026.1.20
+18 -19
View File
@@ -477,29 +477,28 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/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/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/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></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/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/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/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/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/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/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/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/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/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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/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/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/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/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/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></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/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/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/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/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/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/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/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/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></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/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/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></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/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></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/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></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/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/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/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/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></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/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></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/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/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/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></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/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/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/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/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/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/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/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></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/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></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/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/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/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/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/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/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/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/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/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/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/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/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/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></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/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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></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/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></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/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/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/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></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/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></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/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/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/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/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/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/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/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
<a href="https://github.com/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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></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/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></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/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></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/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/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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></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=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></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=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></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=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/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=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></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/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=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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/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/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></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/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></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/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></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/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/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></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=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></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/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></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=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></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/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></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/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/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></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/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
</p> </p>
+1 -2
View File
@@ -147,8 +147,7 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`) - **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`) - **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`) - **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`) - **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
### Message IDs (short vs full) ### Message IDs (short vs full)
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens. Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
+1 -20
View File
@@ -304,8 +304,7 @@ Slack uses Socket Mode only (no HTTP webhook server). Provide both tokens:
"policy": "pairing", "policy": "pairing",
"allowFrom": ["U123", "U456", "*"], "allowFrom": ["U123", "U456", "*"],
"groupEnabled": false, "groupEnabled": false,
"groupChannels": ["G123"], "groupChannels": ["G123"]
"replyToMode": "all"
}, },
"channels": { "channels": {
"C123": { "allow": true, "requireMention": true }, "C123": { "allow": true, "requireMention": true },
@@ -362,24 +361,6 @@ By default, Clawdbot replies in the main channel. Use `channels.slack.replyToMod
The mode applies to both auto-replies and agent tool calls (`slack sendMessage`). The mode applies to both auto-replies and agent tool calls (`slack sendMessage`).
### DM-specific threading
You can configure different threading behavior for DMs vs channels by setting `channels.slack.dm.replyToMode`:
```json5
{
channels: {
slack: {
replyToMode: "off", // default for channels
dm: {
replyToMode: "all" // DMs always thread
}
}
}
}
```
When `dm.replyToMode` is set, DMs use that mode; channels use the top-level `replyToMode`. If `dm.replyToMode` is not set, both DMs and channels use the top-level setting.
### Manual threading tags ### Manual threading tags
For fine-grained control, use these tags in agent responses: For fine-grained control, use these tags in agent responses:
- `[[reply_to_current]]` — reply to the triggering message (start/continue thread). - `[[reply_to_current]]` — reply to the triggering message (start/continue thread).
+8
View File
@@ -141,6 +141,14 @@
"source": "/message/", "source": "/message/",
"destination": "/cli/message" "destination": "/cli/message"
}, },
{
"source": "/mattermost",
"destination": "/channels/mattermost"
},
{
"source": "/mattermost/",
"destination": "/channels/mattermost"
},
{ {
"source": "/providers/discord", "source": "/providers/discord",
"destination": "/channels/discord" "destination": "/channels/discord"
+1 -1
View File
@@ -128,4 +128,4 @@ If your tool allowlist blocks these tools, OpenProse programs will fail. See [Sk
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects. Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster). For deterministic, approval-gated workflows, compare with [Lobster](/lobster).
+5 -9
View File
@@ -16,9 +16,9 @@ provider in two different ways.
### 1) Built-in GitHub Copilot provider (`github-copilot`) ### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token and use it directly Use the native device-login flow to obtain a GitHub token, then exchange it for
against the Copilot API. This is the **default** and simplest path because it Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
does not require VS Code. Enterprise domains are supported. because it does not require VS Code.
### 2) Copilot Proxy plugin (`copilot-proxy`) ### 2) Copilot Proxy plugin (`copilot-proxy`)
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes. open until it completes.
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
domain (for example `company.ghe.com`).
### Optional flags ### Optional flags
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal. - Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try - Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`). another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and uses it directly - The login stores a GitHub token in the auth profile store and exchanges it for a
for Copilot API calls. Copilot API token when Clawdbot runs.
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
for GitHub Enterprise.
+1 -1
View File
@@ -155,7 +155,7 @@ tool usage guidance is injected into prompts. Some plugins ship their own skills
alongside tools (for example, the voice-call plugin). alongside tools (for example, the voice-call plugin).
Optional plugin tools: Optional plugin tools:
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host). - [Lobster](/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
## Tool inventory ## Tool inventory
-91
View File
@@ -11,10 +11,6 @@ read_when:
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints. Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
## Hook
Your assistant can build the tools that manage itself. Ask for a workflow, and 30 minutes later you have a CLI plus pipelines that run as one call. Lobster is the missing piece: deterministic pipelines, explicit approvals, and resumable state.
## Why ## Why
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime: Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
@@ -28,73 +24,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout. Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later. If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
## Pattern: small CLI + JSON pipes + approvals
Build tiny commands that speak JSON, then chain them into a single Lobster call. (Example command names below — swap in your own.)
```bash
inbox list --json
inbox categorize --json
inbox apply --json
```
```json
{
"action": "run",
"pipeline": "exec --json --shell 'inbox list --json' | exec --stdin json --shell 'inbox categorize --json' | exec --stdin json --shell 'inbox apply --json' | approve --preview-from-stdin --limit 5 --prompt 'Apply changes?'",
"timeoutMs": 30000
}
```
If the pipeline requests approval, resume with the token:
```json
{
"action": "resume",
"token": "<resumeToken>",
"approve": true
}
```
AI triggers the workflow; Lobster executes the steps. Approval gates keep side effects explicit and auditable.
Example: map input items into tool calls:
```bash
gog.gmail.search --query 'newer_than:1d' \
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
```
## Workflow files (.lobster)
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
```yaml
name: inbox-triage
args:
tag:
default: "family"
steps:
- id: collect
command: inbox list --json
- id: categorize
command: inbox categorize --json
stdin: $collect.stdout
- id: approve
command: inbox apply --approve
stdin: $categorize.stdout
approval: required
- id: execute
command: inbox apply --execute
stdin: $categorize.stdout
condition: $approve.approved
```
Notes:
- `stdin: $step.stdout` and `stdin: $step.json` pass a prior step’s output.
- `condition` (or `when`) can gate steps on `$step.approved`.
## Install Lobster ## Install Lobster
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`. Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
@@ -186,16 +115,6 @@ Run a pipeline in tool mode.
} }
``` ```
Run a workflow file with args:
```json
{
"action": "run",
"pipeline": "/path/to/inbox-triage.lobster",
"argsJson": "{\"tag\":\"family\"}"
}
```
### `resume` ### `resume`
Continue a halted workflow after approval. Continue a halted workflow after approval.
@@ -214,7 +133,6 @@ Continue a halted workflow after approval.
- `cwd`: Working directory for the pipeline (defaults to the current process working directory). - `cwd`: Working directory for the pipeline (defaults to the current process working directory).
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000). - `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000). - `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
- `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only).
## Output envelope ## Output envelope
@@ -233,8 +151,6 @@ If `requiresApproval` is present, inspect the prompt and decide:
- `approve: true` → resume and continue side effects - `approve: true` → resume and continue side effects
- `approve: false` → cancel and finalize the workflow - `approve: false` → cancel and finalize the workflow
Use `approve --preview-from-stdin --limit N` to attach a JSON preview to approval requests without custom jq/heredoc glue. Resume tokens are now compact: Lobster stores workflow resume state under its state dir and hands back a small token key.
## OpenProse ## OpenProse
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose). OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
@@ -257,10 +173,3 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
- [Plugins](/plugin) - [Plugins](/plugin)
- [Plugin tool authoring](/plugins/agent-tools) - [Plugin tool authoring](/plugins/agent-tools)
## Case study: community workflows
One public example: a “second brain” CLI + Lobster pipelines that manage three Markdown vaults (personal, partner, shared). The CLI emits JSON for stats, inbox listings, and stale scans; Lobster chains those commands into workflows like `weekly-review`, `inbox-triage`, `memory-consolidation`, and `shared-task-sync`, each with approval gates. AI handles judgment (categorization) when available and falls back to deterministic rules when not.
- Thread: https://x.com/plattenschieber/status/2014508656335770033
- Repo: https://github.com/bloomedai/brain-cli
@@ -521,42 +521,6 @@ describe("bluebubblesMessageActions", () => {
}); });
}); });
it("passes asVoice through sendAttachment", async () => {
const { sendBlueBubblesAttachment } = await import("./attachments.js");
const cfg: ClawdbotConfig = {
channels: {
bluebubbles: {
serverUrl: "http://localhost:1234",
password: "test-password",
},
},
};
const base64Buffer = Buffer.from("voice").toString("base64");
await bluebubblesMessageActions.handleAction({
action: "sendAttachment",
params: {
to: "+15551234567",
filename: "voice.mp3",
buffer: base64Buffer,
contentType: "audio/mpeg",
asVoice: true,
},
cfg,
accountId: null,
});
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
expect.objectContaining({
filename: "voice.mp3",
contentType: "audio/mpeg",
asVoice: true,
}),
);
});
it("throws when buffer is missing for setGroupIcon", async () => { it("throws when buffer is missing for setGroupIcon", async () => {
const cfg: ClawdbotConfig = { const cfg: ClawdbotConfig = {
channels: { channels: {
+1 -13
View File
@@ -3,6 +3,7 @@ import {
BLUEBUBBLES_ACTIONS, BLUEBUBBLES_ACTIONS,
createActionGate, createActionGate,
jsonResult, jsonResult,
readBooleanParam,
readNumberParam, readNumberParam,
readReactionParams, readReactionParams,
readStringParam, readStringParam,
@@ -50,17 +51,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
return readStringParam(params, "text") ?? readStringParam(params, "message"); return readStringParam(params, "text") ?? readStringParam(params, "message");
} }
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
const raw = params[key];
if (typeof raw === "boolean") return raw;
if (typeof raw === "string") {
const trimmed = raw.trim().toLowerCase();
if (trimmed === "true") return true;
if (trimmed === "false") return false;
}
return undefined;
}
/** Supported action names for BlueBubbles */ /** Supported action names for BlueBubbles */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES); const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
@@ -366,7 +356,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const caption = readStringParam(params, "caption"); const caption = readStringParam(params, "caption");
const contentType = const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType"); readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
// Buffer can come from params.buffer (base64) or params.path (file path) // Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer"); const base64Buffer = readStringParam(params, "buffer");
@@ -391,7 +380,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
filename, filename,
contentType: contentType ?? undefined, contentType: contentType ?? undefined,
caption: caption ?? undefined, caption: caption ?? undefined,
asVoice: asVoice ?? undefined,
opts, opts,
}); });
+1 -107
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest"; import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js"; import { downloadBlueBubblesAttachment } from "./attachments.js";
import type { BlueBubblesAttachment } from "./types.js"; import type { BlueBubblesAttachment } from "./types.js";
vi.mock("./accounts.js", () => ({ vi.mock("./accounts.js", () => ({
@@ -238,109 +238,3 @@ describe("downloadBlueBubblesAttachment", () => {
expect(result.buffer).toEqual(new Uint8Array([1])); expect(result.buffer).toEqual(new Uint8Array([1]));
}); });
}); });
describe("sendBlueBubblesAttachment", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
function decodeBody(body: Uint8Array) {
return Buffer.from(body).toString("utf8");
}
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice.mp3",
contentType: "audio/mpeg",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('name="isAudioMessage"');
expect(bodyText).toContain("true");
expect(bodyText).toContain('filename="voice.mp3"');
});
it("normalizes mp3 filenames for voice memos", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice",
contentType: "audio/mpeg",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('filename="voice.mp3"');
expect(bodyText).toContain('name="voice.mp3"');
});
it("throws when asVoice is true but media is not audio", async () => {
await expect(
sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "image.png",
contentType: "image/png",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
}),
).rejects.toThrow("voice messages require audio");
expect(mockFetch).not.toHaveBeenCalled();
});
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
await expect(
sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "voice.wav",
contentType: "audio/wav",
asVoice: true,
opts: { serverUrl: "http://localhost:1234", password: "test" },
}),
).rejects.toThrow("require mp3 or caf");
expect(mockFetch).not.toHaveBeenCalled();
});
it("sanitizes filenames before sending", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
});
await sendBlueBubblesAttachment({
to: "chat_guid:iMessage;-;+15551234567",
buffer: new Uint8Array([1, 2, 3]),
filename: "../evil.mp3",
contentType: "audio/mpeg",
opts: { serverUrl: "http://localhost:1234", password: "test" },
});
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
const bodyText = decodeBody(body);
expect(bodyText).toContain('filename="evil.mp3"');
expect(bodyText).toContain('name="evil.mp3"');
});
});
+2 -58
View File
@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import path from "node:path";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js"; import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveChatGuidForTarget } from "./send.js"; import { resolveChatGuidForTarget } from "./send.js";
@@ -20,30 +19,6 @@ export type BlueBubblesAttachmentOpts = {
}; };
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024; const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
function sanitizeFilename(input: string | undefined, fallback: string): string {
const trimmed = input?.trim() ?? "";
const base = trimmed ? path.basename(trimmed) : "";
return base || fallback;
}
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
const currentExt = path.extname(filename);
if (currentExt.toLowerCase() === extension) return filename;
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
return `${base || fallbackBase}${extension}`;
}
function resolveVoiceInfo(filename: string, contentType?: string) {
const normalizedType = contentType?.trim().toLowerCase();
const extension = path.extname(filename).toLowerCase();
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
return { isAudio, isMp3, isCaf };
}
function resolveAccount(params: BlueBubblesAttachmentOpts) { function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({ const account = resolveBlueBubblesAccount({
@@ -129,7 +104,6 @@ function extractMessageId(payload: unknown): string {
/** /**
* Send an attachment via BlueBubbles API. * Send an attachment via BlueBubbles API.
* Supports sending media files (images, videos, audio, documents) to a chat. * Supports sending media files (images, videos, audio, documents) to a chat.
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
*/ */
export async function sendBlueBubblesAttachment(params: { export async function sendBlueBubblesAttachment(params: {
to: string; to: string;
@@ -139,37 +113,12 @@ export async function sendBlueBubblesAttachment(params: {
caption?: string; caption?: string;
replyToMessageGuid?: string; replyToMessageGuid?: string;
replyToPartIndex?: number; replyToPartIndex?: number;
asVoice?: boolean;
opts?: BlueBubblesAttachmentOpts; opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> { }): Promise<SendBlueBubblesAttachmentResult> {
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params; const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
let { buffer, filename, contentType } = params; params;
const wantsVoice = asVoice === true;
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
filename = sanitizeFilename(filename, fallbackName);
contentType = contentType?.trim() || undefined;
const { baseUrl, password } = resolveAccount(opts); const { baseUrl, password } = resolveAccount(opts);
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
const isAudioMessage = wantsVoice;
if (isAudioMessage) {
const voiceInfo = resolveVoiceInfo(filename, contentType);
if (!voiceInfo.isAudio) {
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
}
if (voiceInfo.isMp3) {
filename = ensureExtension(filename, ".mp3", fallbackName);
contentType = contentType ?? "audio/mpeg";
} else if (voiceInfo.isCaf) {
filename = ensureExtension(filename, ".caf", fallbackName);
contentType = contentType ?? "audio/x-caf";
} else {
throw new Error(
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
);
}
}
const target = resolveSendTarget(to); const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({ const chatGuid = await resolveChatGuidForTarget({
baseUrl, baseUrl,
@@ -221,11 +170,6 @@ export async function sendBlueBubblesAttachment(params: {
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`); addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
addField("method", "private-api"); addField("method", "private-api");
// Add isAudioMessage flag for voice memos
if (isAudioMessage) {
addField("isAudioMessage", "true");
}
const trimmedReplyTo = replyToMessageGuid?.trim(); const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo) { if (trimmedReplyTo) {
addField("selectedMessageGuid", trimmedReplyTo); addField("selectedMessageGuid", trimmedReplyTo);
-3
View File
@@ -59,7 +59,6 @@ export async function sendBlueBubblesMedia(params: {
caption?: string; caption?: string;
replyToId?: string | null; replyToId?: string | null;
accountId?: string; accountId?: string;
asVoice?: boolean;
}) { }) {
const { const {
cfg, cfg,
@@ -72,7 +71,6 @@ export async function sendBlueBubblesMedia(params: {
caption, caption,
replyToId, replyToId,
accountId, accountId,
asVoice,
} = params; } = params;
const core = getBlueBubblesRuntime(); const core = getBlueBubblesRuntime();
const maxBytes = resolveChannelMediaMaxBytes({ const maxBytes = resolveChannelMediaMaxBytes({
@@ -148,7 +146,6 @@ export async function sendBlueBubblesMedia(params: {
filename: resolvedFilename ?? "attachment", filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined, contentType: resolvedContentType ?? undefined,
replyToMessageGuid, replyToMessageGuid,
asVoice,
opts: { opts: {
cfg, cfg,
accountId, accountId,
+1 -7
View File
@@ -159,7 +159,6 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf. // NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }), action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
pipeline: Type.Optional(Type.String()), pipeline: Type.Optional(Type.String()),
argsJson: Type.Optional(Type.String()),
token: Type.Optional(Type.String()), token: Type.Optional(Type.String()),
approve: Type.Optional(Type.Boolean()), approve: Type.Optional(Type.Boolean()),
lobsterPath: Type.Optional(Type.String()), lobsterPath: Type.Optional(Type.String()),
@@ -182,12 +181,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
if (action === "run") { if (action === "run") {
const pipeline = typeof params.pipeline === "string" ? params.pipeline : ""; const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
if (!pipeline.trim()) throw new Error("pipeline required"); if (!pipeline.trim()) throw new Error("pipeline required");
const argv = ["run", "--mode", "tool", pipeline]; return ["run", "--mode", "tool", pipeline];
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
if (argsJson.trim()) {
argv.push("--args-json", argsJson);
}
return argv;
} }
if (action === "resume") { if (action === "resume") {
const token = typeof params.token === "string" ? params.token : ""; const token = typeof params.token === "string" ? params.token : "";
@@ -53,7 +53,7 @@ export async function resolveMatrixAuth(params?: {
saveMatrixCredentials, saveMatrixCredentials,
credentialsMatchConfig, credentialsMatchConfig,
touchMatrixCredentials, touchMatrixCredentials,
} = await import("../credentials.js"); } = await import("./credentials.js");
const cached = loadMatrixCredentials(env); const cached = loadMatrixCredentials(env);
const cachedCredentials = const cachedCredentials =
@@ -1,102 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { MatrixClient } from "matrix-bot-sdk";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
let normalizeThreadId: typeof import("./targets.js").normalizeThreadId;
beforeEach(async () => {
vi.resetModules();
({ resolveMatrixRoomId, normalizeThreadId } = await import("./targets.js"));
});
describe("resolveMatrixRoomId", () => {
it("uses m.direct when available", async () => {
const userId = "@user:example.org";
const client = {
getAccountData: vi.fn().mockResolvedValue({
[userId]: ["!room:example.org"],
}),
getJoinedRooms: vi.fn(),
getJoinedRoomMembers: vi.fn(),
setAccountData: vi.fn(),
} as unknown as MatrixClient;
const roomId = await resolveMatrixRoomId(client, userId);
expect(roomId).toBe("!room:example.org");
expect(client.getJoinedRooms).not.toHaveBeenCalled();
expect(client.setAccountData).not.toHaveBeenCalled();
});
it("falls back to joined rooms and persists m.direct", async () => {
const userId = "@fallback:example.org";
const roomId = "!room:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue([
"@bot:example.org",
userId,
]),
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalledWith(
EventType.Direct,
expect.objectContaining({ [userId]: [roomId] }),
);
});
it("continues when a room member lookup fails", async () => {
const userId = "@continue:example.org";
const roomId = "!good:example.org";
const setAccountData = vi.fn().mockResolvedValue(undefined);
const getJoinedRoomMembers = vi
.fn()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce(["@bot:example.org", userId]);
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue(["!bad:example.org", roomId]),
getJoinedRoomMembers,
setAccountData,
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
expect(setAccountData).toHaveBeenCalled();
});
it("allows larger rooms when no 1:1 match exists", async () => {
const userId = "@group:example.org";
const roomId = "!group:example.org";
const client = {
getAccountData: vi.fn().mockRejectedValue(new Error("nope")),
getJoinedRooms: vi.fn().mockResolvedValue([roomId]),
getJoinedRoomMembers: vi.fn().mockResolvedValue([
"@bot:example.org",
userId,
"@extra:example.org",
]),
setAccountData: vi.fn().mockResolvedValue(undefined),
} as unknown as MatrixClient;
const resolved = await resolveMatrixRoomId(client, userId);
expect(resolved).toBe(roomId);
});
});
describe("normalizeThreadId", () => {
it("returns null for empty thread ids", () => {
expect(normalizeThreadId(" ")).toBeNull();
expect(normalizeThreadId("$thread")).toBe("$thread");
});
});
+10 -88
View File
@@ -16,100 +16,22 @@ export function normalizeThreadId(raw?: string | number | null): string | null {
return trimmed ? trimmed : null; return trimmed ? trimmed : null;
} }
const directRoomCache = new Map<string, string>(); async function resolveDirectRoomId(client: MatrixClient, userId: string): Promise<string> {
async function persistDirectRoom(
client: MatrixClient,
userId: string,
roomId: string,
): Promise<void> {
let directContent: MatrixDirectAccountData | null = null;
try {
directContent = (await client.getAccountData(
EventType.Direct,
)) as MatrixDirectAccountData | null;
} catch {
// Ignore fetch errors and fall back to an empty map.
}
const existing =
directContent && !Array.isArray(directContent) ? directContent : {};
const current = Array.isArray(existing[userId]) ? existing[userId] : [];
if (current[0] === roomId) return;
const next = [roomId, ...current.filter((id) => id !== roomId)];
try {
await client.setAccountData(EventType.Direct, {
...existing,
[userId]: next,
});
} catch {
// Ignore persistence errors.
}
}
async function resolveDirectRoomId(
client: MatrixClient,
userId: string,
): Promise<string> {
const trimmed = userId.trim(); const trimmed = userId.trim();
if (!trimmed.startsWith("@")) { if (!trimmed.startsWith("@")) {
throw new Error( throw new Error(`Matrix user IDs must be fully qualified (got "${trimmed}")`);
`Matrix user IDs must be fully qualified (got "${trimmed}")`,
);
} }
// matrix-bot-sdk: use getAccountData to retrieve m.direct
const cached = directRoomCache.get(trimmed);
if (cached) return cached;
// 1) Fast path: use account data (m.direct) for *this* logged-in user (the bot).
try { try {
const directContent = (await client.getAccountData( const directContent = await client.getAccountData(EventType.Direct) as MatrixDirectAccountData | null;
EventType.Direct, const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : [];
)) as MatrixDirectAccountData | null; if (list.length > 0) return list[0];
const list = Array.isArray(directContent?.[trimmed])
? directContent[trimmed]
: [];
if (list.length > 0) {
directRoomCache.set(trimmed, list[0]);
return list[0];
}
} catch { } catch {
// Ignore and fall back. // Ignore errors, try fetching from server
} }
throw new Error(
// 2) Fallback: look for an existing joined room that looks like a 1:1 with the user. `No m.direct room found for ${trimmed}. Open a DM first so Matrix can set m.direct.`,
// Many clients only maintain m.direct for *their own* account data, so relying on it is brittle. );
let fallbackRoom: string | null = null;
try {
const rooms = await client.getJoinedRooms();
for (const roomId of rooms) {
let members: string[];
try {
members = await client.getJoinedRoomMembers(roomId);
} catch {
continue;
}
if (!members.includes(trimmed)) continue;
// Prefer classic 1:1 rooms, but allow larger rooms if requested.
if (members.length === 2) {
directRoomCache.set(trimmed, roomId);
await persistDirectRoom(client, trimmed, roomId);
return roomId;
}
if (!fallbackRoom) {
fallbackRoom = roomId;
}
}
} catch {
// Ignore and fall back.
}
if (fallbackRoom) {
directRoomCache.set(trimmed, fallbackRoom);
await persistDirectRoom(client, trimmed, fallbackRoom);
return fallbackRoom;
}
throw new Error(`No direct room found for ${trimmed} (m.direct missing)`);
} }
export async function resolveMatrixRoomId( export async function resolveMatrixRoomId(
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2025 OpenProse
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+2 -3
View File
@@ -19,7 +19,6 @@ import {
readStringParam, readStringParam,
resolveDefaultSlackAccountId, resolveDefaultSlackAccountId,
resolveSlackAccount, resolveSlackAccount,
resolveSlackReplyToMode,
resolveSlackGroupRequireMention, resolveSlackGroupRequireMention,
buildSlackThreadingToolContext, buildSlackThreadingToolContext,
setAccountEnabledInConfigSection, setAccountEnabledInConfigSection,
@@ -163,8 +162,8 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
+14 -11
View File
@@ -46,7 +46,7 @@ TRASH
local needle="$1" local needle="$1"
local timeout_s="${2:-45}" local timeout_s="${2:-45}"
local needle_compact local needle_compact
needle_compact="$(printf "%s" "$needle" | tr -cd "[:alnum:]")" needle_compact="$(printf "%s" "$needle" | sed -E "s/[[:space:]]+//g")"
local start_s local start_s
start_s="$(date +%s)" start_s="$(date +%s)"
while true; do while true; do
@@ -61,12 +61,17 @@ TRASH
let text = \"\"; let text = \"\";
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); } try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
if (text.length > 20000) text = text.slice(-20000); if (text.length > 20000) text = text.slice(-20000);
const stripAnsi = (value) => value.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\"); const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
const compact = (value) => stripAnsi(value).toLowerCase().replace(/[^a-z0-9]+/g, \"\"); const haystack = sanitize(text);
const haystack = compact(text); const safeNeedle = sanitize(needle);
const compactNeedle = compact(needle); const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
if (!compactNeedle) process.exit(1); let escaped = \"\";
process.exit(haystack.includes(compactNeedle) ? 0 : 1); for (const ch of safeNeedle) {
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
}
const pattern = escaped.split(\"\").join(\".*\");
const re = new RegExp(pattern, \"i\");
process.exit(re.test(haystack) ? 0 : 1);
"; then "; then
return 0 return 0
fi fi
@@ -255,11 +260,9 @@ TRASH
send_skills_flow() { send_skills_flow() {
# Select skills section and skip optional installs. # Select skills section and skip optional installs.
wait_for_log "Where will the Gateway run?" 60 || true send $'"'"'\r'"'"' 1.2
send $'"'"'\r'"'"' 0.6
# Configure skills now? -> No # Configure skills now? -> No
wait_for_log "Configure skills now?" 60 || true send $'"'"'n\r'"'"' 1.5
send $'"'"'n\r'"'"' 0.8
send "" 1.0 send "" 1.0
} }
+1 -5
View File
@@ -115,11 +115,7 @@ if (!shouldBuild()) {
runNode(); runNode();
} else { } else {
logRunner("Building TypeScript (dist is stale)."); logRunner("Building TypeScript (dist is stale).");
const pnpmArgs = ["exec", compiler, ...projectArgs]; const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
const buildArgs =
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
const build = spawn(buildCmd, buildArgs, {
cwd, cwd,
env, env,
stdio: "inherit", stdio: "inherit",
-70
View File
@@ -1,70 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
vi.mock("@mariozechner/pi-ai", () => ({
getOAuthApiKey: vi.fn(() => {
throw new Error("refresh should not be called");
}),
}));
describe("auth-profiles (github-copilot)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tempDir: string | null = null;
afterEach(async () => {
vi.unstubAllGlobals();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
});
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
};
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
const loaded = ensureAuthProfileStore();
const resolved = await resolveApiKeyForProfile({
store: loaded,
profileId: "github-copilot:github",
});
expect(resolved?.apiKey).toBe("gh-token");
});
});
-23
View File
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
const cred = store.profiles[params.profileId]; const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") return null; if (!cred || cred.type !== "oauth") return null;
if (
cred.provider === "github-copilot" &&
(!Number.isFinite(cred.expires) || cred.expires <= 0)
) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
if (Date.now() < cred.expires) { if (Date.now() < cred.expires) {
return { return {
apiKey: buildOAuthApiKey(cred.provider, cred), apiKey: buildOAuthApiKey(cred.provider, cred),
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
if (profileConfig && profileConfig.provider !== cred.provider) return null; if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null; if (profileConfig && profileConfig.mode !== cred.type) return null;
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (Date.now() < cred.expires) { if (Date.now() < cred.expires) {
return { return {
apiKey: buildOAuthApiKey(cred.provider, cred), apiKey: buildOAuthApiKey(cred.provider, cred),
-1
View File
@@ -19,7 +19,6 @@ export type TokenCredential = {
token: string; token: string;
/** Optional expiry timestamp (ms since epoch). */ /** Optional expiry timestamp (ms since epoch). */
expires?: number; expires?: number;
enterpriseUrl?: string;
email?: string; email?: string;
}; };
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -52,6 +51,16 @@ describe("models-config", () => {
try { try {
vi.resetModules(); vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
const agentDir = path.join(home, "agent-default-base-url"); const agentDir = path.join(home, "agent-default-base-url");
@@ -62,55 +71,48 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>; providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
}; };
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0); expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
} finally { } finally {
process.env.COPILOT_GITHUB_TOKEN = previous; process.env.COPILOT_GITHUB_TOKEN = previous;
} }
}); });
}); });
it("uses enterprise URL from auth profiles to derive base URL", async () => { it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
await withTempHome(async () => { await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
try { try {
vi.resetModules(); vi.resetModules();
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise"); const resolveCopilotApiToken = vi.fn().mockResolvedValue({
await fs.mkdir(agentDir, { recursive: true }); token: "copilot",
await fs.writeFile( expiresAt: Date.now() + 60 * 60 * 1000,
path.join(agentDir, "auth-profiles.json"), source: "mock",
JSON.stringify( baseUrl: "https://api.copilot.example",
{ });
version: 1,
profiles: { vi.doMock("../providers/github-copilot-token.js", () => ({
"github-copilot:github": { DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
type: "oauth", resolveCopilotApiToken,
provider: "github-copilot", }));
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
},
null,
2,
),
);
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); await ensureClawdbotModelsJson({ models: { providers: {} } });
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); expect(resolveCopilotApiToken).toHaveBeenCalledWith(
const parsed = JSON.parse(raw) as { expect.objectContaining({ githubToken: "copilot-token" }),
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
); );
} finally { } finally {
// no-op process.env.COPILOT_GITHUB_TOKEN = previous;
process.env.GH_TOKEN = previousGh;
process.env.GITHUB_TOKEN = previousGithub;
} }
}); });
}); });
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -44,7 +43,7 @@ describe("models-config", () => {
process.env.HOME = previousHome; process.env.HOME = previousHome;
}); });
it("uses default baseUrl when env token is present", async () => { it("falls back to default baseUrl when token exchange fails", async () => {
await withTempHome(async () => { await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "gh-token"; process.env.COPILOT_GITHUB_TOKEN = "gh-token";
@@ -52,6 +51,11 @@ describe("models-config", () => {
try { try {
vi.resetModules(); vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js"); const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -63,13 +67,13 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>; providers: Record<string, { baseUrl?: string }>;
}; };
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL); expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
} finally { } finally {
process.env.COPILOT_GITHUB_TOKEN = previous; process.env.COPILOT_GITHUB_TOKEN = previous;
} }
}); });
}); });
it("normalizes enterprise URL when deriving base URL", async () => { it("uses agentDir override auth profiles for copilot injection", async () => {
await withTempHome(async (home) => { await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN; const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN; const previousGh = process.env.GH_TOKEN;
@@ -90,12 +94,9 @@ describe("models-config", () => {
version: 1, version: 1,
profiles: { profiles: {
"github-copilot:github": { "github-copilot:github": {
type: "oauth", type: "token",
provider: "github-copilot", provider: "github-copilot",
refresh: "gh-profile-token", token: "gh-profile-token",
access: "gh-profile-token",
expires: 0,
enterpriseUrl: "https://company.ghe.com/",
}, },
}, },
}, },
@@ -104,6 +105,16 @@ describe("models-config", () => {
), ),
); );
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
@@ -113,9 +124,7 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>; providers: Record<string, { baseUrl?: string }>;
}; };
expect(parsed.providers["github-copilot"]?.baseUrl).toBe( expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
"https://copilot-api.company.ghe.com",
);
} finally { } finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous; else process.env.COPILOT_GITHUB_TOKEN = previous;
+20 -9
View File
@@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { import {
normalizeGithubCopilotDomain, DEFAULT_COPILOT_API_BASE_URL,
resolveGithubCopilotBaseUrl, resolveCopilotApiToken,
} from "../providers/github-copilot-utils.js"; } from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js"; import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js"; import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { import {
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
if (!hasProfile && !githubToken) return null; if (!hasProfile && !githubToken) return null;
let enterpriseDomain: string | null = null; let selectedGithubToken = githubToken;
if (hasProfile) { if (!selectedGithubToken && hasProfile) {
// Use the first available profile as a default for discovery (it will be // Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner). // re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0]; const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined; const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") { if (profile && profile.type === "token") {
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl); selectedGithubToken = profile.token;
} }
} }
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain); let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
// pi-coding-agent's ModelRegistry marks a model "available" only if its // pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc). // `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
// GitHub token (not the exchanged Copilot token), and (3) matches existing // GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent. // patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here. // Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and passes the GitHub token at runtime. // Clawdbot uses its own auth store and exchanges tokens at runtime.
// `models list` uses Clawdbot's auth heuristics for availability. // `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json. // We intentionally do NOT define custom models for Copilot in models.json.
@@ -3,7 +3,6 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> { async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" }); return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -81,16 +80,25 @@ describe("models-config", () => {
), ),
); );
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir); await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); expect(resolveCopilotApiToken).toHaveBeenCalledWith(
const parsed = JSON.parse(raw) as { expect.objectContaining({ githubToken: "alpha-token" }),
providers: Record<string, { baseUrl?: string }>; );
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
} finally { } finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN; if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous; else process.env.COPILOT_GITHUB_TOKEN = previous;
@@ -109,6 +117,16 @@ describe("models-config", () => {
try { try {
vi.resetModules(); vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js"); const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js"); const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -1,8 +1,7 @@
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { ensureClawdbotModelsJson } from "./models-config.js"; import { ensureClawdbotModelsJson } from "./models-config.js";
@@ -87,25 +86,10 @@ vi.mock("@mariozechner/pi-ai", async () => {
}); });
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent; let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
let tempRoot: string | undefined;
let agentDir: string;
let workspaceDir: string;
let sessionCounter = 0;
beforeAll(async () => { beforeEach(async () => {
vi.useRealTimers(); vi.useRealTimers();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js")); ({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-embedded-agent-"));
agentDir = path.join(tempRoot, "agent");
workspaceDir = path.join(tempRoot, "workspace");
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(workspaceDir, { recursive: true });
}, 20_000);
afterAll(async () => {
if (!tempRoot) return;
await fs.rm(tempRoot, { recursive: true, force: true });
tempRoot = undefined;
}); });
const makeOpenAiConfig = (modelIds: string[]) => const makeOpenAiConfig = (modelIds: string[]) =>
@@ -130,14 +114,10 @@ const makeOpenAiConfig = (modelIds: string[]) =>
}, },
}) satisfies ClawdbotConfig; }) satisfies ClawdbotConfig;
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir); const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
const nextSessionFile = () => { const testSessionKey = "agent:test:embedded-ordering";
sessionCounter += 1;
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
};
const testSessionKey = "agent:test:embedded";
const immediateEnqueue = async <T>(task: () => Promise<T>) => task(); const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
const textFromContent = (content: unknown) => { const textFromContent = (content: unknown) => {
@@ -165,114 +145,15 @@ const readSessionMessages = async (sessionFile: string) => {
}; };
describe("runEmbeddedPiAgent", () => { describe("runEmbeddedPiAgent", () => {
it("writes models.json into the provided agentDir", async () => {
const sessionFile = nextSessionFile();
const cfg = {
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "sk-minimax-test",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
} satisfies ClawdbotConfig;
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hi",
provider: "definitely-not-a-provider",
model: "definitely-not-a-model",
timeoutMs: 1,
agentDir,
enqueue: immediateEnqueue,
}),
).rejects.toThrow(/Unknown model:/);
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
});
it("persists the user message when prompt fails before assistant output", async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-error"]);
await ensureModels(cfg);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "boom",
provider: "openai",
model: "mock-error",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
expect(result.payloads[0]?.isError).toBe(true);
const messages = await readSessionMessages(sessionFile);
const userIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
);
expect(userIndex).toBeGreaterThanOrEqual(0);
});
it( it(
"appends new user + assistant after existing transcript entries", "appends new user + assistant after existing transcript entries",
{ timeout: 90_000 }, { timeout: 90_000 },
async () => { async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent"); const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile); const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({ sessionManager.appendMessage({
@@ -304,7 +185,7 @@ describe("runEmbeddedPiAgent", () => {
}); });
const cfg = makeOpenAiConfig(["mock-1"]); const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg); await ensureModels(cfg, agentDir);
await runEmbeddedPiAgent({ await runEmbeddedPiAgent({
sessionId: "session:test", sessionId: "session:test",
@@ -340,11 +221,13 @@ describe("runEmbeddedPiAgent", () => {
expect(newAssistantIndex).toBeGreaterThan(newUserIndex); expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
}, },
); );
it("persists multi-turn user/assistant ordering across runs", async () => { it("persists multi-turn user/assistant ordering across runs", async () => {
const sessionFile = nextSessionFile(); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-1"]); const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg); await ensureModels(cfg, agentDir);
await runEmbeddedPiAgent({ await runEmbeddedPiAgent({
sessionId: "session:test", sessionId: "session:test",
@@ -382,33 +265,58 @@ describe("runEmbeddedPiAgent", () => {
(message, index) => index > firstUserIndex && message?.role === "assistant", (message, index) => index > firstUserIndex && message?.role === "assistant",
); );
const secondUserIndex = messages.findIndex( const secondUserIndex = messages.findIndex(
(message, index) => (message) => message?.role === "user" && textFromContent(message.content) === "second",
index > firstAssistantIndex &&
message?.role === "user" &&
textFromContent(message.content) === "second",
); );
const secondAssistantIndex = messages.findIndex( const secondAssistantIndex = messages.findIndex(
(message, index) => index > secondUserIndex && message?.role === "assistant", (message, index) => index > secondUserIndex && message?.role === "assistant",
); );
expect(firstUserIndex).toBeGreaterThanOrEqual(0); expect(firstUserIndex).toBeGreaterThanOrEqual(0);
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex); expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex); expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex); expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
}); }, 90_000);
it("repairs orphaned user messages and continues", async () => { it("repairs orphaned user messages and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent"); const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile); const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({ sessionManager.appendMessage({
role: "user", role: "user",
content: [{ type: "text", text: "orphaned user" }], content: [{ type: "text", text: "seed user 1" }],
});
sessionManager.appendMessage({
role: "assistant",
content: [{ type: "text", text: "seed assistant" }],
stopReason: "stop",
api: "openai-responses",
provider: "openai",
model: "mock-1",
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
});
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user 2" }],
}); });
const cfg = makeOpenAiConfig(["mock-1"]); const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg); await ensureModels(cfg, agentDir);
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgent({
sessionId: "session:test", sessionId: "session:test",
@@ -430,16 +338,19 @@ describe("runEmbeddedPiAgent", () => {
it("repairs orphaned single-user sessions and continues", async () => { it("repairs orphaned single-user sessions and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent"); const { SessionManager } = await import("@mariozechner/pi-coding-agent");
const sessionFile = nextSessionFile();
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const sessionManager = SessionManager.open(sessionFile); const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({ sessionManager.appendMessage({
role: "user", role: "user",
content: [{ type: "text", text: "solo user" }], content: [{ type: "text", text: "seed user only" }],
}); });
const cfg = makeOpenAiConfig(["mock-1"]); const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg); await ensureModels(cfg, agentDir);
const result = await runEmbeddedPiAgent({ const result = await runEmbeddedPiAgent({
sessionId: "session:test", sessionId: "session:test",
@@ -210,74 +210,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
} }
}); });
it("honors user-pinned profiles even when in cooldown", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
},
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
"openai:p2": { lastUsed: 2 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:user-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "user",
timeoutMs: 5_000,
runId: "run:user-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as {
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
};
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined();
expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1);
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
});
it("ignores user-locked profile when provider mismatches", async () => { it("ignores user-locked profile when provider mismatches", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-")); const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-")); const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
@@ -316,149 +248,4 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
await fs.rm(workspaceDir, { recursive: true, force: true }); await fs.rm(workspaceDir, { recursive: true, force: true });
} }
}); });
it("skips profiles in cooldown during initial selection", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
},
usageStats: {
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour
"openai:p2": { lastUsed: 2 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:skip-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: undefined,
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:skip-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as { usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }> };
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
});
it("skips profiles in cooldown when rotating after failure", async () => {
vi.useFakeTimers();
try {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const now = Date.now();
vi.setSystemTime(now);
try {
const authPath = path.join(agentDir, "auth-profiles.json");
const payload = {
version: 1,
profiles: {
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
"openai:p3": { type: "api_key", provider: "openai", key: "sk-three" },
},
usageStats: {
"openai:p1": { lastUsed: 1 },
"openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown
"openai:p3": { lastUsed: 3 },
},
};
await fs.writeFile(authPath, JSON.stringify(payload));
runEmbeddedAttemptMock
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: [],
lastAssistant: buildAssistant({
stopReason: "error",
errorMessage: "rate limit",
}),
}),
)
.mockResolvedValueOnce(
makeAttempt({
assistantTexts: ["ok"],
lastAssistant: buildAssistant({
stopReason: "stop",
content: [{ type: "text", text: "ok" }],
}),
}),
);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: "agent:test:rotate-skip-cooldown",
sessionFile: path.join(workspaceDir, "session.jsonl"),
workspaceDir,
agentDir,
config: makeConfig(),
prompt: "hello",
provider: "openai",
model: "mock-1",
authProfileId: "openai:p1",
authProfileIdSource: "auto",
timeoutMs: 5_000,
runId: "run:rotate-skip-cooldown",
});
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
const stored = JSON.parse(
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
) as {
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
};
expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number");
expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number");
expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
await fs.rm(workspaceDir, { recursive: true, force: true });
}
} finally {
vi.useRealTimers();
}
});
}); });
@@ -0,0 +1,258 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({
role: "assistant" as const,
content: [{ type: "text" as const, text: "ok" }],
stopReason: "stop" as const,
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 1,
output: 1,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 2,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
});
const buildAssistantErrorMessage = (model: { api: string; provider: string; id: string }) => ({
role: "assistant" as const,
content: [] as const,
stopReason: "error" as const,
errorMessage: "boom",
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
},
},
timestamp: Date.now(),
});
const mockPiAi = () => {
vi.doMock("@mariozechner/pi-ai", async () => {
const actual =
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
return {
...actual,
complete: async (model: { api: string; provider: string; id: string }) => {
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
return buildAssistantMessage(model);
},
completeSimple: async (model: { api: string; provider: string; id: string }) => {
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
return buildAssistantMessage(model);
},
streamSimple: (model: { api: string; provider: string; id: string }) => {
const stream = new actual.AssistantMessageEventStream();
queueMicrotask(() => {
stream.push({
type: "done",
reason: "stop",
message:
model.id === "mock-error"
? buildAssistantErrorMessage(model)
: buildAssistantMessage(model),
});
stream.end();
});
return stream;
},
};
});
};
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
beforeAll(async () => {
vi.useRealTimers();
mockPiAi();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
}, 20_000);
const makeOpenAiConfig = (modelIds: string[]) =>
({
models: {
providers: {
openai: {
api: "openai-responses",
apiKey: "sk-test",
baseUrl: "https://example.com",
models: modelIds.map((id) => ({
id,
name: `Mock ${id}`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 16_000,
maxTokens: 2048,
})),
},
},
},
}) satisfies ClawdbotConfig;
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
const testSessionKey = "agent:test:embedded-models";
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
const textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
if (Array.isArray(content) && content[0]?.type === "text") {
return (content[0] as { text?: string }).text;
}
return undefined;
};
const readSessionMessages = async (sessionFile: string) => {
const raw = await fs.readFile(sessionFile, "utf-8");
return raw
.split(/\r?\n/)
.filter(Boolean)
.map(
(line) =>
JSON.parse(line) as {
type?: string;
message?: { role?: string; content?: unknown };
},
)
.filter((entry) => entry.type === "message")
.map((entry) => entry.message as { role?: string; content?: unknown });
};
describe("runEmbeddedPiAgent", () => {
it("writes models.json into the provided agentDir", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = {
models: {
providers: {
minimax: {
baseUrl: "https://api.minimax.io/anthropic",
api: "anthropic-messages",
apiKey: "sk-minimax-test",
models: [
{
id: "MiniMax-M2.1",
name: "MiniMax M2.1",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192,
},
],
},
},
},
} satisfies ClawdbotConfig;
await expect(
runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hi",
provider: "definitely-not-a-provider",
model: "definitely-not-a-model",
timeoutMs: 1,
agentDir,
enqueue: immediateEnqueue,
}),
).rejects.toThrow(/Unknown model:/);
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
});
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "hello",
provider: "openai",
model: "mock-1",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
const messages = await readSessionMessages(sessionFile);
const firstUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
);
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
if (firstAssistantIndex !== -1) {
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
}
});
it("persists the user message when prompt fails before assistant output", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
const sessionFile = path.join(workspaceDir, "session.jsonl");
const cfg = makeOpenAiConfig(["mock-error"]);
await ensureModels(cfg, agentDir);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
sessionKey: testSessionKey,
sessionFile,
workspaceDir,
config: cfg,
prompt: "boom",
provider: "openai",
model: "mock-error",
timeoutMs: 5_000,
agentDir,
enqueue: immediateEnqueue,
});
expect(result.payloads[0]?.isError).toBe(true);
const messages = await readSessionMessages(sessionFile);
const userIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
);
expect(userIndex).toBeGreaterThanOrEqual(0);
});
});
+7
View File
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
); );
} }
} else if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else { } else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
} }
+3 -18
View File
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js"; import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js"; import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js"; import { normalizeProviderId } from "../model-selection.js";
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
type InlineModelEntry = ModelDefinitionConfig & { provider: string }; type InlineModelEntry = ModelDefinitionConfig & { provider: string };
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
if (model.provider === "github-copilot") {
const headers = model.headers
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
: { "User-Agent": resolveGithubCopilotUserAgent() };
return { ...model, headers };
}
return model;
}
export function buildInlineProviderModels( export function buildInlineProviderModels(
providers: Record<string, { models?: ModelDefinitionConfig[] }>, providers: Record<string, { models?: ModelDefinitionConfig[] }>,
): InlineModelEntry[] { ): InlineModelEntry[] {
@@ -71,7 +60,7 @@ export function resolveModel(
if (inlineMatch) { if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>); const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
return { return {
model: applyProviderModelOverrides(normalized), model: normalized,
authStorage, authStorage,
modelRegistry, modelRegistry,
}; };
@@ -89,7 +78,7 @@ export function resolveModel(
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS, contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS, maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
} as Model<Api>); } as Model<Api>);
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry }; return { model: fallbackModel, authStorage, modelRegistry };
} }
return { return {
error: `Unknown model: ${provider}/${modelId}`, error: `Unknown model: ${provider}/${modelId}`,
@@ -97,9 +86,5 @@ export function resolveModel(
modelRegistry, modelRegistry,
}; };
} }
return { return { model: normalizeModelCompat(model), authStorage, modelRegistry };
model: applyProviderModelOverrides(normalizeModelCompat(model)),
authStorage,
modelRegistry,
};
} }
+14 -34
View File
@@ -5,7 +5,6 @@ import { resolveUserPath } from "../../utils.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js"; import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js"; import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { import {
isProfileInCooldown,
markAuthProfileFailure, markAuthProfileFailure,
markAuthProfileGood, markAuthProfileGood,
markAuthProfileUsed, markAuthProfileUsed,
@@ -149,11 +148,7 @@ export async function runEmbeddedPiAgent(
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) { if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`); throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
} }
const profileCandidates = lockedProfileId const profileCandidates = profileOrder.length > 0 ? profileOrder : [undefined];
? [lockedProfileId]
: profileOrder.length > 0
? profileOrder
: [undefined];
let profileIndex = 0; let profileIndex = 0;
const initialThinkLevel = params.thinkLevel ?? "off"; const initialThinkLevel = params.thinkLevel ?? "off";
@@ -174,18 +169,26 @@ export async function runEmbeddedPiAgent(
const applyApiKeyInfo = async (candidate?: string): Promise<void> => { const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
apiKeyInfo = await resolveApiKeyForCandidate(candidate); apiKeyInfo = await resolveApiKeyForCandidate(candidate);
const resolvedProfileId = apiKeyInfo.profileId ?? candidate;
if (!apiKeyInfo.apiKey) { if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") { if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error( throw new Error(
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`, `No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
); );
} }
lastProfileId = resolvedProfileId; lastProfileId = apiKeyInfo.profileId;
return; return;
} }
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey); if (model.provider === "github-copilot") {
lastProfileId = resolvedProfileId; const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
lastProfileId = apiKeyInfo.profileId;
}; };
const advanceAuthProfile = async (): Promise<boolean> => { const advanceAuthProfile = async (): Promise<boolean> => {
@@ -193,10 +196,6 @@ export async function runEmbeddedPiAgent(
let nextIndex = profileIndex + 1; let nextIndex = profileIndex + 1;
while (nextIndex < profileCandidates.length) { while (nextIndex < profileCandidates.length) {
const candidate = profileCandidates[nextIndex]; const candidate = profileCandidates[nextIndex];
if (candidate && isProfileInCooldown(authStore, candidate)) {
nextIndex += 1;
continue;
}
try { try {
await applyApiKeyInfo(candidate); await applyApiKeyInfo(candidate);
profileIndex = nextIndex; profileIndex = nextIndex;
@@ -212,24 +211,7 @@ export async function runEmbeddedPiAgent(
}; };
try { try {
while (profileIndex < profileCandidates.length) { await applyApiKeyInfo(profileCandidates[profileIndex]);
const candidate = profileCandidates[profileIndex];
if (
candidate &&
candidate !== lockedProfileId &&
isProfileInCooldown(authStore, candidate)
) {
profileIndex += 1;
continue;
}
await applyApiKeyInfo(profileCandidates[profileIndex]);
break;
}
if (profileIndex >= profileCandidates.length) {
throw new Error(
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
);
}
} catch (err) { } catch (err) {
if (profileCandidates[profileIndex] === lockedProfileId) throw err; if (profileCandidates[profileIndex] === lockedProfileId) throw err;
const advanced = await advanceAuthProfile(); const advanced = await advanceAuthProfile();
@@ -520,12 +502,10 @@ export async function runEmbeddedPiAgent(
store: authStore, store: authStore,
provider, provider,
profileId: lastProfileId, profileId: lastProfileId,
agentDir: params.agentDir,
}); });
await markAuthProfileUsed({ await markAuthProfileUsed({
store: authStore, store: authStore,
profileId: lastProfileId, profileId: lastProfileId,
agentDir: params.agentDir,
}); });
} }
return { return {
-14
View File
@@ -237,20 +237,6 @@ describe("buildAgentSystemPrompt", () => {
expect(prompt).toContain("Bravo"); expect(prompt).toContain("Bravo");
}); });
it("adds SOUL guidance when a soul file is present", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd",
contextFiles: [
{ path: "./SOUL.md", content: "Persona" },
{ path: "dir\\SOUL.md", content: "Persona Windows" },
],
});
expect(prompt).toContain(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
});
it("summarizes the message tool when available", () => { it("summarizes the message tool when available", () => {
const prompt = buildAgentSystemPrompt({ const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/clawd", workspaceDir: "/tmp/clawd",
+6 -12
View File
@@ -517,18 +517,12 @@ export function buildAgentSystemPrompt(params: {
const contextFiles = params.contextFiles ?? []; const contextFiles = params.contextFiles ?? [];
if (contextFiles.length > 0) { if (contextFiles.length > 0) {
const hasSoulFile = contextFiles.some((file) => { lines.push(
const normalizedPath = file.path.trim().replace(/\\/g, "/"); "# Project Context",
const baseName = normalizedPath.split("/").pop() ?? normalizedPath; "",
return baseName.toLowerCase() === "soul.md"; "The following project context files have been loaded:",
}); "",
lines.push("# Project Context", "", "The following project context files have been loaded:"); );
if (hasSoulFile) {
lines.push(
"If SOUL.md is present, embody its persona and tone. Avoid stiff, generic replies; follow its guidance unless higher-priority instructions override it.",
);
}
lines.push("");
for (const file of contextFiles) { for (const file of contextFiles) {
lines.push(`## ${file.path}`, "", file.content, ""); lines.push(`## ${file.path}`, "", file.content, "");
} }
-14
View File
@@ -357,20 +357,6 @@ describe("handleSlackAction", () => {
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString()); expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
}); });
it("passes threadId through to readSlackMessages", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
readSlackMessages.mockClear();
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
await handleSlackAction(
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
cfg,
);
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
expect(opts?.threadId).toBe("12345.6789");
});
it("adds normalized timestamps to pin payloads", async () => { it("adds normalized timestamps to pin payloads", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig; const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([ listSlackPins.mockResolvedValueOnce([
-2
View File
@@ -214,13 +214,11 @@ export async function handleSlackAction(
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined; typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before"); const before = readStringParam(params, "before");
const after = readStringParam(params, "after"); const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, { const result = await readSlackMessages(channelId, {
...readOpts, ...readOpts,
limit, limit,
before: before ?? undefined, before: before ?? undefined,
after: after ?? undefined, after: after ?? undefined,
threadId: threadId ?? undefined,
}); });
const messages = result.messages.map((message) => const messages = result.messages.map((message) =>
withNormalizedTimestamp( withNormalizedTimestamp(
+1 -1
View File
@@ -42,7 +42,7 @@ describe("block streaming", () => {
}); });
async function waitForCalls(fn: () => number, calls: number) { async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 5000; const deadline = Date.now() + 1500;
while (fn() < calls) { while (fn() < calls) {
if (Date.now() > deadline) { if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`); throw new Error(`Expected ${calls} call(s), got ${fn()}`);
-1
View File
@@ -136,7 +136,6 @@ export async function runReplyAgent(params: {
followupRun.run.config, followupRun.run.config,
replyToChannel, replyToChannel,
sessionCtx.AccountId, sessionCtx.AccountId,
sessionCtx.ChatType,
); );
const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel); const applyReplyToMode = createReplyToModeFilterForChannel(replyToMode, replyToChannel);
const cfg = followupRun.run.config; const cfg = followupRun.run.config;
-1
View File
@@ -204,7 +204,6 @@ export function createFollowupRunner(params: {
queued.run.config, queued.run.config,
replyToChannel, replyToChannel,
queued.originatingAccountId, queued.originatingAccountId,
queued.originatingChatType,
); );
const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({ const replyTaggedPayloads: ReplyPayload[] = applyReplyThreading({
-1
View File
@@ -358,7 +358,6 @@ export async function runPreparedReply(
originatingTo: ctx.OriginatingTo, originatingTo: ctx.OriginatingTo,
originatingAccountId: ctx.AccountId, originatingAccountId: ctx.AccountId,
originatingThreadId: ctx.MessageThreadId, originatingThreadId: ctx.MessageThreadId,
originatingChatType: ctx.ChatType,
run: { run: {
agentId, agentId,
agentDir, agentDir,
-2
View File
@@ -39,8 +39,6 @@ export type FollowupRun = {
originatingAccountId?: string; originatingAccountId?: string;
/** Thread id for reply routing (Telegram topic id or Matrix thread event id). */ /** Thread id for reply routing (Telegram topic id or Matrix thread event id). */
originatingThreadId?: string | number; originatingThreadId?: string | number;
/** Chat type for context-aware threading (e.g., DM vs channel). */
originatingChatType?: string;
run: { run: {
agentId: string; agentId: string;
agentDir: string; agentDir: string;
@@ -31,33 +31,6 @@ describe("resolveReplyToMode", () => {
expect(resolveReplyToMode(cfg, "discord")).toBe("first"); expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all"); expect(resolveReplyToMode(cfg, "slack")).toBe("all");
}); });
it("uses dm-specific replyToMode for Slack DMs when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when dm.replyToMode is not configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
dm: { enabled: true },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
}); });
describe("createReplyToModeFilter", () => { describe("createReplyToModeFilter", () => {
-2
View File
@@ -9,14 +9,12 @@ export function resolveReplyToMode(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
channel?: OriginatingChannelType, channel?: OriginatingChannelType,
accountId?: string | null, accountId?: string | null,
chatType?: string | null,
): ReplyToMode { ): ReplyToMode {
const provider = normalizeChannelId(channel); const provider = normalizeChannelId(channel);
if (!provider) return "all"; if (!provider) return "all";
const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({ const resolved = getChannelDock(provider)?.threading?.resolveReplyToMode?.({
cfg, cfg,
accountId, accountId,
chatType,
}); });
return resolved ?? "all"; return resolved ?? "all";
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { resolveDiscordAccount } from "../discord/accounts.js"; import { resolveDiscordAccount } from "../discord/accounts.js";
import { resolveIMessageAccount } from "../imessage/accounts.js"; import { resolveIMessageAccount } from "../imessage/accounts.js";
import { resolveSignalAccount } from "../signal/accounts.js"; import { resolveSignalAccount } from "../signal/accounts.js";
import { resolveSlackAccount, resolveSlackReplyToMode } from "../slack/accounts.js"; import { resolveSlackAccount } from "../slack/accounts.js";
import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js"; import { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
import { resolveTelegramAccount } from "../telegram/accounts.js"; import { resolveTelegramAccount } from "../telegram/accounts.js";
import { normalizeE164 } from "../utils.js"; import { normalizeE164 } from "../utils.js";
@@ -224,8 +224,8 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
resolveRequireMention: resolveSlackGroupRequireMention, resolveRequireMention: resolveSlackGroupRequireMention,
}, },
threading: { threading: {
resolveReplyToMode: ({ cfg, accountId, chatType }) => resolveReplyToMode: ({ cfg, accountId }) =>
resolveSlackReplyToMode(resolveSlackAccount({ cfg, accountId }), chatType), resolveSlackAccount({ cfg, accountId }).replyToMode ?? "off",
allowTagsWhenOff: true, allowTagsWhenOff: true,
buildToolContext: (params) => buildSlackThreadingToolContext(params), buildToolContext: (params) => buildSlackThreadingToolContext(params),
}, },
@@ -1,34 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { createSlackActions } from "./slack.actions.js";
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
vi.mock("../../agents/tools/slack-actions.js", () => ({
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
}));
describe("slack actions adapter", () => {
it("forwards threadId for read", async () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
const actions = createSlackActions("slack");
await actions.handleAction?.({
channel: "slack",
action: "read",
cfg,
params: {
channelId: "C1",
threadId: "171234.567",
},
});
const [params] = handleSlackAction.mock.calls[0] ?? [];
expect(params).toMatchObject({
action: "readMessages",
channelId: "C1",
threadId: "171234.567",
});
});
});
-1
View File
@@ -133,7 +133,6 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
limit, limit,
before: readStringParam(params, "before"), before: readStringParam(params, "before"),
after: readStringParam(params, "after"), after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined, accountId: accountId ?? undefined,
}, },
cfg, cfg,
-1
View File
@@ -198,7 +198,6 @@ export type ChannelThreadingAdapter = {
resolveReplyToMode?: (params: { resolveReplyToMode?: (params: {
cfg: ClawdbotConfig; cfg: ClawdbotConfig;
accountId?: string | null; accountId?: string | null;
chatType?: string | null;
}) => "off" | "first" | "all"; }) => "off" | "first" | "all";
allowTagsWhenOff?: boolean; allowTagsWhenOff?: boolean;
buildToolContext?: (params: { buildToolContext?: (params: {
+2 -6
View File
@@ -94,13 +94,9 @@ export function buildParseArgv(params: {
: baseArgv[0]?.endsWith("clawdbot") : baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1) ? baseArgv.slice(1)
: baseArgv; : baseArgv;
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase(); const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
const looksLikeNode = const looksLikeNode =
normalizedArgv.length >= 2 && normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
if (looksLikeNode) return normalizedArgv; if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv]; return ["node", programName || "clawdbot", ...normalizedArgv];
} }
@@ -9,7 +9,6 @@ import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js"; import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { resolveBrowserActionContext } from "./shared.js"; import { resolveBrowserActionContext } from "./shared.js";
import { shortenHomePath } from "../../utils.js";
export function registerBrowserFilesAndDownloadsCommands( export function registerBrowserFilesAndDownloadsCommands(
browser: Command, browser: Command,
@@ -74,7 +73,7 @@ export function registerBrowserFilesAndDownloadsCommands(
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`); defaultRuntime.log(`downloaded: ${result.download.path}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -106,7 +105,7 @@ export function registerBrowserFilesAndDownloadsCommands(
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`); defaultRuntime.log(`downloaded: ${result.download.path}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
+1 -2
View File
@@ -9,7 +9,6 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
function runBrowserObserve(action: () => Promise<void>) { function runBrowserObserve(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => { return runCommandWithRuntime(defaultRuntime, action, (err) => {
@@ -62,7 +61,7 @@ export function registerBrowserActionObserveCommands(
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`); defaultRuntime.log(`PDF: ${result.path}`);
}); });
}); });
+1 -2
View File
@@ -12,7 +12,6 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
function runBrowserDebug(action: () => Promise<void>) { function runBrowserDebug(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => { return runCommandWithRuntime(defaultRuntime, action, (err) => {
@@ -165,7 +164,7 @@ export function registerBrowserDebugCommands(
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`); defaultRuntime.log(`TRACE:${result.path}`);
}); });
}); });
} }
+3 -6
View File
@@ -11,7 +11,6 @@ import { defaultRuntime } from "../runtime.js";
import { movePathToTrash } from "../browser/trash.js"; import { movePathToTrash } from "../browser/trash.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
function bundledExtensionRootDir() { function bundledExtensionRootDir() {
@@ -78,8 +77,7 @@ export function registerBrowserExtensionCommands(
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2)); defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
return; return;
} }
const displayPath = shortenHomePath(installed.path); defaultRuntime.log(installed.path);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(installed.path).catch(() => false); const copied = await copyToClipboard(installed.path).catch(() => false);
defaultRuntime.error( defaultRuntime.error(
info( info(
@@ -87,7 +85,7 @@ export function registerBrowserExtensionCommands(
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.", copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
"Next:", "Next:",
`- Chrome → chrome://extensions → enable “Developer mode”`, `- Chrome → chrome://extensions → enable “Developer mode”`,
`- “Load unpacked” → select: ${displayPath}`, `- “Load unpacked” → select: ${installed.path}`,
`- Pin “Clawdbot Browser Relay”, then click it on the tab (badge shows ON)`, `- Pin “Clawdbot Browser Relay”, then click it on the tab (badge shows ON)`,
"", "",
`${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.clawd.bot/tools/chrome-extension")}`, `${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.clawd.bot/tools/chrome-extension")}`,
@@ -117,8 +115,7 @@ export function registerBrowserExtensionCommands(
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2)); defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
return; return;
} }
const displayPath = shortenHomePath(dir); defaultRuntime.log(dir);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(dir).catch(() => false); const copied = await copyToClipboard(dir).catch(() => false);
if (copied) defaultRuntime.error(info("Copied to clipboard.")); if (copied) defaultRuntime.error(info("Copied to clipboard."));
}); });
+4 -5
View File
@@ -5,7 +5,6 @@ import { browserScreenshotAction } from "../browser/client-actions.js";
import { loadConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js"; import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserInspectCommands( export function registerBrowserInspectCommands(
@@ -37,7 +36,7 @@ export function registerBrowserInspectCommands(
defaultRuntime.log(JSON.stringify(result, null, 2)); defaultRuntime.log(JSON.stringify(result, null, 2));
return; return;
} }
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`); defaultRuntime.log(`MEDIA:${result.path}`);
} catch (err) { } catch (err) {
defaultRuntime.error(danger(String(err))); defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1); defaultRuntime.exit(1);
@@ -107,9 +106,9 @@ export function registerBrowserInspectCommands(
), ),
); );
} else { } else {
defaultRuntime.log(shortenHomePath(opts.out)); defaultRuntime.log(opts.out);
if (result.format === "ai" && result.imagePath) { if (result.format === "ai" && result.imagePath) {
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`); defaultRuntime.log(`MEDIA:${result.imagePath}`);
} }
} }
return; return;
@@ -123,7 +122,7 @@ export function registerBrowserInspectCommands(
if (result.format === "ai") { if (result.format === "ai") {
defaultRuntime.log(result.snapshot); defaultRuntime.log(result.snapshot);
if (result.imagePath) { if (result.imagePath) {
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`); defaultRuntime.log(`MEDIA:${result.imagePath}`);
} }
return; return;
} }
+1 -4
View File
@@ -18,7 +18,6 @@ import {
import { browserAct } from "../browser/client-actions-core.js"; import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js"; import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js"; import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js"; import { runCommandWithRuntime } from "./cli-utils.js";
@@ -47,8 +46,6 @@ export function registerBrowserManageCommands(
defaultRuntime.log(JSON.stringify(status, null, 2)); defaultRuntime.log(JSON.stringify(status, null, 2));
return; return;
} }
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto";
defaultRuntime.log( defaultRuntime.log(
[ [
`profile: ${status.profile ?? "clawd"}`, `profile: ${status.profile ?? "clawd"}`,
@@ -59,7 +56,7 @@ export function registerBrowserManageCommands(
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`, `cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`, `browser: ${status.chosenBrowser ?? "unknown"}`,
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`, `detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
`detectedPath: ${detectedDisplay}`, `detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
`profileColor: ${status.color}`, `profileColor: ${status.color}`,
...(status.detectError ? [`detectError: ${status.detectError}`] : []), ...(status.detectError ? [`detectError: ${status.detectError}`] : []),
].join("\n"), ].join("\n"),
+1 -2
View File
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
type PathSegment = string; type PathSegment = string;
@@ -169,7 +168,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
async function loadValidConfig() { async function loadValidConfig() {
const snapshot = await readConfigFileSnapshot(); const snapshot = await readConfigFileSnapshot();
if (snapshot.valid) return snapshot; if (snapshot.valid) return snapshot;
defaultRuntime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`); defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
for (const issue of snapshot.issues) { for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`); defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
} }
+7 -12
View File
@@ -13,7 +13,6 @@ import { isWSLEnv } from "../../infra/wsl.js";
import { getResolvedLoggerSettings } from "../../logging.js"; import { getResolvedLoggerSettings } from "../../logging.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import { shortenHomePath } from "../../utils.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
import { import {
filterDaemonEnv, filterDaemonEnv,
@@ -67,7 +66,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`); defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
try { try {
const logFile = getResolvedLoggerSettings().file; const logFile = getResolvedLoggerSettings().file;
defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(logFile))}`); defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
} catch { } catch {
// ignore missing config/log resolution // ignore missing config/log resolution
} }
@@ -77,14 +76,10 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
); );
} }
if (service.command?.sourcePath) { if (service.command?.sourcePath) {
defaultRuntime.log( defaultRuntime.log(`${label("Service file:")} ${infoText(service.command.sourcePath)}`);
`${label("Service file:")} ${infoText(shortenHomePath(service.command.sourcePath))}`,
);
} }
if (service.command?.workingDirectory) { if (service.command?.workingDirectory) {
defaultRuntime.log( defaultRuntime.log(`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`);
`${label("Working dir:")} ${infoText(shortenHomePath(service.command.workingDirectory))}`,
);
} }
const daemonEnvLines = safeDaemonEnv(service.command?.environment); const daemonEnvLines = safeDaemonEnv(service.command?.environment);
if (daemonEnvLines.length > 0) { if (daemonEnvLines.length > 0) {
@@ -106,7 +101,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
} }
if (status.config) { if (status.config) {
const cliCfg = `${shortenHomePath(status.config.cli.path)}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`; const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`); defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
if (!status.config.cli.valid && status.config.cli.issues?.length) { if (!status.config.cli.valid && status.config.cli.issues?.length) {
for (const issue of status.config.cli.issues.slice(0, 5)) { for (const issue of status.config.cli.issues.slice(0, 5)) {
@@ -116,7 +111,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
} }
} }
if (status.config.daemon) { if (status.config.daemon) {
const daemonCfg = `${shortenHomePath(status.config.daemon.path)}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`; const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`); defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
if (!status.config.daemon.valid && status.config.daemon.issues?.length) { if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
for (const issue of status.config.daemon.issues.slice(0, 5)) { for (const issue of status.config.daemon.issues.slice(0, 5)) {
@@ -281,8 +276,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
const logs = resolveGatewayLogPaths( const logs = resolveGatewayLogPaths(
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv, (service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
); );
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`); defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`); defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
} }
spacer(); spacer();
} }
+3 -3
View File
@@ -6,7 +6,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js"; import { handleReset } from "../../commands/onboard-helpers.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js"; import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath, shortenHomePath } from "../../utils.js"; import { resolveUserPath } from "../../utils.js";
const DEV_IDENTITY_NAME = "C3-PO"; const DEV_IDENTITY_NAME = "C3-PO";
const DEV_IDENTITY_THEME = "protocol droid"; const DEV_IDENTITY_THEME = "protocol droid";
@@ -117,6 +117,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
}, },
}); });
await ensureDevWorkspace(workspace); await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}`); defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`); defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
} }
+81 -64
View File
@@ -1,66 +1,66 @@
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import fs from "node:fs"; import fs from "node:fs";
import net from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { pathToFileURL } from "node:url"; import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
const waitForReady = async ( const waitForPortOpen = async (
proc: ReturnType<typeof spawn>, proc: ReturnType<typeof spawn>,
chunksOut: string[], chunksOut: string[],
chunksErr: string[], chunksErr: string[],
port: number,
timeoutMs: number, timeoutMs: number,
) => { ) => {
await new Promise<void>((resolve, reject) => { const startedAt = Date.now();
const timer = setTimeout(() => { while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
const stdout = chunksOut.join(""); const stdout = chunksOut.join("");
const stderr = chunksErr.join(""); const stderr = chunksErr.join("");
cleanup(); throw new Error(
reject( `gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
new Error( `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
`timeout waiting for gateway to start\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
),
); );
}, timeoutMs); }
const cleanup = () => { try {
clearTimeout(timer); await new Promise<void>((resolve, reject) => {
proc.off("exit", onExit); const socket = net.connect({ host: "127.0.0.1", port });
proc.off("message", onMessage); socket.once("connect", () => {
proc.stdout?.off("data", onStdout); socket.destroy();
}; resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
const onExit = () => { await new Promise((resolve) => setTimeout(resolve, 10));
const stdout = chunksOut.join(""); }
const stderr = chunksErr.join(""); const stdout = chunksOut.join("");
cleanup(); const stderr = chunksErr.join("");
reject( throw new Error(
new Error( `timeout waiting for gateway to listen on port ${port}\n` +
`gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` + `--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`, );
), };
);
};
const onMessage = (msg: unknown) => { const getFreePort = async () => {
if (msg && typeof msg === "object" && "ready" in msg) { const srv = net.createServer();
cleanup(); await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
resolve(); const addr = srv.address();
} if (!addr || typeof addr === "string") {
}; srv.close();
throw new Error("failed to bind ephemeral port");
const onStdout = (chunk: unknown) => { }
if (String(chunk).includes("READY")) { await new Promise<void>((resolve) => srv.close(() => resolve()));
cleanup(); return addr.port;
resolve();
}
};
proc.once("exit", onExit);
proc.on("message", onMessage);
proc.stdout?.on("data", onStdout);
});
}; };
describe("gateway SIGTERM", () => { describe("gateway SIGTERM", () => {
@@ -77,50 +77,67 @@ describe("gateway SIGTERM", () => {
}); });
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => { it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
const port = await getFreePort();
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-")); const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
const configPath = path.join(stateDir, "clawdbot.json");
fs.writeFileSync(
configPath,
JSON.stringify({ gateway: { mode: "local", port } }, null, 2),
"utf8",
);
const out: string[] = []; const out: string[] = [];
const err: string[] = []; const err: string[] = [];
const nodeBin = process.execPath; const nodeBin = process.execPath;
const entryArgs = [
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
];
const env = { const env = {
...process.env, ...process.env,
CLAWDBOT_NO_RESPAWN: "1", CLAWDBOT_NO_RESPAWN: "1",
CLAWDBOT_STATE_DIR: stateDir, CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_CHANNELS: "1", CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_GMAIL_WATCHER: "1",
CLAWDBOT_SKIP_CRON: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1", CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1", CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a gateway server.
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: "0",
}; };
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs"); const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts"); const runMainPath = path.resolve("src/cli/run-main.ts");
const runtimePath = path.resolve("src/runtime.ts");
fs.writeFileSync( fs.writeFileSync(
bootstrapPath, bootstrapPath,
[ [
'import { pathToFileURL } from "node:url";', 'import { pathToFileURL } from "node:url";',
`const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`, 'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";',
`const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`, "let entryArgs = [];",
"const { runGatewayLoop } = await import(runLoopUrl);", "try {",
"const { defaultRuntime } = await import(runtimeUrl);", " entryArgs = JSON.parse(rawArgs);",
"await runGatewayLoop({", "} catch (err) {",
" start: async () => {", ' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);',
' process.stdout.write("READY\\\\n");', " process.exit(1);",
" if (process.send) process.send({ ready: true });", "}",
" const keepAlive = setInterval(() => {}, 1000);", "if (!Array.isArray(entryArgs)) entryArgs = [];",
" return { close: async () => clearInterval(keepAlive) };", 'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));',
" },", `const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`,
" runtime: defaultRuntime,", "const { runCli } = await import(runMainUrl);",
"});", 'await runCli(["node", "clawdbot", ...entryArgs]);',
].join("\n"), ].join("\n"),
"utf8", "utf8",
); );
const childArgs = ["--import", "tsx", bootstrapPath]; const childArgs = ["--import", "tsx", bootstrapPath];
env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs);
child = spawn(nodeBin, childArgs, { child = spawn(nodeBin, childArgs, {
cwd: process.cwd(), cwd: process.cwd(),
env, env,
stdio: ["ignore", "pipe", "pipe", "ipc"], stdio: ["ignore", "pipe", "pipe"],
}); });
const proc = child; const proc = child;
@@ -131,7 +148,7 @@ describe("gateway SIGTERM", () => {
child.stdout?.on("data", (d) => out.push(String(d))); child.stdout?.on("data", (d) => out.push(String(d)));
child.stderr?.on("data", (d) => err.push(String(d))); child.stderr?.on("data", (d) => err.push(String(d)));
await waitForReady(proc, out, err, 150_000); await waitForPortOpen(proc, out, err, port, 150_000);
proc.kill("SIGTERM"); proc.kill("SIGTERM");
+4 -4
View File
@@ -25,7 +25,7 @@ import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export type HooksListOptions = { export type HooksListOptions = {
json?: boolean; json?: boolean;
@@ -224,8 +224,8 @@ export function formatHookInfo(
} else { } else {
lines.push(`${theme.muted(" Source:")} ${hook.source}`); lines.push(`${theme.muted(" Source:")} ${hook.source}`);
} }
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`); lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`); lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
if (hook.homepage) { if (hook.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`); lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
} }
@@ -577,7 +577,7 @@ export function registerHooksCli(program: Command): void {
}); });
await writeConfigFile(next); await writeConfigFile(next);
defaultRuntime.log(`Linked hook path: ${shortenHomePath(resolved)}`); defaultRuntime.log(`Linked hook path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load hooks.`); defaultRuntime.log(`Restart the gateway to load hooks.`);
return; return;
} }
+13 -27
View File
@@ -17,7 +17,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js"; import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js"; import { resolveStateDir } from "../config/paths.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
type MemoryCommandOptions = { type MemoryCommandOptions = {
agent?: string; agent?: string;
@@ -45,15 +44,11 @@ type MemorySourceScan = {
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string { function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") { if (source === "memory") {
return shortenHomeInString( return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
`memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`,
);
} }
if (source === "sessions") { if (source === "sessions") {
const stateDir = resolveStateDir(process.env, os.homedir); const stateDir = resolveStateDir(process.env, os.homedir);
return shortenHomeInString( return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
`sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`,
);
} }
return source; return source;
} }
@@ -81,10 +76,7 @@ async function checkReadableFile(pathname: string): Promise<{ exists: boolean; i
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { exists: false }; if (code === "ENOENT") return { exists: false };
return { return { exists: true, issue: `${pathname} not readable (${code ?? "error"})` };
exists: true,
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
};
} }
} }
@@ -100,12 +92,10 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") { if (code === "ENOENT") {
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`); issues.push(`sessions directory missing (${sessionsDir})`);
return { source: "sessions", totalFiles: 0, issues }; return { source: "sessions", totalFiles: 0, issues };
} }
issues.push( issues.push(`sessions directory not accessible (${sessionsDir}): ${code ?? "error"}`);
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
);
return { source: "sessions", totalFiles: null, issues }; return { source: "sessions", totalFiles: null, issues };
} }
} }
@@ -128,12 +118,10 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") { if (code === "ENOENT") {
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`); issues.push(`memory directory missing (${memoryDir})`);
dirReadable = false; dirReadable = false;
} else { } else {
issues.push( issues.push(`memory directory not accessible (${memoryDir}): ${code ?? "error"}`);
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null; dirReadable = null;
} }
} }
@@ -146,9 +134,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
} catch (err) { } catch (err) {
const code = (err as NodeJS.ErrnoException).code; const code = (err as NodeJS.ErrnoException).code;
if (dirReadable !== null) { if (dirReadable !== null) {
issues.push( issues.push(`memory directory scan failed (${memoryDir}): ${code ?? "error"}`);
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null; dirReadable = null;
} }
} }
@@ -166,7 +152,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
} }
if ((totalFiles ?? 0) === 0 && issues.length === 0) { if ((totalFiles ?? 0) === 0 && issues.length === 0) {
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`); issues.push(`no memory files found in ${workspaceDir}`);
} }
return { source: "memory", totalFiles, issues }; return { source: "memory", totalFiles, issues };
@@ -308,8 +294,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null, status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
`${label("Indexed")} ${success(indexedLabel)}`, `${label("Indexed")} ${success(indexedLabel)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`, `${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`, `${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`, `${label("Workspace")} ${info(status.workspaceDir)}`,
].filter(Boolean) as string[]; ].filter(Boolean) as string[];
if (embeddingProbe) { if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable"; const state = embeddingProbe.ok ? "ready" : "unavailable";
@@ -354,7 +340,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`); lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
} }
if (status.vector.extensionPath) { if (status.vector.extensionPath) {
lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`); lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
} }
if (status.vector.loadError) { if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`); lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
@@ -608,7 +594,7 @@ export function registerMemoryCli(program: Command) {
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize( `${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
rich, rich,
theme.accent, theme.accent,
`${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`, `${result.path}:${result.startLine}-${result.endLine}`,
)}`, )}`,
); );
lines.push(colorize(rich, theme.muted, result.snippet)); lines.push(colorize(rich, theme.muted, result.snippet));
+2 -3
View File
@@ -13,7 +13,6 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js"; import { renderTable } from "../../terminal/table.js";
import { shortenHomePath } from "../../utils.js";
const parseFacing = (value: string): CameraFacing => { const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "") const v = String(value ?? "")
@@ -166,7 +165,7 @@ export function registerNodesCameraCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify({ files: results }, null, 2)); defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
return; return;
} }
defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n")); defaultRuntime.log(results.map((r) => `MEDIA:${r.path}`).join("\n"));
}); });
}), }),
{ timeoutMs: 60_000 }, { timeoutMs: 60_000 },
@@ -240,7 +239,7 @@ export function registerNodesCameraCommands(nodes: Command) {
); );
return; return;
} }
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); defaultRuntime.log(`MEDIA:${filePath}`);
}); });
}), }),
{ timeoutMs: 90_000 }, { timeoutMs: 90_000 },
+1 -2
View File
@@ -9,7 +9,6 @@ import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js"; import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { shortenHomePath } from "../../utils.js";
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) { async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? "")); const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
@@ -86,7 +85,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
); );
return; return;
} }
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`); defaultRuntime.log(`MEDIA:${filePath}`);
}); });
}), }),
{ timeoutMs: 60_000 }, { timeoutMs: 60_000 },
+1 -2
View File
@@ -10,7 +10,6 @@ import { parseDurationMs } from "../parse-duration.js";
import { runNodesCommand } from "./cli-utils.js"; import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js"; import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { shortenHomePath } from "../../utils.js";
export function registerNodesScreenCommands(nodes: Command) { export function registerNodesScreenCommands(nodes: Command) {
const screen = nodes const screen = nodes
@@ -78,7 +77,7 @@ export function registerNodesScreenCommands(nodes: Command) {
); );
return; return;
} }
defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`); defaultRuntime.log(`MEDIA:${written.path}`);
}); });
}), }),
{ timeoutMs: 180_000 }, { timeoutMs: 180_000 },
+2 -4
View File
@@ -6,7 +6,6 @@ import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js"; import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js"; import { renderTable } from "../../terminal/table.js";
import { parseDurationMs } from "../parse-duration.js"; import { parseDurationMs } from "../parse-duration.js";
import { shortenHomeInString } from "../../utils.js";
function formatVersionLabel(raw: string) { function formatVersionLabel(raw: string) {
const trimmed = raw.trim(); const trimmed = raw.trim();
@@ -50,9 +49,8 @@ function formatPathEnv(raw?: string): string | null {
const trimmed = raw.trim(); const trimmed = raw.trim();
if (!trimmed) return null; if (!trimmed) return null;
const parts = trimmed.split(":").filter(Boolean); const parts = trimmed.split(":").filter(Boolean);
const display = if (parts.length <= 3) return trimmed;
parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`; return `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
return shortenHomeInString(display);
} }
function parseSinceMs(raw: unknown, label: string): number | undefined { function parseSinceMs(raw: unknown, label: string): number | undefined {
+21 -24
View File
@@ -15,7 +15,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
export type PluginsListOptions = { export type PluginsListOptions = {
json?: boolean; json?: boolean;
@@ -55,7 +55,7 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const parts = [ const parts = [
`${name}${idSuffix} ${status}`, `${name}${idSuffix} ${status}`,
` source: ${theme.muted(shortenHomeInString(plugin.source))}`, ` source: ${theme.muted(plugin.source)}`,
` origin: ${plugin.origin}`, ` origin: ${plugin.origin}`,
]; ];
if (plugin.version) parts.push(` version: ${plugin.version}`); if (plugin.version) parts.push(` version: ${plugin.version}`);
@@ -135,22 +135,19 @@ export function registerPluginsCli(program: Command) {
if (!opts.verbose) { if (!opts.verbose) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1); const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = list.map((plugin) => { const rows = list.map((plugin) => ({
const desc = plugin.description ? theme.muted(plugin.description) : ""; Name: plugin.name || plugin.id,
const sourceLine = desc ? `${plugin.source}\n${desc}` : plugin.source; ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
return { Status:
Name: plugin.name || plugin.id, plugin.status === "loaded"
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "", ? theme.success("loaded")
Status: : plugin.status === "disabled"
plugin.status === "loaded" ? theme.warn("disabled")
? theme.success("loaded") : theme.error("error"),
: plugin.status === "disabled" Source: plugin.source,
? theme.warn("disabled") Version: plugin.version ?? "",
: theme.error("error"), Description: plugin.description ?? "",
Source: sourceLine, }));
Version: plugin.version ?? "",
};
});
defaultRuntime.log( defaultRuntime.log(
renderTable({ renderTable({
width: tableWidth, width: tableWidth,
@@ -158,8 +155,9 @@ export function registerPluginsCli(program: Command) {
{ key: "Name", header: "Name", minWidth: 14, flex: true }, { key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true }, { key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Status", header: "Status", minWidth: 10 }, { key: "Status", header: "Status", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 26, flex: true }, { key: "Source", header: "Source", minWidth: 10 },
{ key: "Version", header: "Version", minWidth: 8 }, { key: "Version", header: "Version", minWidth: 8 },
{ key: "Description", header: "Description", minWidth: 18, flex: true },
], ],
rows, rows,
}).trimEnd(), }).trimEnd(),
@@ -203,7 +201,7 @@ export function registerPluginsCli(program: Command) {
if (plugin.description) lines.push(plugin.description); if (plugin.description) lines.push(plugin.description);
lines.push(""); lines.push("");
lines.push(`${theme.muted("Status:")} ${plugin.status}`); lines.push(`${theme.muted("Status:")} ${plugin.status}`);
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`); lines.push(`${theme.muted("Source:")} ${plugin.source}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`); lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`); if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
if (plugin.toolNames.length > 0) { if (plugin.toolNames.length > 0) {
@@ -229,10 +227,9 @@ export function registerPluginsCli(program: Command) {
lines.push(""); lines.push("");
lines.push(`${theme.muted("Install:")} ${install.source}`); lines.push(`${theme.muted("Install:")} ${install.source}`);
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`); if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
if (install.sourcePath) if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`);
if (install.installPath) if (install.installPath)
lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`); lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`); if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
if (install.installedAt) if (install.installedAt)
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`); lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
@@ -336,7 +333,7 @@ export function registerPluginsCli(program: Command) {
next = slotResult.config; next = slotResult.config;
await writeConfigFile(next); await writeConfigFile(next);
logSlotWarnings(slotResult.warnings); logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`); defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Restart the gateway to load plugins.`); defaultRuntime.log(`Restart the gateway to load plugins.`);
return; return;
} }
+1 -2
View File
@@ -3,7 +3,6 @@ import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-fl
import { colorize, isRich, theme } from "../../terminal/theme.js"; import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../command-format.js"; import { formatCliCommand } from "../command-format.js";
import { shortenHomePath } from "../../utils.js";
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]); const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]);
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([ const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
@@ -61,7 +60,7 @@ export async function ensureConfigReady(params: {
const commandText = (value: string) => colorize(rich, theme.command, value); const commandText = (value: string) => colorize(rich, theme.command, value);
params.runtime.error(heading("Config invalid")); params.runtime.error(heading("Config invalid"));
params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`); params.runtime.error(`${muted("File:")} ${muted(snapshot.path)}`);
if (issues.length > 0) { if (issues.length > 0) {
params.runtime.error(muted("Problem:")); params.runtime.error(muted("Problem:"));
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n")); params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));
+5 -12
View File
@@ -6,7 +6,6 @@ import { runSecurityAudit } from "../security/audit.js";
import { fixSecurityFootguns } from "../security/fix.js"; import { fixSecurityFootguns } from "../security/fix.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js"; import { isRich, theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
type SecurityAuditOptions = { type SecurityAuditOptions = {
@@ -84,24 +83,18 @@ export function registerSecurityCli(program: Command) {
lines.push(""); lines.push("");
lines.push(heading("FIX")); lines.push(heading("FIX"));
for (const change of fixResult.changes) { for (const change of fixResult.changes) {
lines.push(muted(` ${shortenHomeInString(change)}`)); lines.push(muted(` ${change}`));
} }
for (const action of fixResult.actions) { for (const action of fixResult.actions) {
const mode = action.mode.toString(8).padStart(3, "0"); const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`)); if (action.ok) lines.push(muted(` chmod ${mode} ${action.path}`));
else if (action.skipped) else if (action.skipped)
lines.push( lines.push(muted(` skip chmod ${mode} ${action.path} (${action.skipped})`));
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (action.error) else if (action.error)
lines.push( lines.push(muted(` chmod ${mode} ${action.path} failed: ${action.error}`));
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
} }
if (fixResult.errors.length > 0) { if (fixResult.errors.length > 0) {
for (const err of fixResult.errors) { for (const err of fixResult.errors) lines.push(muted(` error: ${err}`));
lines.push(muted(` error: ${shortenHomeInString(err)}`));
}
} }
} }
} }
+1 -2
View File
@@ -10,7 +10,6 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js"; import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js"; import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js"; import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js"; import { formatCliCommand } from "./command-format.js";
export type SkillsListOptions = { export type SkillsListOptions = {
@@ -177,7 +176,7 @@ export function formatSkillInfo(
// Details // Details
lines.push(theme.heading("Details:")); lines.push(theme.heading("Details:"));
lines.push(`${theme.muted(" Source:")} ${skill.source}`); lines.push(`${theme.muted(" Source:")} ${skill.source}`);
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(skill.filePath)}`); lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
if (skill.homepage) { if (skill.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`); lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
} }
+6 -7
View File
@@ -8,12 +8,11 @@ import {
} from "../agents/agent-scope.js"; } from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js"; import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js"; import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js"; import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js"; import { WizardCancelledError } from "../wizard/prompts.js";
import { import {
@@ -127,7 +126,7 @@ export async function agentsAddCommand(
: { config: nextConfig, added: [], skipped: [], conflicts: [] }; : { config: nextConfig, added: [], skipped: [], conflicts: [] };
await writeConfigFile(bindingResult.config); await writeConfigFile(bindingResult.config);
if (!opts.json) logConfigUpdated(runtime); if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, { await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap), skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
@@ -152,8 +151,8 @@ export async function agentsAddCommand(
runtime.log(JSON.stringify(payload, null, 2)); runtime.log(JSON.stringify(payload, null, 2));
} else { } else {
runtime.log(`Agent: ${agentId}`); runtime.log(`Agent: ${agentId}`);
runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`); runtime.log(`Workspace: ${workspaceDir}`);
runtime.log(`Agent dir: ${shortenHomePath(agentDir)}`); runtime.log(`Agent dir: ${agentDir}`);
if (model) runtime.log(`Model: ${model}`); if (model) runtime.log(`Model: ${model}`);
if (bindingResult.conflicts.length > 0) { if (bindingResult.conflicts.length > 0) {
runtime.error( runtime.error(
@@ -335,7 +334,7 @@ export async function agentsAddCommand(
} }
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, { await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
agentId, agentId,
+2 -3
View File
@@ -1,6 +1,5 @@
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
@@ -70,7 +69,7 @@ export async function agentsDeleteCommand(
const result = pruneAgentConfig(cfg, agentId); const result = pruneAgentConfig(cfg, agentId);
await writeConfigFile(result.config); await writeConfigFile(result.config);
if (!opts.json) logConfigUpdated(runtime); if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime; const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await moveToTrash(workspaceDir, quietRuntime); await moveToTrash(workspaceDir, quietRuntime);
+7 -8
View File
@@ -4,13 +4,12 @@ import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js"; import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js"; import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import { writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { IdentityConfig } from "../config/types.js"; import type { IdentityConfig } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js"; import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { resolveUserPath, shortenHomePath } from "../utils.js"; import { resolveUserPath } from "../utils.js";
import { requireValidConfig } from "./agents.command-shared.js"; import { requireValidConfig } from "./agents.command-shared.js";
import { import {
type AgentIdentity, type AgentIdentity,
@@ -106,14 +105,14 @@ export async function agentsSetIdentityCommand(
const matches = resolveAgentIdByWorkspace(cfg, workspaceDir); const matches = resolveAgentIdByWorkspace(cfg, workspaceDir);
if (matches.length === 0) { if (matches.length === 0) {
runtime.error( runtime.error(
`No agent workspace matches ${shortenHomePath(workspaceDir)}. Pass --agent to target a specific agent.`, `No agent workspace matches ${workspaceDir}. Pass --agent to target a specific agent.`,
); );
runtime.exit(1); runtime.exit(1);
return; return;
} }
if (matches.length > 1) { if (matches.length > 1) {
runtime.error( runtime.error(
`Multiple agents match ${shortenHomePath(workspaceDir)}: ${matches.join(", ")}. Pass --agent to choose one.`, `Multiple agents match ${workspaceDir}: ${matches.join(", ")}. Pass --agent to choose one.`,
); );
runtime.exit(1); runtime.exit(1);
return; return;
@@ -132,7 +131,7 @@ export async function agentsSetIdentityCommand(
const targetPath = const targetPath =
identityFilePath ?? identityFilePath ??
(workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md"); (workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md");
runtime.error(`No identity data found in ${shortenHomePath(targetPath)}.`); runtime.error(`No identity data found in ${targetPath}.`);
runtime.exit(1); runtime.exit(1);
return; return;
} }
@@ -212,11 +211,11 @@ export async function agentsSetIdentityCommand(
return; return;
} }
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Agent: ${agentId}`); runtime.log(`Agent: ${agentId}`);
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`); if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`); if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`); if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`); if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
if (workspaceDir) runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`); if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
} }
+2 -3
View File
@@ -3,7 +3,6 @@ import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { shortenHomePath } from "../utils.js";
import { describeBinding } from "./agents.bindings.js"; import { describeBinding } from "./agents.bindings.js";
import { requireValidConfig } from "./agents.command-shared.js"; import { requireValidConfig } from "./agents.command-shared.js";
import type { AgentSummary } from "./agents.config.js"; import type { AgentSummary } from "./agents.config.js";
@@ -41,8 +40,8 @@ function formatSummary(summary: AgentSummary) {
if (identityLine) { if (identityLine) {
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`); lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
} }
lines.push(` Workspace: ${shortenHomePath(summary.workspace)}`); lines.push(` Workspace: ${summary.workspace}`);
lines.push(` Agent dir: ${shortenHomePath(summary.agentDir)}`); lines.push(` Agent dir: ${summary.agentDir}`);
if (summary.model) lines.push(` Model: ${summary.model}`); if (summary.model) lines.push(` Model: ${summary.model}`);
lines.push(` Routing rules: ${summary.bindings}`); lines.push(` Routing rules: ${summary.bindings}`);
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
nextConfig = applyAuthProfileConfig(nextConfig, { nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "github-copilot:github", profileId: "github-copilot:github",
provider: "github-copilot", provider: "github-copilot",
mode: "oauth", mode: "token",
}); });
if (params.setDefaultModel) { if (params.setDefaultModel) {
+5 -6
View File
@@ -4,7 +4,7 @@ import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir, resolveUserPath, shortenHomeInString } from "../utils.js"; import { resolveHomeDir, resolveUserPath } from "../utils.js";
export type RemovalResult = { export type RemovalResult = {
ok: boolean; ok: boolean;
@@ -53,21 +53,20 @@ export async function removePath(
if (!target?.trim()) return { ok: false, skipped: true }; if (!target?.trim()) return { ok: false, skipped: true };
const resolved = path.resolve(target); const resolved = path.resolve(target);
const label = opts?.label ?? resolved; const label = opts?.label ?? resolved;
const displayLabel = shortenHomeInString(label);
if (isUnsafeRemovalTarget(resolved)) { if (isUnsafeRemovalTarget(resolved)) {
runtime.error(`Refusing to remove unsafe path: ${displayLabel}`); runtime.error(`Refusing to remove unsafe path: ${label}`);
return { ok: false }; return { ok: false };
} }
if (opts?.dryRun) { if (opts?.dryRun) {
runtime.log(`[dry-run] remove ${displayLabel}`); runtime.log(`[dry-run] remove ${label}`);
return { ok: true, skipped: true }; return { ok: true, skipped: true };
} }
try { try {
await fs.rm(resolved, { recursive: true, force: true }); await fs.rm(resolved, { recursive: true, force: true });
runtime.log(`Removed ${displayLabel}`); runtime.log(`Removed ${label}`);
return { ok: true }; return { ok: true };
} catch (err) { } catch (err) {
runtime.error(`Failed to remove ${displayLabel}: ${String(err)}`); runtime.error(`Failed to remove ${label}: ${String(err)}`);
return { ok: false }; return { ok: false };
} }
} }
+1 -2
View File
@@ -4,7 +4,6 @@ import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { confirm, select } from "./configure.shared.js"; import { confirm, select } from "./configure.shared.js";
import { guardCancel } from "./onboard-helpers.js"; import { guardCancel } from "./onboard-helpers.js";
@@ -52,7 +51,7 @@ export async function removeChannelConfigWizard(
const label = getChannelPlugin(channel)?.meta.label ?? channel; const label = getChannelPlugin(channel)?.meta.label ?? channel;
const confirmed = guardCancel( const confirmed = guardCancel(
await confirm({ await confirm({
message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}?`, message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
initialValue: false, initialValue: false,
}), }),
runtime, runtime,
+8 -4
View File
@@ -1,7 +1,11 @@
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js"; import {
import { logConfigUpdated } from "../config/logging.js"; CONFIG_PATH_CLAWDBOT,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js"; import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
@@ -249,7 +253,7 @@ export async function runConfigureWizard(
mode, mode,
}); });
await writeConfigFile(remoteConfig); await writeConfigFile(remoteConfig);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
outro("Remote gateway configured."); outro("Remote gateway configured.");
return; return;
} }
@@ -282,7 +286,7 @@ export async function runConfigureWizard(
mode, mode,
}); });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
}; };
if (opts.sections) { if (opts.sections) {
+2 -4
View File
@@ -6,7 +6,6 @@ import { promisify } from "node:util";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
const execFileAsync = promisify(execFile); const execFileAsync = promisify(execFile);
@@ -20,11 +19,10 @@ export async function noteMacLaunchAgentOverrides() {
const hasMarker = fs.existsSync(markerPath); const hasMarker = fs.existsSync(markerPath);
if (!hasMarker) return; if (!hasMarker) return;
const displayMarkerPath = shortenHomePath(markerPath);
const lines = [ const lines = [
`- LaunchAgent writes are disabled via ${displayMarkerPath}.`, `- LaunchAgent writes are disabled via ${markerPath}.`,
"- To restore default behavior:", "- To restore default behavior:",
` rm ${displayMarkerPath}`, ` rm ${markerPath}`,
].filter((line): line is string => Boolean(line)); ].filter((line): line is string => Boolean(line));
note(lines.join("\n"), "Gateway (macOS)"); note(lines.join("\n"), "Gateway (macOS)");
} }
+26 -41
View File
@@ -13,7 +13,6 @@ import {
resolveStorePath, resolveStorePath,
} from "../config/sessions.js"; } from "../config/sessions.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
type DoctorPrompterLike = { type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: { confirmSkipInNonInteractive: (params: {
@@ -132,16 +131,11 @@ export async function noteStateIntegrity(
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir); const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId }); const storePath = resolveStorePath(cfg.session?.store, { agentId });
const storeDir = path.dirname(storePath); const storeDir = path.dirname(storePath);
const displayStateDir = shortenHomePath(stateDir);
const displayOauthDir = shortenHomePath(oauthDir);
const displaySessionsDir = shortenHomePath(sessionsDir);
const displayStoreDir = shortenHomePath(storeDir);
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
let stateDirExists = existsDir(stateDir); let stateDirExists = existsDir(stateDir);
if (!stateDirExists) { if (!stateDirExists) {
warnings.push( warnings.push(
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`, `- CRITICAL: state directory missing (${stateDir}). Sessions, credentials, logs, and config are stored there.`,
); );
if (cfg.gateway?.mode === "remote") { if (cfg.gateway?.mode === "remote") {
warnings.push( warnings.push(
@@ -149,26 +143,26 @@ export async function noteStateIntegrity(
); );
} }
const create = await prompter.confirmSkipInNonInteractive({ const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${displayStateDir} now?`, message: `Create ${stateDir} now?`,
initialValue: false, initialValue: false,
}); });
if (create) { if (create) {
const created = ensureDir(stateDir); const created = ensureDir(stateDir);
if (created.ok) { if (created.ok) {
changes.push(`- Created ${displayStateDir}`); changes.push(`- Created ${stateDir}`);
stateDirExists = true; stateDirExists = true;
} else { } else {
warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`); warnings.push(`- Failed to create ${stateDir}: ${created.error}`);
} }
} }
} }
if (stateDirExists && !canWriteDir(stateDir)) { if (stateDirExists && !canWriteDir(stateDir)) {
warnings.push(`- State directory not writable (${displayStateDir}).`); warnings.push(`- State directory not writable (${stateDir}).`);
const hint = dirPermissionHint(stateDir); const hint = dirPermissionHint(stateDir);
if (hint) warnings.push(` ${hint}`); if (hint) warnings.push(` ${hint}`);
const repair = await prompter.confirmSkipInNonInteractive({ const repair = await prompter.confirmSkipInNonInteractive({
message: `Repair permissions on ${displayStateDir}?`, message: `Repair permissions on ${stateDir}?`,
initialValue: true, initialValue: true,
}); });
if (repair) { if (repair) {
@@ -176,9 +170,9 @@ export async function noteStateIntegrity(
const stat = fs.statSync(stateDir); const stat = fs.statSync(stateDir);
const target = addUserRwx(stat.mode); const target = addUserRwx(stat.mode);
fs.chmodSync(stateDir, target); fs.chmodSync(stateDir, target);
changes.push(`- Repaired permissions on ${displayStateDir}`); changes.push(`- Repaired permissions on ${stateDir}`);
} catch (err) { } catch (err) {
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`); warnings.push(`- Failed to repair ${stateDir}: ${String(err)}`);
} }
} }
} }
@@ -187,19 +181,19 @@ export async function noteStateIntegrity(
const stat = fs.statSync(stateDir); const stat = fs.statSync(stateDir);
if ((stat.mode & 0o077) !== 0) { if ((stat.mode & 0o077) !== 0) {
warnings.push( warnings.push(
`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`, `- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
); );
const tighten = await prompter.confirmSkipInNonInteractive({ const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${displayStateDir} to 700?`, message: `Tighten permissions on ${stateDir} to 700?`,
initialValue: true, initialValue: true,
}); });
if (tighten) { if (tighten) {
fs.chmodSync(stateDir, 0o700); fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${displayStateDir} to 700`); changes.push(`- Tightened permissions on ${stateDir} to 700`);
} }
} }
} catch (err) { } catch (err) {
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`); warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
} }
} }
@@ -208,21 +202,19 @@ export async function noteStateIntegrity(
const stat = fs.statSync(configPath); const stat = fs.statSync(configPath);
if ((stat.mode & 0o077) !== 0) { if ((stat.mode & 0o077) !== 0) {
warnings.push( warnings.push(
`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`, `- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
); );
const tighten = await prompter.confirmSkipInNonInteractive({ const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`, message: `Tighten permissions on ${configPath} to 600?`,
initialValue: true, initialValue: true,
}); });
if (tighten) { if (tighten) {
fs.chmodSync(configPath, 0o600); fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`); changes.push(`- Tightened permissions on ${configPath} to 600`);
} }
} }
} catch (err) { } catch (err) {
warnings.push( warnings.push(`- Failed to read config permissions (${configPath}): ${String(err)}`);
`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`,
);
} }
} }
@@ -231,33 +223,26 @@ export async function noteStateIntegrity(
dirCandidates.set(sessionsDir, "Sessions dir"); dirCandidates.set(sessionsDir, "Sessions dir");
dirCandidates.set(storeDir, "Session store dir"); dirCandidates.set(storeDir, "Session store dir");
dirCandidates.set(oauthDir, "OAuth dir"); dirCandidates.set(oauthDir, "OAuth dir");
const displayDirFor = (dir: string) => {
if (dir === sessionsDir) return displaySessionsDir;
if (dir === storeDir) return displayStoreDir;
if (dir === oauthDir) return displayOauthDir;
return shortenHomePath(dir);
};
for (const [dir, label] of dirCandidates) { for (const [dir, label] of dirCandidates) {
const displayDir = displayDirFor(dir);
if (!existsDir(dir)) { if (!existsDir(dir)) {
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`); warnings.push(`- CRITICAL: ${label} missing (${dir}).`);
const create = await prompter.confirmSkipInNonInteractive({ const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${label} at ${displayDir}?`, message: `Create ${label} at ${dir}?`,
initialValue: true, initialValue: true,
}); });
if (create) { if (create) {
const created = ensureDir(dir); const created = ensureDir(dir);
if (created.ok) { if (created.ok) {
changes.push(`- Created ${label}: ${displayDir}`); changes.push(`- Created ${label}: ${dir}`);
} else { } else {
warnings.push(`- Failed to create ${displayDir}: ${created.error}`); warnings.push(`- Failed to create ${dir}: ${created.error}`);
} }
} }
continue; continue;
} }
if (!canWriteDir(dir)) { if (!canWriteDir(dir)) {
warnings.push(`- ${label} not writable (${displayDir}).`); warnings.push(`- ${label} not writable (${dir}).`);
const hint = dirPermissionHint(dir); const hint = dirPermissionHint(dir);
if (hint) warnings.push(` ${hint}`); if (hint) warnings.push(` ${hint}`);
const repair = await prompter.confirmSkipInNonInteractive({ const repair = await prompter.confirmSkipInNonInteractive({
@@ -269,9 +254,9 @@ export async function noteStateIntegrity(
const stat = fs.statSync(dir); const stat = fs.statSync(dir);
const target = addUserRwx(stat.mode); const target = addUserRwx(stat.mode);
fs.chmodSync(dir, target); fs.chmodSync(dir, target);
changes.push(`- Repaired permissions on ${label}: ${displayDir}`); changes.push(`- Repaired permissions on ${label}: ${dir}`);
} catch (err) { } catch (err) {
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`); warnings.push(`- Failed to repair ${dir}: ${String(err)}`);
} }
} }
} }
@@ -289,8 +274,8 @@ export async function noteStateIntegrity(
warnings.push( warnings.push(
[ [
"- Multiple state directories detected. This can split session history.", "- Multiple state directories detected. This can split session history.",
...Array.from(extraStateDirs).map((dir) => ` - ${shortenHomePath(dir)}`), ...Array.from(extraStateDirs).map((dir) => ` - ${dir}`),
` Active state dir: ${displayStateDir}`, ` Active state dir: ${stateDir}`,
].join("\n"), ].join("\n"),
); );
} }
@@ -326,7 +311,7 @@ export async function noteStateIntegrity(
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId }); const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
if (!existsFile(transcriptPath)) { if (!existsFile(transcriptPath)) {
warnings.push( warnings.push(
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`, `- Main session transcript missing (${transcriptPath}). History will appear to reset.`,
); );
} else { } else {
const lineCount = countJsonlLines(transcriptPath); const lineCount = countJsonlLines(transcriptPath);
+2 -3
View File
@@ -8,7 +8,6 @@ import {
DEFAULT_SOUL_FILENAME, DEFAULT_SOUL_FILENAME,
DEFAULT_USER_FILENAME, DEFAULT_USER_FILENAME,
} from "../agents/workspace.js"; } from "../agents/workspace.js";
import { shortenHomePath } from "../utils.js";
export const MEMORY_SYSTEM_PROMPT = [ export const MEMORY_SYSTEM_PROMPT = [
"Memory system not found in workspace.", "Memory system not found in workspace.",
@@ -81,8 +80,8 @@ export function detectLegacyWorkspaceDirs(params: {
export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string { export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string {
return [ return [
"Extra workspace directories detected (may contain old agent files):", "Extra workspace directories detected (may contain old agent files):",
...detection.legacyDirs.map((dir) => `- ${shortenHomePath(dir)}`), ...detection.legacyDirs.map((dir) => `- ${dir}`),
`Active workspace: ${shortenHomePath(detection.activeWorkspace)}`, `Active workspace: ${detection.activeWorkspace}`,
"If unused, archive or move to Trash (e.g. trash ~/clawdbot).", "If unused, archive or move to Trash (e.g. trash ~/clawdbot).",
].join("\n"); ].join("\n");
} }
+6 -10
View File
@@ -12,16 +12,13 @@ import {
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import type { ClawdbotConfig } from "../config/config.js"; import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, writeConfigFile } from "../config/config.js"; import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveGatewayService } from "../daemon/service.js"; import { resolveGatewayService } from "../daemon/service.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js"; import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js"; import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js"; import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js"; import { stylePromptTitle } from "../terminal/prompt-style.js";
import { shortenHomePath } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js"; import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js"; import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js"; import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
@@ -114,11 +111,10 @@ export async function doctorCommand(
note(gatewayDetails.remoteFallbackNote, "Gateway"); note(gatewayDetails.remoteFallbackNote, "Gateway");
} }
if (resolveMode(cfg) === "local") { if (resolveMode(cfg) === "local") {
const auth = resolveGatewayAuth({ const authMode = cfg.gateway?.auth?.mode;
authConfig: cfg.gateway?.auth, const token =
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off", typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
}); const needsToken = authMode !== "password" && (authMode !== "token" || !token);
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) { if (needsToken) {
note( note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).", "Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
@@ -271,10 +267,10 @@ export async function doctorCommand(
if (shouldWriteConfig) { if (shouldWriteConfig) {
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) }); cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
await writeConfigFile(cfg); await writeConfigFile(cfg);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`; const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`;
if (fs.existsSync(backupPath)) { if (fs.existsSync(backupPath)) {
runtime.log(`Backup: ${shortenHomePath(backupPath)}`); runtime.log(`Backup: ${backupPath}`);
} }
} else { } else {
runtime.log(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply changes.`); runtime.log(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply changes.`);
@@ -389,39 +389,4 @@ describe("doctor command", () => {
); );
expect(warned).toBe(true); expect(warned).toBe(true);
}); });
it("skips gateway auth warning when CLAWDBOT_GATEWAY_TOKEN is set", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdbot.json",
exists: true,
raw: "{}",
parsed: {},
valid: true,
config: {
gateway: { mode: "local" },
},
issues: [],
legacyIssues: [],
});
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token-1234567890";
note.mockClear();
try {
const { doctorCommand } = await import("./doctor.js");
await doctorCommand(
{ log: vi.fn(), error: vi.fn(), exit: vi.fn() },
{ nonInteractive: true, workspaceSuggestions: false },
);
} finally {
if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
}
const warned = note.mock.calls.some(([message]) =>
String(message).includes("Gateway auth is off or missing a token"),
);
expect(warned).toBe(false);
});
}); });
+3 -4
View File
@@ -1,5 +1,4 @@
import { loadConfig } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import {
ensureFlagCompatibility, ensureFlagCompatibility,
@@ -75,7 +74,7 @@ export async function modelsAliasesAddCommand(
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`); runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
} }
@@ -106,7 +105,7 @@ export async function modelsAliasesRemoveCommand(aliasRaw: string, runtime: Runt
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if ( if (
!updated.agents?.defaults?.models || !updated.agents?.defaults?.models ||
Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim()) Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim())
+8 -5
View File
@@ -16,8 +16,11 @@ import {
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { parseDurationMs } from "../../cli/parse-duration.js"; import { parseDurationMs } from "../../cli/parse-duration.js";
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js";
import { readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js"; import {
import { logConfigUpdated } from "../../config/logging.js"; CONFIG_PATH_CLAWDBOT,
readConfigFileSnapshot,
type ClawdbotConfig,
} from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js"; import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { applyAuthProfileConfig } from "../onboard-auth.js"; import { applyAuthProfileConfig } from "../onboard-auth.js";
@@ -114,7 +117,7 @@ export async function modelsAuthSetupTokenCommand(
}), }),
); );
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`); runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
} }
@@ -156,7 +159,7 @@ export async function modelsAuthPasteTokenCommand(
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" })); await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`); runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
} }
@@ -422,7 +425,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
return next; return next;
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
for (const profile of result.profiles) { for (const profile of result.profiles) {
runtime.log( runtime.log(
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`, `Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
+4 -5
View File
@@ -1,6 +1,5 @@
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { loadConfig } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import {
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
@@ -79,7 +78,7 @@ export async function modelsFallbacksAddCommand(modelRaw: string, runtime: Runti
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`); runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
} }
@@ -125,7 +124,7 @@ export async function modelsFallbacksRemoveCommand(modelRaw: string, runtime: Ru
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`); runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
} }
@@ -149,6 +148,6 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log("Fallback list cleared."); runtime.log("Fallback list cleared.");
} }
+4 -5
View File
@@ -1,6 +1,5 @@
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js"; import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { loadConfig } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import {
DEFAULT_PROVIDER, DEFAULT_PROVIDER,
@@ -79,7 +78,7 @@ export async function modelsImageFallbacksAddCommand(modelRaw: string, runtime:
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log( runtime.log(
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
); );
@@ -127,7 +126,7 @@ export async function modelsImageFallbacksRemoveCommand(modelRaw: string, runtim
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log( runtime.log(
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`, `Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
); );
@@ -153,6 +152,6 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log("Image fallback list cleared."); runtime.log("Image fallback list cleared.");
} }
+1 -1
View File
@@ -250,7 +250,7 @@ export async function modelsStatusCommand(
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel; rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
runtime.log( runtime.log(
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, shortenHomePath(CONFIG_PATH_CLAWDBOT))}`, `${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`,
); );
runtime.log( runtime.log(
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize( `${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
+2 -3
View File
@@ -2,8 +2,7 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompt
import { resolveApiKeyForProvider } from "../../agents/model-auth.js"; import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js"; import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
import { withProgressTotals } from "../../cli/progress.js"; import { withProgressTotals } from "../../cli/progress.js";
import { loadConfig } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { import {
stylePromptHint, stylePromptHint,
@@ -344,7 +343,7 @@ export async function modelsScanCommand(
return; return;
} }
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Fallbacks: ${selected.join(", ")}`); runtime.log(`Fallbacks: ${selected.join(", ")}`);
if (selectedImages.length > 0) { if (selectedImages.length > 0) {
runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`); runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`);
+2 -2
View File
@@ -1,4 +1,4 @@
import { logConfigUpdated } from "../../config/logging.js"; import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { resolveModelTarget, updateConfig } from "./shared.js"; import { resolveModelTarget, updateConfig } from "./shared.js";
@@ -27,6 +27,6 @@ export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEn
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`); runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`);
} }
+2 -2
View File
@@ -1,4 +1,4 @@
import { logConfigUpdated } from "../../config/logging.js"; import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { resolveModelTarget, updateConfig } from "./shared.js"; import { resolveModelTarget, updateConfig } from "./shared.js";
@@ -27,6 +27,6 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
}; };
}); });
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`); runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`);
} }
+12 -31
View File
@@ -18,13 +18,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js"; import { stylePromptTitle } from "../terminal/prompt-style.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { import { CONFIG_DIR, resolveUserPath, sleep } from "../utils.js";
CONFIG_DIR,
resolveUserPath,
shortenHomeInString,
shortenHomePath,
sleep,
} from "../utils.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js"; import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
@@ -39,21 +33,21 @@ export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
export function summarizeExistingConfig(config: ClawdbotConfig): string { export function summarizeExistingConfig(config: ClawdbotConfig): string {
const rows: string[] = []; const rows: string[] = [];
const defaults = config.agents?.defaults; const defaults = config.agents?.defaults;
if (defaults?.workspace) rows.push(shortenHomeInString(`workspace: ${defaults.workspace}`)); if (defaults?.workspace) rows.push(`workspace: ${defaults.workspace}`);
if (defaults?.model) { if (defaults?.model) {
const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary; const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary;
if (model) rows.push(shortenHomeInString(`model: ${model}`)); if (model) rows.push(`model: ${model}`);
} }
if (config.gateway?.mode) rows.push(shortenHomeInString(`gateway.mode: ${config.gateway.mode}`)); if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
if (typeof config.gateway?.port === "number") { if (typeof config.gateway?.port === "number") {
rows.push(shortenHomeInString(`gateway.port: ${config.gateway.port}`)); rows.push(`gateway.port: ${config.gateway.port}`);
} }
if (config.gateway?.bind) rows.push(shortenHomeInString(`gateway.bind: ${config.gateway.bind}`)); if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`);
if (config.gateway?.remote?.url) { if (config.gateway?.remote?.url) {
rows.push(shortenHomeInString(`gateway.remote.url: ${config.gateway.remote.url}`)); rows.push(`gateway.remote.url: ${config.gateway.remote.url}`);
} }
if (config.skills?.install?.nodeManager) { if (config.skills?.install?.nodeManager) {
rows.push(shortenHomeInString(`skills.nodeManager: ${config.skills.install.nodeManager}`)); rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`);
} }
return rows.length ? rows.join("\n") : "No key settings detected."; return rows.length ? rows.join("\n") : "No key settings detected.";
} }
@@ -217,19 +211,6 @@ export async function openUrl(url: string): Promise<boolean> {
} }
} }
export async function openUrlInBackground(url: string): Promise<boolean> {
if (process.platform !== "darwin") return false;
const resolved = await resolveBrowserOpenCommand();
if (!resolved.argv || resolved.command !== "open") return false;
const command = ["open", "-g", url];
try {
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
return true;
} catch {
return false;
}
}
export async function ensureWorkspaceAndSessions( export async function ensureWorkspaceAndSessions(
workspaceDir: string, workspaceDir: string,
runtime: RuntimeEnv, runtime: RuntimeEnv,
@@ -239,10 +220,10 @@ export async function ensureWorkspaceAndSessions(
dir: workspaceDir, dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap, ensureBootstrapFiles: !options?.skipBootstrap,
}); });
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); runtime.log(`Workspace OK: ${ws.dir}`);
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId); const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true }); await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`); runtime.log(`Sessions OK: ${sessionsDir}`);
} }
export function resolveNodeManagerOptions(): Array<{ export function resolveNodeManagerOptions(): Array<{
@@ -265,9 +246,9 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
} }
try { try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 }); await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`); runtime.log(`Moved to Trash: ${pathname}`);
} catch { } catch {
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`); runtime.log(`Failed to move to Trash (manual delete): ${pathname}`);
} }
} }
@@ -0,0 +1,213 @@
import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { rawDataToString } from "../infra/ws.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = createServer();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
reject(new Error("failed to acquire free port"));
return;
}
const port = addr.port;
srv.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
async function onceMessage<T = unknown>(
ws: WebSocket,
filter: (obj: unknown) => boolean,
timeoutMs = 5000,
): Promise<T> {
return await new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
const closeHandler = (code: number, reason: Buffer) => {
clearTimeout(timer);
ws.off("message", handler);
reject(new Error(`closed ${code}: ${rawDataToString(reason)}`));
};
const handler = (data: WebSocket.RawData) => {
const obj = JSON.parse(rawDataToString(data));
if (!filter(obj)) return;
clearTimeout(timer);
ws.off("message", handler);
ws.off("close", closeHandler);
resolve(obj as T);
};
ws.on("message", handler);
ws.once("close", closeHandler);
});
}
async function connectReq(params: { url: string; token?: string }) {
const ws = new WebSocket(params.url);
await new Promise<void>((resolve) => ws.once("open", resolve));
const identity = loadOrCreateDeviceIdentity();
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: GATEWAY_CLIENT_NAMES.TEST,
clientMode: GATEWAY_CLIENT_MODES.TEST,
role: "operator",
scopes: [],
signedAtMs,
token: params.token ?? null,
});
const device = {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
ws.send(
JSON.stringify({
type: "req",
id: "c1",
method: "connect",
params: {
minProtocol: PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: GATEWAY_CLIENT_NAMES.TEST,
displayName: "vitest",
version: "dev",
platform: process.platform,
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: [],
auth: params.token ? { token: params.token } : undefined,
device,
},
}),
);
const res = await onceMessage<{
type: "res";
id: string;
ok: boolean;
error?: { message?: string };
}>(ws, (o) => {
const obj = o as { type?: unknown; id?: unknown } | undefined;
return obj?.type === "res" && obj?.id === "c1";
});
ws.close();
return res;
}
describe("onboard (non-interactive): gateway auth", () => {
it("writes gateway token auth into config and gateway enforces it", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-noninteractive-"));
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
const token = "tok_test_123";
const workspace = path.join(tempHome, "clawd");
const runtime = {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayToken: token,
},
runtime,
);
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
gateway?: { auth?: { mode?: string; token?: string } };
agents?: { defaults?: { workspace?: string } };
};
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
const { startGatewayServer } = await import("../gateway/server.js");
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
controlUiEnabled: false,
});
try {
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectReq({
url: `ws://127.0.0.1:${port}`,
token,
});
expect(resToken.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive onboard auth test" });
}
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
}, 60_000);
});
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws"; import { WebSocket } from "ws";
import { import {
@@ -13,32 +13,33 @@ import {
} from "../infra/device-identity.js"; } from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.js"; import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js"; import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
import { rawDataToString } from "../infra/ws.js"; import { rawDataToString } from "../infra/ws.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
async function getFreePort(): Promise<number> { async function isPortFree(port: number): Promise<boolean> {
return await new Promise((resolve, reject) => { if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
return await new Promise((resolve) => {
const srv = createServer(); const srv = createServer();
srv.on("error", reject); srv.once("error", () => resolve(false));
srv.listen(0, "127.0.0.1", () => { srv.listen(port, "127.0.0.1", () => {
const addr = srv.address(); srv.close(() => resolve(true));
if (!addr || typeof addr === "string") {
srv.close();
reject(new Error("failed to acquire free port"));
return;
}
const port = addr.port;
srv.close((err) => {
if (err) reject(err);
else resolve(port);
});
}); });
}); });
} }
async function getFreeGatewayPort(): Promise<number> { async function getFreeGatewayPort(): Promise<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] }); // Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
// ensuring the common derived offsets are free too.
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreeTestPort();
const candidates = [port, port + 1, port + 2, port + 4];
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
Boolean,
);
if (ok) return port;
}
throw new Error("failed to acquire a free gateway port block");
} }
async function onceMessage<T = unknown>( async function onceMessage<T = unknown>(
@@ -120,180 +121,47 @@ async function connectReq(params: { url: string; token?: string }) {
return res; return res;
} }
const runtime = { describe("onboard (non-interactive): lan bind auto-token", () => {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
describe("onboard (non-interactive): gateway and remote auth", () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
skipBrowser: process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
};
let tempHome: string | undefined;
const initStateDir = async (prefix: string) => {
if (!tempHome) {
throw new Error("temp home not initialized");
}
const stateDir = await fs.mkdtemp(path.join(tempHome, prefix));
process.env.CLAWDBOT_STATE_DIR = stateDir;
delete process.env.CLAWDBOT_CONFIG_PATH;
vi.resetModules();
return stateDir;
};
beforeAll(async () => {
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
process.env.HOME = tempHome;
});
afterAll(async () => {
if (tempHome) {
await fs.rm(tempHome, { recursive: true, force: true });
}
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER = prev.skipBrowser;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
});
it("writes gateway token auth into config and gateway enforces it", async () => {
const stateDir = await initStateDir("state-noninteractive-");
const token = "tok_test_123";
const workspace = path.join(stateDir, "clawd");
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "local",
workspace,
authChoice: "skip",
skipSkills: true,
skipHealth: true,
installDaemon: false,
gatewayBind: "loopback",
gatewayAuth: "token",
gatewayToken: token,
},
runtime,
);
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
gateway?: { auth?: { mode?: string; token?: string } };
agents?: { defaults?: { workspace?: string } };
};
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
expect(cfg?.gateway?.auth?.mode).toBe("token");
expect(cfg?.gateway?.auth?.token).toBe(token);
const { startGatewayServer } = await import("../gateway/server.js");
const port = await getFreePort();
const server = await startGatewayServer(port, {
bind: "loopback",
controlUiEnabled: false,
});
try {
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectReq({
url: `ws://127.0.0.1:${port}`,
token,
});
expect(resToken.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive onboard auth test" });
}
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);
it("writes gateway.remote url/token and callGateway uses them", async () => {
const stateDir = await initStateDir("state-remote-");
const port = await getFreePort();
const token = "tok_remote_123";
const { startGatewayServer } = await import("../gateway/server.js");
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
try {
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "remote",
remoteUrl: `ws://127.0.0.1:${port}`,
remoteToken: token,
authChoice: "skip",
json: true,
},
runtime,
);
const { resolveConfigPath } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};
expect(cfg.gateway?.mode).toBe("remote");
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
expect(cfg.gateway?.remote?.token).toBe(token);
const { callGateway } = await import("../gateway/call.js");
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
expect(health?.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive remote test complete" });
await fs.rm(stateDir, { recursive: true, force: true });
}
}, 60_000);
it("auto-enables token auth when binding LAN and persists the token", async () => { it("auto-enables token auth when binding LAN and persists the token", async () => {
if (process.platform === "win32") { if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green. // Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return; return;
} }
const stateDir = await initStateDir("state-lan-"); const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
};
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-lan-"));
process.env.HOME = tempHome;
const stateDir = path.join(tempHome, ".clawdbot");
process.env.CLAWDBOT_STATE_DIR = stateDir; process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json"); process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
const port = await getFreeGatewayPort(); const port = await getFreeGatewayPort();
const workspace = path.join(stateDir, "clawd"); const workspace = path.join(tempHome, "clawd");
const runtime = {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
// Other test files mock ../config/config.js. This onboarding flow needs the real // Other test files mock ../config/config.js. This onboarding flow needs the real
// implementation so it can persist the config and then read it back (Windows CI // implementation so it can persist the config and then read it back (Windows CI
@@ -358,6 +226,14 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
await server.close({ reason: "lan auto-token test complete" }); await server.close({ reason: "lan auto-token test complete" });
} }
await fs.rm(stateDir, { recursive: true, force: true }); await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
}, 60_000); }, 60_000);
}); });
@@ -0,0 +1,113 @@
import fs from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = createServer();
srv.on("error", reject);
srv.listen(0, "127.0.0.1", () => {
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
reject(new Error("failed to acquire free port"));
return;
}
const port = addr.port;
srv.close((err) => {
if (err) reject(err);
else resolve(port);
});
});
});
}
describe("onboard (non-interactive): remote gateway config", () => {
it("writes gateway.remote url/token and callGateway uses them", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
skipCron: process.env.CLAWDBOT_SKIP_CRON,
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
};
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
process.env.CLAWDBOT_SKIP_CRON = "1";
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-remote-"));
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
const port = await getFreePort();
const token = "tok_remote_123";
const { startGatewayServer } = await import("../gateway/server.js");
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const runtime = {
log: () => {},
error: (msg: string) => {
throw new Error(msg);
},
exit: (code: number) => {
throw new Error(`exit:${code}`);
},
};
try {
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
await runNonInteractiveOnboarding(
{
nonInteractive: true,
mode: "remote",
remoteUrl: `ws://127.0.0.1:${port}`,
remoteToken: token,
authChoice: "skip",
json: true,
},
runtime,
);
const { resolveConfigPath } = await import("../config/config.js");
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
};
expect(cfg.gateway?.mode).toBe("remote");
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
expect(cfg.gateway?.remote?.token).toBe(token);
const { callGateway } = await import("../gateway/call.js");
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
expect(health?.ok).toBe(true);
} finally {
await server.close({ reason: "non-interactive remote test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
process.env.HOME = prev.home;
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
}
}, 60_000);
});
@@ -1,6 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { resolveGatewayPort, writeConfigFile } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, resolveGatewayPort, writeConfigFile } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js"; import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js";
@@ -75,7 +74,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
await ensureWorkspaceAndSessions(workspaceDir, runtime, { await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap), skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
@@ -36,7 +36,6 @@ import {
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js"; import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js"; import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js"; import { resolveNonInteractiveApiKey } from "../api-keys.js";
import { shortenHomePath } from "../../../utils.js";
export async function applyNonInteractiveAuthChoice(params: { export async function applyNonInteractiveAuthChoice(params: {
nextConfig: ClawdbotConfig; nextConfig: ClawdbotConfig;
@@ -173,7 +172,7 @@ export async function applyNonInteractiveAuthChoice(params: {
const key = resolved.key; const key = resolved.key;
const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: key }); const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: key });
process.env.OPENAI_API_KEY = key; process.env.OPENAI_API_KEY = key;
runtime.log(`Saved OPENAI_API_KEY to ${shortenHomePath(result.path)}`); runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
return nextConfig; return nextConfig;
} }
@@ -1,6 +1,5 @@
import type { ClawdbotConfig } from "../../config/config.js"; import type { ClawdbotConfig } from "../../config/config.js";
import { writeConfigFile } from "../../config/config.js"; import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js"; import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../../cli/command-format.js"; import { formatCliCommand } from "../../cli/command-format.js";
import { applyWizardMetadata } from "../onboard-helpers.js"; import { applyWizardMetadata } from "../onboard-helpers.js";
@@ -34,7 +33,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
}; };
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode }); nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig); await writeConfigFile(nextConfig);
logConfigUpdated(runtime); runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
const payload = { const payload = {
mode, mode,
+8 -10
View File
@@ -4,11 +4,9 @@ import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js"; import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js"; import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDir } from "../config/sessions.js"; import { resolveSessionTranscriptsDir } from "../config/sessions.js";
import type { RuntimeEnv } from "../runtime.js"; import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js"; import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
async function readConfigFileRaw(): Promise<{ async function readConfigFileRaw(): Promise<{
exists: boolean; exists: boolean;
@@ -54,22 +52,22 @@ export async function setupCommand(
if (!existingRaw.exists || defaults.workspace !== workspace) { if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next); await writeConfigFile(next);
if (!existingRaw.exists) { runtime.log(
runtime.log(`Wrote ${formatConfigPath()}`); !existingRaw.exists
} else { ? `Wrote ${CONFIG_PATH_CLAWDBOT}`
logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" }); : `Updated ${CONFIG_PATH_CLAWDBOT} (set agents.defaults.workspace)`,
} );
} else { } else {
runtime.log(`Config OK: ${formatConfigPath()}`); runtime.log(`Config OK: ${CONFIG_PATH_CLAWDBOT}`);
} }
const ws = await ensureAgentWorkspace({ const ws = await ensureAgentWorkspace({
dir: workspace, dir: workspace,
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap, ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
}); });
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`); runtime.log(`Workspace OK: ${ws.dir}`);
const sessionsDir = resolveSessionTranscriptsDir(); const sessionsDir = resolveSessionTranscriptsDir();
await fs.mkdir(sessionsDir, { recursive: true }); await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`); runtime.log(`Sessions OK: ${sessionsDir}`);
} }
Loaded 100 of 157 files, more files were not shown because too many files have changed in this diff. Show more