Compare commits

..

39 Commits

Author SHA1 Message Date
Peter Steinberger
43bd219f1d fix: hydrate Slack thread root files (#1479) (thanks @travisirby) 2026-01-23 05:00:59 +00:00
Tak hoffman
b65916e0d1 CLI: fix Windows gateway startup 2026-01-23 04:47:01 +00:00
Peter Steinberger
9207840db4 docs: note #1482 in changelog 2026-01-23 04:38:08 +00:00
Peter Steinberger
784468d6c3 fix: harden BlueBubbles voice memos (#1477) (thanks @Nicell) 2026-01-23 04:38:08 +00:00
Clawd
02b5f403db feat(bluebubbles): add asVoice support for voice memos
Add asVoice parameter to sendBlueBubblesAttachment that converts audio
to iMessage voice memo format (Opus CAF at 48kHz) and sets isAudioMessage
flag in the BlueBubbles API.

This follows the existing asVoice pattern used by Telegram.

- Convert audio to Opus CAF format using ffmpeg when asVoice=true
- Set isAudioMessage=true in BlueBubbles attachment API
- Pass asVoice through action handler and media-send
2026-01-23 04:34:19 +00:00
Peter Steinberger
5d0d9e6323 feat: refine onboarding hatch flow 2026-01-23 04:32:23 +00:00
Peter Steinberger
64be2b2cd1 test: speed up gateway suite setup 2026-01-23 04:28:02 +00:00
Rodrigo Uroz
dd2400fb2a fix: read Slack thread replies for message reads (#1450) (#1450)
Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Rodrigo Uroz <rodrigouroz@users.noreply.github.com>
2026-01-23 04:17:45 +00:00
Peter Steinberger
5d001cb953 refactor: add config logging helpers 2026-01-23 04:16:39 +00:00
Peter Steinberger
d23c4a3f10 fix: put plugin descriptions under source 2026-01-23 04:02:42 +00:00
Peter Steinberger
e750ad5e75 refactor: centralize config update logging 2026-01-23 04:01:26 +00:00
Paulo Portella
246ee490f6 docs: add pauloportella to clawtributors 2026-01-23 03:58:57 +00:00
Peter Steinberger
d62a20fba9 chore: add open-prose license 2026-01-23 03:53:03 +00:00
Peter Steinberger
7f68bf79b6 fix: prefer ~ for home paths in output 2026-01-23 03:44:31 +00:00
Peter Steinberger
34bb7250f8 fix: resolve changelog merge markers 2026-01-23 03:44:14 +00:00
Peter Steinberger
34696dc8b9 Merge pull request #1432 from tobiasbischoff/main
fix(auth): skip auth profiles in cooldown during selection and rotation
2026-01-23 03:35:25 +00:00
Peter Steinberger
9a9afb389a Merge origin/main into pr-1432 2026-01-23 03:35:16 +00:00
Peter Steinberger
1e9ae7649d docs: add changelog entry for #1432 2026-01-23 03:31:42 +00:00
Peter Steinberger
5cb9026541 fix: honor user-pinned profiles and search ranking 2026-01-23 03:28:47 +00:00
Tobias Bischoff
81e78dced5 perf(tui): optimize searchable select list filtering
- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort

Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
2026-01-23 03:28:18 +00:00
Tobias Bischoff
565944ec71 fix(auth): skip auth profiles in cooldown during selection and rotation
Auth profiles in cooldown (due to rate limiting) were being attempted,
causing unnecessary retries and delays. This fix ensures:

1. Initial profile selection skips profiles in cooldown
2. Profile rotation (after failures) skips cooldown profiles
3. Clear error message when all profiles are unavailable

Tests added:
- Skips profiles in cooldown during initial selection
- Skips profiles in cooldown when rotating after failure

Fixes #1316
2026-01-23 03:28:18 +00:00
Peter Steinberger
ec2c69c230 fix: honor gateway env token for doctor/security
Co-authored-by: azade-c <azade-c@users.noreply.github.com>
2026-01-23 03:16:52 +00:00
Peter Steinberger
f1deffa681 fix: repair docs redirects 2026-01-23 03:13:12 +00:00
Peter Steinberger
4b19066cc1 fix: normalize Windows exec allowlist paths 2026-01-23 03:11:41 +00:00
Peter Steinberger
ea79b26b79 feat: extend lobster tool run args 2026-01-23 03:09:59 +00:00
Peter Steinberger
6eb355954c docs: add changelog entry for #1432 2026-01-23 03:06:10 +00:00
Peter Steinberger
91ca52d3c5 fix: honor user-pinned profiles and search ranking 2026-01-23 03:05:01 +00:00
Peter Steinberger
0149d2b678 test: speed up test suite 2026-01-23 02:55:38 +00:00
Peter Steinberger
ecfddb7807 docs: fix lobster links 2026-01-23 02:51:33 +00:00
Peter Steinberger
35228ecae9 fix: treat copilot oauth tokens as non-expiring 2026-01-23 02:51:33 +00:00
Peter Steinberger
cfcc4548bb fix: set Copilot user agent header 2026-01-23 02:51:33 +00:00
Peter Steinberger
21a9b3b66f fix: improve GitHub Copilot integration 2026-01-23 02:51:33 +00:00
Peter Steinberger
837749dced fix: honor send path/filePath inputs (#1444) (thanks @hopyky) 2026-01-23 02:27:47 +00:00
Peter Steinberger
59a8eecd7e test: speed up test suite 2026-01-23 02:22:02 +00:00
Peter Steinberger
542cf011a0 Merge pull request #1444 from hopyky/fix-message-path-parameter
Fix: Support path and filePath parameters in message send action
2026-01-23 02:10:54 +00:00
Peter Steinberger
4355d9acca fix: resolve heartbeat sender and Slack thread_ts 2026-01-23 02:05:34 +00:00
Matt mini
57e81d3c24 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-22 13:15:48 +01:00
Tobias Bischoff
917bcb714e perf(tui): optimize searchable select list filtering
- Add regex caching to avoid creating new RegExp objects on each render
- Optimize smartFilter to use single array with tier-based scoring
- Replace non-existent fuzzyFilter import with local fuzzyFilterLower
- Reduces from 4 array allocations and 4 sorts to 1 array and 1 sort

Fixes pre-existing bug where fuzzyFilter was imported from pi-tui but not exported.
2026-01-22 10:29:37 +01:00
Tobias Bischoff
3d8a759eba fix(auth): skip auth profiles in cooldown during selection and rotation
Auth profiles in cooldown (due to rate limiting) were being attempted,
causing unnecessary retries and delays. This fix ensures:

1. Initial profile selection skips profiles in cooldown
2. Profile rotation (after failures) skips cooldown profiles
3. Clear error message when all profiles are unavailable

Tests added:
- Skips profiles in cooldown during initial selection
- Skips profiles in cooldown when rotating after failure

Fixes #1316
2026-01-22 10:04:56 +01:00
137 changed files with 5578 additions and 4956 deletions

View File

@@ -2,24 +2,23 @@
Docs: https://docs.clawd.bot
## 2026.1.22 (unreleased)
## 2026.1.22
### 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
- 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.
- 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 /model allowlist troubleshooting note. (#1405)
- 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.
- 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).
- Signal: add typing indicators and DM read receipts via signal-cli.
- 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
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
@@ -32,15 +31,18 @@ Docs: https://docs.clawd.bot
- 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.
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
<<<<<<< Updated upstream
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
- Agents: make tool summaries more readable and only show optional params when set.
- CLI: prefer `~` for home paths in output.
- 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.
>>>>>>> Stashed changes
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- 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.
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- Slack: hydrate thread root attachments for replies and include multi-file context. (#1479) Thanks @travisirby.
- 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
@@ -50,63 +52,38 @@ Docs: https://docs.clawd.bot
## 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
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
- 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
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
- 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
- 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
- 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
- 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.
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
### 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:** Envelope and system event timestamps now default to host-local time (was UTC) so agents dont have to constantly convert.
### 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.
- 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)
- 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)
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- Embedded runner: persist injected history images so attachments arent reloaded each turn. (#1374) Thanks @Nicell.
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
- 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

View File

@@ -477,28 +477,29 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
<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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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=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=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/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=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=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/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/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/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/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/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/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/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/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/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/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/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=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/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=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=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/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>

View File

@@ -147,7 +147,8 @@ Available actions:
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
- **leaveGroup**: Leave a group chat (`chatGuid`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
- 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)
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.

View File

@@ -141,14 +141,6 @@
"source": "/message/",
"destination": "/cli/message"
},
{
"source": "/mattermost",
"destination": "/channels/mattermost"
},
{
"source": "/mattermost/",
"destination": "/channels/mattermost"
},
{
"source": "/providers/discord",
"destination": "/channels/discord"

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.
For deterministic, approval-gated workflows, compare with [Lobster](/lobster).
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster).

View File

@@ -16,9 +16,9 @@ provider in two different ways.
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
Use the native device-login flow to obtain a GitHub token and use it directly
against the Copilot API. This is the **default** and simplest path because it
does not require VS Code. Enterprise domains are supported.
### 2) Copilot Proxy plugin (`copilot-proxy`)
@@ -39,6 +39,8 @@ clawdbot models auth login-github-copilot
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
domain (for example `company.ghe.com`).
### Optional flags
@@ -66,5 +68,7 @@ clawdbot models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
- The login stores a GitHub token in the auth profile store and uses it directly
for Copilot API calls.
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
for GitHub Enterprise.

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).
Optional plugin tools:
- [Lobster](/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
## Tool inventory

View File

@@ -11,6 +11,10 @@ read_when:
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
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:
@@ -24,6 +28,73 @@ 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.
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 steps output.
- `condition` (or `when`) can gate steps on `$step.approved`.
## 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`.
@@ -115,6 +186,16 @@ 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`
Continue a halted workflow after approval.
@@ -133,6 +214,7 @@ Continue a halted workflow after approval.
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
- `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
@@ -151,6 +233,8 @@ If `requiresApproval` is present, inspect the prompt and decide:
- `approve: true` → resume and continue side effects
- `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 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).
@@ -173,3 +257,10 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
- [Plugins](/plugin)
- [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

View File

@@ -521,6 +521,42 @@ 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 () => {
const cfg: ClawdbotConfig = {
channels: {

View File

@@ -3,7 +3,6 @@ import {
BLUEBUBBLES_ACTIONS,
createActionGate,
jsonResult,
readBooleanParam,
readNumberParam,
readReactionParams,
readStringParam,
@@ -51,6 +50,17 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
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 */
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
@@ -356,6 +366,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
const caption = readStringParam(params, "caption");
const contentType =
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
const asVoice = readBooleanParam(params, "asVoice");
// Buffer can come from params.buffer (base64) or params.path (file path)
const base64Buffer = readStringParam(params, "buffer");
@@ -380,6 +391,7 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
filename,
contentType: contentType ?? undefined,
caption: caption ?? undefined,
asVoice: asVoice ?? undefined,
opts,
});

View File

@@ -1,6 +1,6 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
import { downloadBlueBubblesAttachment } from "./attachments.js";
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
import type { BlueBubblesAttachment } from "./types.js";
vi.mock("./accounts.js", () => ({
@@ -238,3 +238,109 @@ describe("downloadBlueBubblesAttachment", () => {
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"');
});
});

View File

@@ -1,4 +1,5 @@
import crypto from "node:crypto";
import path from "node:path";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { resolveChatGuidForTarget } from "./send.js";
@@ -19,6 +20,30 @@ export type BlueBubblesAttachmentOpts = {
};
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) {
const account = resolveBlueBubblesAccount({
@@ -104,6 +129,7 @@ function extractMessageId(payload: unknown): string {
/**
* Send an attachment via BlueBubbles API.
* 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: {
to: string;
@@ -113,12 +139,37 @@ export async function sendBlueBubblesAttachment(params: {
caption?: string;
replyToMessageGuid?: string;
replyToPartIndex?: number;
asVoice?: boolean;
opts?: BlueBubblesAttachmentOpts;
}): Promise<SendBlueBubblesAttachmentResult> {
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
params;
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
let { buffer, filename, contentType } = params;
const wantsVoice = asVoice === true;
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
filename = sanitizeFilename(filename, fallbackName);
contentType = contentType?.trim() || undefined;
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 chatGuid = await resolveChatGuidForTarget({
baseUrl,
@@ -170,6 +221,11 @@ export async function sendBlueBubblesAttachment(params: {
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
addField("method", "private-api");
// Add isAudioMessage flag for voice memos
if (isAudioMessage) {
addField("isAudioMessage", "true");
}
const trimmedReplyTo = replyToMessageGuid?.trim();
if (trimmedReplyTo) {
addField("selectedMessageGuid", trimmedReplyTo);

View File

@@ -59,6 +59,7 @@ export async function sendBlueBubblesMedia(params: {
caption?: string;
replyToId?: string | null;
accountId?: string;
asVoice?: boolean;
}) {
const {
cfg,
@@ -71,6 +72,7 @@ export async function sendBlueBubblesMedia(params: {
caption,
replyToId,
accountId,
asVoice,
} = params;
const core = getBlueBubblesRuntime();
const maxBytes = resolveChannelMediaMaxBytes({
@@ -146,6 +148,7 @@ export async function sendBlueBubblesMedia(params: {
filename: resolvedFilename ?? "attachment",
contentType: resolvedContentType ?? undefined,
replyToMessageGuid,
asVoice,
opts: {
cfg,
accountId,

View File

@@ -159,6 +159,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
pipeline: Type.Optional(Type.String()),
argsJson: Type.Optional(Type.String()),
token: Type.Optional(Type.String()),
approve: Type.Optional(Type.Boolean()),
lobsterPath: Type.Optional(Type.String()),
@@ -181,7 +182,12 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
if (action === "run") {
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
if (!pipeline.trim()) throw new Error("pipeline required");
return ["run", "--mode", "tool", pipeline];
const argv = ["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") {
const token = typeof params.token === "string" ? params.token : "";

View File

@@ -0,0 +1,21 @@
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.

View File

@@ -115,7 +115,11 @@ if (!shouldBuild()) {
runNode();
} else {
logRunner("Building TypeScript (dist is stale).");
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
const pnpmArgs = ["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,
env,
stdio: "inherit",

View File

@@ -0,0 +1,70 @@
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");
});
});

View File

@@ -39,6 +39,15 @@ async function refreshOAuthTokenWithLock(params: {
const cred = store.profiles[params.profileId];
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) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
@@ -103,6 +112,20 @@ async function tryResolveOAuthProfile(params: {
if (profileConfig && profileConfig.provider !== cred.provider) 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) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),

View File

@@ -19,6 +19,7 @@ export type TokenCredential = {
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
enterpriseUrl?: string;
email?: string;
};

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.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> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -51,16 +52,6 @@ describe("models-config", () => {
try {
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 agentDir = path.join(home, "agent-default-base-url");
@@ -71,48 +62,55 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
it("uses enterprise URL from auth profiles to derive base URL", 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 {
vi.resetModules();
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 agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
},
null,
2,
),
);
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } });
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "copilot-token" }),
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
process.env.GH_TOKEN = previousGh;
process.env.GITHUB_TOKEN = previousGithub;
// no-op
}
});
});

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.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> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -43,7 +44,7 @@ describe("models-config", () => {
process.env.HOME = previousHome;
});
it("falls back to default baseUrl when token exchange fails", async () => {
it("uses default baseUrl when env token is present", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
@@ -51,11 +52,6 @@ describe("models-config", () => {
try {
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 { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -67,13 +63,13 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("uses agentDir override auth profiles for copilot injection", async () => {
it("normalizes enterprise URL when deriving base URL", async () => {
await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
@@ -94,9 +90,12 @@ describe("models-config", () => {
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
type: "oauth",
provider: "github-copilot",
token: "gh-profile-token",
refresh: "gh-profile-token",
access: "gh-profile-token",
expires: 0,
enterpriseUrl: "https://company.ghe.com/",
},
},
},
@@ -105,16 +104,6 @@ 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");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
@@ -124,7 +113,9 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;

View File

@@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
normalizeGithubCopilotDomain,
resolveGithubCopilotBaseUrl,
} from "../providers/github-copilot-utils.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import {
@@ -331,29 +331,18 @@ export async function resolveImplicitCopilotProvider(params: {
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
let enterpriseDomain: string | null = null;
if (hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
}
}
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;
}
}
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
@@ -364,7 +353,7 @@ export async function resolveImplicitCopilotProvider(params: {
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.

View File

@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.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> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -80,25 +81,16 @@ 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");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "alpha-token" }),
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
@@ -117,16 +109,6 @@ describe("models-config", () => {
try {
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 { resolveClawdbotAgentDir } = await import("./agent-paths.js");

View File

@@ -210,6 +210,74 @@ 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 () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
@@ -248,4 +316,149 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
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();
}
});
});

View File

@@ -1,258 +0,0 @@
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);
});
});

View File

@@ -1,7 +1,8 @@
import fs from "node:fs/promises";
import os from "node:os";
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 { ensureClawdbotModelsJson } from "./models-config.js";
@@ -86,10 +87,25 @@ vi.mock("@mariozechner/pi-ai", async () => {
});
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
let tempRoot: string | undefined;
let agentDir: string;
let workspaceDir: string;
let sessionCounter = 0;
beforeEach(async () => {
beforeAll(async () => {
vi.useRealTimers();
({ 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[]) =>
@@ -114,10 +130,14 @@ const makeOpenAiConfig = (modelIds: string[]) =>
},
}) satisfies ClawdbotConfig;
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
const testSessionKey = "agent:test:embedded-ordering";
const nextSessionFile = () => {
sessionCounter += 1;
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
};
const testSessionKey = "agent:test:embedded";
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
const textFromContent = (content: unknown) => {
@@ -145,15 +165,114 @@ const readSessionMessages = async (sessionFile: string) => {
};
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(
"appends new user + assistant after existing transcript entries",
{ timeout: 90_000 },
async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
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 sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
@@ -185,7 +304,7 @@ describe("runEmbeddedPiAgent", () => {
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
@@ -221,13 +340,11 @@ describe("runEmbeddedPiAgent", () => {
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
},
);
it("persists multi-turn user/assistant ordering across runs", 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");
it("persists multi-turn user/assistant ordering across runs", async () => {
const sessionFile = nextSessionFile();
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await ensureModels(cfg);
await runEmbeddedPiAgent({
sessionId: "session:test",
@@ -265,58 +382,33 @@ describe("runEmbeddedPiAgent", () => {
(message, index) => index > firstUserIndex && message?.role === "assistant",
);
const secondUserIndex = messages.findIndex(
(message) => message?.role === "user" && textFromContent(message.content) === "second",
(message, index) =>
index > firstAssistantIndex &&
message?.role === "user" &&
textFromContent(message.content) === "second",
);
const secondAssistantIndex = messages.findIndex(
(message, index) => index > secondUserIndex && message?.role === "assistant",
);
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
}, 90_000);
});
it("repairs orphaned user messages and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
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 sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "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" }],
content: [{ type: "text", text: "orphaned user" }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await ensureModels(cfg);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",
@@ -338,19 +430,16 @@ describe("runEmbeddedPiAgent", () => {
it("repairs orphaned single-user sessions and continues", async () => {
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
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 sessionFile = nextSessionFile();
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage({
role: "user",
content: [{ type: "text", text: "seed user only" }],
content: [{ type: "text", text: "solo user" }],
});
const cfg = makeOpenAiConfig(["mock-1"]);
await ensureModels(cfg, agentDir);
await ensureModels(cfg);
const result = await runEmbeddedPiAgent({
sessionId: "session:test",

View File

@@ -128,13 +128,6 @@ export async function compactEmbeddedPiSession(params: {
`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 {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}

View File

@@ -7,9 +7,20 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js";
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
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(
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
): InlineModelEntry[] {
@@ -60,7 +71,7 @@ export function resolveModel(
if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
return {
model: normalized,
model: applyProviderModelOverrides(normalized),
authStorage,
modelRegistry,
};
@@ -78,7 +89,7 @@ export function resolveModel(
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
return { model: fallbackModel, authStorage, modelRegistry };
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
}
return {
error: `Unknown model: ${provider}/${modelId}`,
@@ -86,5 +97,9 @@ export function resolveModel(
modelRegistry,
};
}
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
return {
model: applyProviderModelOverrides(normalizeModelCompat(model)),
authStorage,
modelRegistry,
};
}

View File

@@ -5,6 +5,7 @@ import { resolveUserPath } from "../../utils.js";
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
import { resolveClawdbotAgentDir } from "../agent-paths.js";
import {
isProfileInCooldown,
markAuthProfileFailure,
markAuthProfileGood,
markAuthProfileUsed,
@@ -148,7 +149,11 @@ export async function runEmbeddedPiAgent(
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
}
const profileCandidates = profileOrder.length > 0 ? profileOrder : [undefined];
const profileCandidates = lockedProfileId
? [lockedProfileId]
: profileOrder.length > 0
? profileOrder
: [undefined];
let profileIndex = 0;
const initialThinkLevel = params.thinkLevel ?? "off";
@@ -169,26 +174,18 @@ export async function runEmbeddedPiAgent(
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
const resolvedProfileId = apiKeyInfo.profileId ?? candidate;
if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error(
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
);
}
lastProfileId = apiKeyInfo.profileId;
lastProfileId = resolvedProfileId;
return;
}
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 {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
lastProfileId = apiKeyInfo.profileId;
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
lastProfileId = resolvedProfileId;
};
const advanceAuthProfile = async (): Promise<boolean> => {
@@ -196,6 +193,10 @@ export async function runEmbeddedPiAgent(
let nextIndex = profileIndex + 1;
while (nextIndex < profileCandidates.length) {
const candidate = profileCandidates[nextIndex];
if (candidate && isProfileInCooldown(authStore, candidate)) {
nextIndex += 1;
continue;
}
try {
await applyApiKeyInfo(candidate);
profileIndex = nextIndex;
@@ -211,7 +212,24 @@ export async function runEmbeddedPiAgent(
};
try {
await applyApiKeyInfo(profileCandidates[profileIndex]);
while (profileIndex < profileCandidates.length) {
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) {
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
const advanced = await advanceAuthProfile();
@@ -502,10 +520,12 @@ export async function runEmbeddedPiAgent(
store: authStore,
provider,
profileId: lastProfileId,
agentDir: params.agentDir,
});
await markAuthProfileUsed({
store: authStore,
profileId: lastProfileId,
agentDir: params.agentDir,
});
}
return {

View File

@@ -357,6 +357,20 @@ describe("handleSlackAction", () => {
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 () => {
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
listSlackPins.mockResolvedValueOnce([

View File

@@ -214,11 +214,13 @@ export async function handleSlackAction(
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
const before = readStringParam(params, "before");
const after = readStringParam(params, "after");
const threadId = readStringParam(params, "threadId");
const result = await readSlackMessages(channelId, {
...readOpts,
limit,
before: before ?? undefined,
after: after ?? undefined,
threadId: threadId ?? undefined,
});
const messages = result.messages.map((message) =>
withNormalizedTimestamp(

View File

@@ -42,7 +42,7 @@ describe("block streaming", () => {
});
async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 1500;
const deadline = Date.now() + 5000;
while (fn() < calls) {
if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`);

View File

@@ -0,0 +1,34 @@
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",
});
});
});

View File

@@ -133,6 +133,7 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
limit,
before: readStringParam(params, "before"),
after: readStringParam(params, "after"),
threadId: readStringParam(params, "threadId"),
accountId: accountId ?? undefined,
},
cfg,

View File

@@ -94,9 +94,13 @@ export function buildParseArgv(params: {
: baseArgv[0]?.endsWith("clawdbot")
? baseArgv.slice(1)
: baseArgv;
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
const looksLikeNode =
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
normalizedArgv.length >= 2 &&
(executable === "node" ||
executable === "node.exe" ||
executable === "bun" ||
executable === "bun.exe");
if (looksLikeNode) return normalizedArgv;
return ["node", programName || "clawdbot", ...normalizedArgv];
}

View File

@@ -9,6 +9,7 @@ import { danger } from "../../globals.js";
import { defaultRuntime } from "../../runtime.js";
import type { BrowserParentOpts } from "../browser-cli-shared.js";
import { resolveBrowserActionContext } from "./shared.js";
import { shortenHomePath } from "../../utils.js";
export function registerBrowserFilesAndDownloadsCommands(
browser: Command,
@@ -73,7 +74,7 @@ export function registerBrowserFilesAndDownloadsCommands(
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`downloaded: ${result.download.path}`);
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -105,7 +106,7 @@ export function registerBrowserFilesAndDownloadsCommands(
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`downloaded: ${result.download.path}`);
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);

View File

@@ -9,6 +9,7 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
function runBrowserObserve(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
@@ -61,7 +62,7 @@ export function registerBrowserActionObserveCommands(
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`PDF: ${result.path}`);
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`);
});
});

View File

@@ -12,6 +12,7 @@ import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
import { shortenHomePath } from "../utils.js";
function runBrowserDebug(action: () => Promise<void>) {
return runCommandWithRuntime(defaultRuntime, action, (err) => {
@@ -164,7 +165,7 @@ export function registerBrowserDebugCommands(
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`TRACE:${result.path}`);
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);
});
});
}

View File

@@ -11,6 +11,7 @@ import { defaultRuntime } from "../runtime.js";
import { movePathToTrash } from "../browser/trash.js";
import { formatDocsLink } from "../terminal/links.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
function bundledExtensionRootDir() {
@@ -77,7 +78,8 @@ export function registerBrowserExtensionCommands(
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
return;
}
defaultRuntime.log(installed.path);
const displayPath = shortenHomePath(installed.path);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(installed.path).catch(() => false);
defaultRuntime.error(
info(
@@ -85,7 +87,7 @@ export function registerBrowserExtensionCommands(
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
"Next:",
`- Chrome → chrome://extensions → enable “Developer mode”`,
`- “Load unpacked” → select: ${installed.path}`,
`- “Load unpacked” → select: ${displayPath}`,
`- 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")}`,
@@ -115,7 +117,8 @@ export function registerBrowserExtensionCommands(
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
return;
}
defaultRuntime.log(dir);
const displayPath = shortenHomePath(dir);
defaultRuntime.log(displayPath);
const copied = await copyToClipboard(dir).catch(() => false);
if (copied) defaultRuntime.error(info("Copied to clipboard."));
});

View File

@@ -5,6 +5,7 @@ import { browserScreenshotAction } from "../browser/client-actions.js";
import { loadConfig } from "../config/config.js";
import { danger } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
export function registerBrowserInspectCommands(
@@ -36,7 +37,7 @@ export function registerBrowserInspectCommands(
defaultRuntime.log(JSON.stringify(result, null, 2));
return;
}
defaultRuntime.log(`MEDIA:${result.path}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`);
} catch (err) {
defaultRuntime.error(danger(String(err)));
defaultRuntime.exit(1);
@@ -106,9 +107,9 @@ export function registerBrowserInspectCommands(
),
);
} else {
defaultRuntime.log(opts.out);
defaultRuntime.log(shortenHomePath(opts.out));
if (result.format === "ai" && result.imagePath) {
defaultRuntime.log(`MEDIA:${result.imagePath}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
}
}
return;
@@ -122,7 +123,7 @@ export function registerBrowserInspectCommands(
if (result.format === "ai") {
defaultRuntime.log(result.snapshot);
if (result.imagePath) {
defaultRuntime.log(`MEDIA:${result.imagePath}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
}
return;
}

View File

@@ -18,6 +18,7 @@ import {
import { browserAct } from "../browser/client-actions-core.js";
import { danger, info } from "../globals.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
import type { BrowserParentOpts } from "./browser-cli-shared.js";
import { runCommandWithRuntime } from "./cli-utils.js";
@@ -46,6 +47,8 @@ export function registerBrowserManageCommands(
defaultRuntime.log(JSON.stringify(status, null, 2));
return;
}
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto";
defaultRuntime.log(
[
`profile: ${status.profile ?? "clawd"}`,
@@ -56,7 +59,7 @@ export function registerBrowserManageCommands(
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
`browser: ${status.chosenBrowser ?? "unknown"}`,
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
`detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
`detectedPath: ${detectedDisplay}`,
`profileColor: ${status.color}`,
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
].join("\n"),

View File

@@ -7,6 +7,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { formatCliCommand } from "./command-format.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
type PathSegment = string;
@@ -168,7 +169,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
async function loadValidConfig() {
const snapshot = await readConfigFileSnapshot();
if (snapshot.valid) return snapshot;
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
defaultRuntime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
for (const issue of snapshot.issues) {
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
}

View File

@@ -13,6 +13,7 @@ import { isWSLEnv } from "../../infra/wsl.js";
import { getResolvedLoggerSettings } from "../../logging.js";
import { defaultRuntime } from "../../runtime.js";
import { colorize, isRich, theme } from "../../terminal/theme.js";
import { shortenHomePath } from "../../utils.js";
import { formatCliCommand } from "../command-format.js";
import {
filterDaemonEnv,
@@ -66,7 +67,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
try {
const logFile = getResolvedLoggerSettings().file;
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(logFile))}`);
} catch {
// ignore missing config/log resolution
}
@@ -76,10 +77,14 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
);
}
if (service.command?.sourcePath) {
defaultRuntime.log(`${label("Service file:")} ${infoText(service.command.sourcePath)}`);
defaultRuntime.log(
`${label("Service file:")} ${infoText(shortenHomePath(service.command.sourcePath))}`,
);
}
if (service.command?.workingDirectory) {
defaultRuntime.log(`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`);
defaultRuntime.log(
`${label("Working dir:")} ${infoText(shortenHomePath(service.command.workingDirectory))}`,
);
}
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
if (daemonEnvLines.length > 0) {
@@ -101,7 +106,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
}
if (status.config) {
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
const cliCfg = `${shortenHomePath(status.config.cli.path)}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
if (!status.config.cli.valid && status.config.cli.issues?.length) {
for (const issue of status.config.cli.issues.slice(0, 5)) {
@@ -111,7 +116,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
}
}
if (status.config.daemon) {
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
const daemonCfg = `${shortenHomePath(status.config.daemon.path)}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
for (const issue of status.config.daemon.issues.slice(0, 5)) {
@@ -276,8 +281,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
const logs = resolveGatewayLogPaths(
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
);
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`);
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`);
}
spacer();
}

View File

@@ -6,7 +6,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { handleReset } from "../../commands/onboard-helpers.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath } from "../../utils.js";
import { resolveUserPath, shortenHomePath } from "../../utils.js";
const DEV_IDENTITY_NAME = "C3-PO";
const DEV_IDENTITY_THEME = "protocol droid";
@@ -117,6 +117,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
},
});
await ensureDevWorkspace(workspace);
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}`);
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
}

View File

@@ -1,66 +1,66 @@
import { spawn } from "node:child_process";
import fs from "node:fs";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
const waitForPortOpen = async (
const waitForReady = async (
proc: ReturnType<typeof spawn>,
chunksOut: string[],
chunksErr: string[],
port: number,
timeoutMs: number,
) => {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
if (proc.exitCode !== null) {
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`gateway exited before listening (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
cleanup();
reject(
new Error(
`timeout waiting for gateway to start\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
),
);
}
}, timeoutMs);
try {
await new Promise<void>((resolve, reject) => {
const socket = net.connect({ host: "127.0.0.1", port });
socket.once("connect", () => {
socket.destroy();
resolve();
});
socket.once("error", (err) => {
socket.destroy();
reject(err);
});
});
return;
} catch {
// keep polling
}
const cleanup = () => {
clearTimeout(timer);
proc.off("exit", onExit);
proc.off("message", onMessage);
proc.stdout?.off("data", onStdout);
};
await new Promise((resolve) => setTimeout(resolve, 10));
}
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
throw new Error(
`timeout waiting for gateway to listen on port ${port}\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
);
};
const onExit = () => {
const stdout = chunksOut.join("");
const stderr = chunksErr.join("");
cleanup();
reject(
new Error(
`gateway exited before ready (code=${String(proc.exitCode)} signal=${String(proc.signalCode)})\n` +
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
),
);
};
const getFreePort = async () => {
const srv = net.createServer();
await new Promise<void>((resolve) => srv.listen(0, "127.0.0.1", resolve));
const addr = srv.address();
if (!addr || typeof addr === "string") {
srv.close();
throw new Error("failed to bind ephemeral port");
}
await new Promise<void>((resolve) => srv.close(() => resolve()));
return addr.port;
const onMessage = (msg: unknown) => {
if (msg && typeof msg === "object" && "ready" in msg) {
cleanup();
resolve();
}
};
const onStdout = (chunk: unknown) => {
if (String(chunk).includes("READY")) {
cleanup();
resolve();
}
};
proc.once("exit", onExit);
proc.on("message", onMessage);
proc.stdout?.on("data", onStdout);
});
};
describe("gateway SIGTERM", () => {
@@ -77,67 +77,50 @@ describe("gateway SIGTERM", () => {
});
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 configPath = path.join(stateDir, "clawdbot.json");
fs.writeFileSync(
configPath,
JSON.stringify({ gateway: { mode: "local", port } }, null, 2),
"utf8",
);
const out: string[] = [];
const err: string[] = [];
const nodeBin = process.execPath;
const entryArgs = [
"gateway",
"--port",
String(port),
"--bind",
"loopback",
"--allow-unconfigured",
];
const env = {
...process.env,
CLAWDBOT_NO_RESPAWN: "1",
CLAWDBOT_STATE_DIR: stateDir,
CLAWDBOT_CONFIG_PATH: configPath,
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_GMAIL_WATCHER: "1",
CLAWDBOT_SKIP_CRON: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "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 runMainPath = path.resolve("src/cli/run-main.ts");
const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts");
const runtimePath = path.resolve("src/runtime.ts");
fs.writeFileSync(
bootstrapPath,
[
'import { pathToFileURL } from "node:url";',
'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";',
"let entryArgs = [];",
"try {",
" entryArgs = JSON.parse(rawArgs);",
"} catch (err) {",
' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);',
" process.exit(1);",
"}",
"if (!Array.isArray(entryArgs)) entryArgs = [];",
'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));',
`const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`,
"const { runCli } = await import(runMainUrl);",
'await runCli(["node", "clawdbot", ...entryArgs]);',
`const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`,
`const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`,
"const { runGatewayLoop } = await import(runLoopUrl);",
"const { defaultRuntime } = await import(runtimeUrl);",
"await runGatewayLoop({",
" start: async () => {",
' process.stdout.write("READY\\\\n");',
" if (process.send) process.send({ ready: true });",
" const keepAlive = setInterval(() => {}, 1000);",
" return { close: async () => clearInterval(keepAlive) };",
" },",
" runtime: defaultRuntime,",
"});",
].join("\n"),
"utf8",
);
const childArgs = ["--import", "tsx", bootstrapPath];
env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs);
child = spawn(nodeBin, childArgs, {
cwd: process.cwd(),
env,
stdio: ["ignore", "pipe", "pipe"],
stdio: ["ignore", "pipe", "pipe", "ipc"],
});
const proc = child;
@@ -148,7 +131,7 @@ describe("gateway SIGTERM", () => {
child.stdout?.on("data", (d) => out.push(String(d)));
child.stderr?.on("data", (d) => err.push(String(d)));
await waitForPortOpen(proc, out, err, port, 150_000);
await waitForReady(proc, out, err, 150_000);
proc.kill("SIGTERM");

View File

@@ -25,7 +25,7 @@ import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { formatCliCommand } from "./command-format.js";
import { resolveUserPath } from "../utils.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
export type HooksListOptions = {
json?: boolean;
@@ -224,8 +224,8 @@ export function formatHookInfo(
} else {
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
}
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`);
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`);
if (hook.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
}
@@ -577,7 +577,7 @@ export function registerHooksCli(program: Command): void {
});
await writeConfigFile(next);
defaultRuntime.log(`Linked hook path: ${resolved}`);
defaultRuntime.log(`Linked hook path: ${shortenHomePath(resolved)}`);
defaultRuntime.log(`Restart the gateway to load hooks.`);
return;
}

View File

@@ -17,6 +17,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { colorize, isRich, theme } from "../terminal/theme.js";
import { resolveStateDir } from "../config/paths.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
type MemoryCommandOptions = {
agent?: string;
@@ -44,11 +45,15 @@ type MemorySourceScan = {
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
if (source === "memory") {
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
return shortenHomeInString(
`memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`,
);
}
if (source === "sessions") {
const stateDir = resolveStateDir(process.env, os.homedir);
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
return shortenHomeInString(
`sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`,
);
}
return source;
}
@@ -76,7 +81,10 @@ async function checkReadableFile(pathname: string): Promise<{ exists: boolean; i
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") return { exists: false };
return { exists: true, issue: `${pathname} not readable (${code ?? "error"})` };
return {
exists: true,
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
};
}
}
@@ -92,10 +100,12 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
issues.push(`sessions directory missing (${sessionsDir})`);
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`);
return { source: "sessions", totalFiles: 0, issues };
}
issues.push(`sessions directory not accessible (${sessionsDir}): ${code ?? "error"}`);
issues.push(
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
);
return { source: "sessions", totalFiles: null, issues };
}
}
@@ -118,10 +128,12 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (code === "ENOENT") {
issues.push(`memory directory missing (${memoryDir})`);
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`);
dirReadable = false;
} else {
issues.push(`memory directory not accessible (${memoryDir}): ${code ?? "error"}`);
issues.push(
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null;
}
}
@@ -134,7 +146,9 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (dirReadable !== null) {
issues.push(`memory directory scan failed (${memoryDir}): ${code ?? "error"}`);
issues.push(
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
);
dirReadable = null;
}
}
@@ -152,7 +166,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
}
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
issues.push(`no memory files found in ${workspaceDir}`);
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`);
}
return { source: "memory", totalFiles, issues };
@@ -294,8 +308,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
`${label("Indexed")} ${success(indexedLabel)}`,
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
`${label("Store")} ${info(status.dbPath)}`,
`${label("Workspace")} ${info(status.workspaceDir)}`,
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
`${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`,
].filter(Boolean) as string[];
if (embeddingProbe) {
const state = embeddingProbe.ok ? "ready" : "unavailable";
@@ -340,7 +354,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
}
if (status.vector.extensionPath) {
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`);
}
if (status.vector.loadError) {
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
@@ -594,7 +608,7 @@ export function registerMemoryCli(program: Command) {
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
rich,
theme.accent,
`${result.path}:${result.startLine}-${result.endLine}`,
`${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`,
)}`,
);
lines.push(colorize(rich, theme.muted, result.snippet));

View File

@@ -13,6 +13,7 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
import { shortenHomePath } from "../../utils.js";
const parseFacing = (value: string): CameraFacing => {
const v = String(value ?? "")
@@ -165,7 +166,7 @@ export function registerNodesCameraCommands(nodes: Command) {
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
return;
}
defaultRuntime.log(results.map((r) => `MEDIA:${r.path}`).join("\n"));
defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n"));
});
}),
{ timeoutMs: 60_000 },
@@ -239,7 +240,7 @@ export function registerNodesCameraCommands(nodes: Command) {
);
return;
}
defaultRuntime.log(`MEDIA:${filePath}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
});
}),
{ timeoutMs: 90_000 },

View File

@@ -9,6 +9,7 @@ import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { shortenHomePath } from "../../utils.js";
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
@@ -85,7 +86,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
);
return;
}
defaultRuntime.log(`MEDIA:${filePath}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
});
}),
{ timeoutMs: 60_000 },

View File

@@ -10,6 +10,7 @@ import { parseDurationMs } from "../parse-duration.js";
import { runNodesCommand } from "./cli-utils.js";
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { shortenHomePath } from "../../utils.js";
export function registerNodesScreenCommands(nodes: Command) {
const screen = nodes
@@ -77,7 +78,7 @@ export function registerNodesScreenCommands(nodes: Command) {
);
return;
}
defaultRuntime.log(`MEDIA:${written.path}`);
defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`);
});
}),
{ timeoutMs: 180_000 },

View File

@@ -6,6 +6,7 @@ import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
import type { NodesRpcOpts } from "./types.js";
import { renderTable } from "../../terminal/table.js";
import { parseDurationMs } from "../parse-duration.js";
import { shortenHomeInString } from "../../utils.js";
function formatVersionLabel(raw: string) {
const trimmed = raw.trim();
@@ -49,8 +50,9 @@ function formatPathEnv(raw?: string): string | null {
const trimmed = raw.trim();
if (!trimmed) return null;
const parts = trimmed.split(":").filter(Boolean);
if (parts.length <= 3) return trimmed;
return `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
const display =
parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
return shortenHomeInString(display);
}
function parseSinceMs(raw: unknown, label: string): number | undefined {

View File

@@ -15,7 +15,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { resolveUserPath } from "../utils.js";
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
export type PluginsListOptions = {
json?: boolean;
@@ -55,7 +55,7 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
const parts = [
`${name}${idSuffix} ${status}`,
` source: ${theme.muted(plugin.source)}`,
` source: ${theme.muted(shortenHomeInString(plugin.source))}`,
` origin: ${plugin.origin}`,
];
if (plugin.version) parts.push(` version: ${plugin.version}`);
@@ -135,19 +135,22 @@ export function registerPluginsCli(program: Command) {
if (!opts.verbose) {
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
const rows = list.map((plugin) => ({
Name: plugin.name || plugin.id,
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
Status:
plugin.status === "loaded"
? theme.success("loaded")
: plugin.status === "disabled"
? theme.warn("disabled")
: theme.error("error"),
Source: plugin.source,
Version: plugin.version ?? "",
Description: plugin.description ?? "",
}));
const rows = list.map((plugin) => {
const desc = plugin.description ? theme.muted(plugin.description) : "";
const sourceLine = desc ? `${plugin.source}\n${desc}` : plugin.source;
return {
Name: plugin.name || plugin.id,
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
Status:
plugin.status === "loaded"
? theme.success("loaded")
: plugin.status === "disabled"
? theme.warn("disabled")
: theme.error("error"),
Source: sourceLine,
Version: plugin.version ?? "",
};
});
defaultRuntime.log(
renderTable({
width: tableWidth,
@@ -155,9 +158,8 @@ export function registerPluginsCli(program: Command) {
{ key: "Name", header: "Name", minWidth: 14, flex: true },
{ key: "ID", header: "ID", minWidth: 10, flex: true },
{ key: "Status", header: "Status", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 10 },
{ key: "Source", header: "Source", minWidth: 26, flex: true },
{ key: "Version", header: "Version", minWidth: 8 },
{ key: "Description", header: "Description", minWidth: 18, flex: true },
],
rows,
}).trimEnd(),
@@ -201,7 +203,7 @@ export function registerPluginsCli(program: Command) {
if (plugin.description) lines.push(plugin.description);
lines.push("");
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
lines.push(`${theme.muted("Source:")} ${plugin.source}`);
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`);
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
if (plugin.toolNames.length > 0) {
@@ -227,9 +229,10 @@ export function registerPluginsCli(program: Command) {
lines.push("");
lines.push(`${theme.muted("Install:")} ${install.source}`);
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
if (install.sourcePath)
lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`);
if (install.installPath)
lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`);
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
if (install.installedAt)
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
@@ -333,7 +336,7 @@ export function registerPluginsCli(program: Command) {
next = slotResult.config;
await writeConfigFile(next);
logSlotWarnings(slotResult.warnings);
defaultRuntime.log(`Linked plugin path: ${resolved}`);
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`);
defaultRuntime.log(`Restart the gateway to load plugins.`);
return;
}

View File

@@ -3,6 +3,7 @@ import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-fl
import { colorize, isRich, theme } from "../../terminal/theme.js";
import type { RuntimeEnv } from "../../runtime.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_GATEWAY_SUBCOMMANDS = new Set([
@@ -60,7 +61,7 @@ export async function ensureConfigReady(params: {
const commandText = (value: string) => colorize(rich, theme.command, value);
params.runtime.error(heading("Config invalid"));
params.runtime.error(`${muted("File:")} ${muted(snapshot.path)}`);
params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`);
if (issues.length > 0) {
params.runtime.error(muted("Problem:"));
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));

View File

@@ -6,6 +6,7 @@ import { runSecurityAudit } from "../security/audit.js";
import { fixSecurityFootguns } from "../security/fix.js";
import { formatDocsLink } from "../terminal/links.js";
import { isRich, theme } from "../terminal/theme.js";
import { shortenHomeInString, shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
type SecurityAuditOptions = {
@@ -83,18 +84,24 @@ export function registerSecurityCli(program: Command) {
lines.push("");
lines.push(heading("FIX"));
for (const change of fixResult.changes) {
lines.push(muted(` ${change}`));
lines.push(muted(` ${shortenHomeInString(change)}`));
}
for (const action of fixResult.actions) {
const mode = action.mode.toString(8).padStart(3, "0");
if (action.ok) lines.push(muted(` chmod ${mode} ${action.path}`));
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
else if (action.skipped)
lines.push(muted(` skip chmod ${mode} ${action.path} (${action.skipped})`));
lines.push(
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
);
else if (action.error)
lines.push(muted(` chmod ${mode} ${action.path} failed: ${action.error}`));
lines.push(
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
);
}
if (fixResult.errors.length > 0) {
for (const err of fixResult.errors) lines.push(muted(` error: ${err}`));
for (const err of fixResult.errors) {
lines.push(muted(` error: ${shortenHomeInString(err)}`));
}
}
}
}

View File

@@ -10,6 +10,7 @@ import { defaultRuntime } from "../runtime.js";
import { formatDocsLink } from "../terminal/links.js";
import { renderTable } from "../terminal/table.js";
import { theme } from "../terminal/theme.js";
import { shortenHomePath } from "../utils.js";
import { formatCliCommand } from "./command-format.js";
export type SkillsListOptions = {
@@ -176,7 +177,7 @@ export function formatSkillInfo(
// Details
lines.push(theme.heading("Details:"));
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(skill.filePath)}`);
if (skill.homepage) {
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
}

View File

@@ -8,11 +8,12 @@ import {
} from "../agents/agent-scope.js";
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { createClackPrompter } from "../wizard/clack-prompter.js";
import { WizardCancelledError } from "../wizard/prompts.js";
import {
@@ -126,7 +127,7 @@ export async function agentsAddCommand(
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
await writeConfigFile(bindingResult.config);
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (!opts.json) logConfigUpdated(runtime);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
@@ -151,8 +152,8 @@ export async function agentsAddCommand(
runtime.log(JSON.stringify(payload, null, 2));
} else {
runtime.log(`Agent: ${agentId}`);
runtime.log(`Workspace: ${workspaceDir}`);
runtime.log(`Agent dir: ${agentDir}`);
runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
runtime.log(`Agent dir: ${shortenHomePath(agentDir)}`);
if (model) runtime.log(`Model: ${model}`);
if (bindingResult.conflicts.length > 0) {
runtime.error(
@@ -334,7 +335,7 @@ export async function agentsAddCommand(
}
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
agentId,

View File

@@ -1,5 +1,6 @@
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -69,7 +70,7 @@ export async function agentsDeleteCommand(
const result = pruneAgentConfig(cfg, agentId);
await writeConfigFile(result.config);
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
if (!opts.json) logConfigUpdated(runtime);
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
await moveToTrash(workspaceDir, quietRuntime);

View File

@@ -4,12 +4,13 @@ import path from "node:path";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
import { writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import type { IdentityConfig } from "../config/types.js";
import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { resolveUserPath } from "../utils.js";
import { resolveUserPath, shortenHomePath } from "../utils.js";
import { requireValidConfig } from "./agents.command-shared.js";
import {
type AgentIdentity,
@@ -105,14 +106,14 @@ export async function agentsSetIdentityCommand(
const matches = resolveAgentIdByWorkspace(cfg, workspaceDir);
if (matches.length === 0) {
runtime.error(
`No agent workspace matches ${workspaceDir}. Pass --agent to target a specific agent.`,
`No agent workspace matches ${shortenHomePath(workspaceDir)}. Pass --agent to target a specific agent.`,
);
runtime.exit(1);
return;
}
if (matches.length > 1) {
runtime.error(
`Multiple agents match ${workspaceDir}: ${matches.join(", ")}. Pass --agent to choose one.`,
`Multiple agents match ${shortenHomePath(workspaceDir)}: ${matches.join(", ")}. Pass --agent to choose one.`,
);
runtime.exit(1);
return;
@@ -131,7 +132,7 @@ export async function agentsSetIdentityCommand(
const targetPath =
identityFilePath ??
(workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md");
runtime.error(`No identity data found in ${targetPath}.`);
runtime.error(`No identity data found in ${shortenHomePath(targetPath)}.`);
runtime.exit(1);
return;
}
@@ -211,11 +212,11 @@ export async function agentsSetIdentityCommand(
return;
}
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Agent: ${agentId}`);
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
if (workspaceDir) runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
}

View File

@@ -3,6 +3,7 @@ import { normalizeAgentId } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { formatCliCommand } from "../cli/command-format.js";
import { shortenHomePath } from "../utils.js";
import { describeBinding } from "./agents.bindings.js";
import { requireValidConfig } from "./agents.command-shared.js";
import type { AgentSummary } from "./agents.config.js";
@@ -40,8 +41,8 @@ function formatSummary(summary: AgentSummary) {
if (identityLine) {
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
}
lines.push(` Workspace: ${summary.workspace}`);
lines.push(` Agent dir: ${summary.agentDir}`);
lines.push(` Workspace: ${shortenHomePath(summary.workspace)}`);
lines.push(` Agent dir: ${shortenHomePath(summary.agentDir)}`);
if (summary.model) lines.push(` Model: ${summary.model}`);
lines.push(` Routing rules: ${summary.bindings}`);

View File

@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
nextConfig = applyAuthProfileConfig(nextConfig, {
profileId: "github-copilot:github",
provider: "github-copilot",
mode: "token",
mode: "oauth",
});
if (params.setDefaultModel) {

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
import type { ClawdbotConfig } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { resolveHomeDir, resolveUserPath } from "../utils.js";
import { resolveHomeDir, resolveUserPath, shortenHomeInString } from "../utils.js";
export type RemovalResult = {
ok: boolean;
@@ -53,20 +53,21 @@ export async function removePath(
if (!target?.trim()) return { ok: false, skipped: true };
const resolved = path.resolve(target);
const label = opts?.label ?? resolved;
const displayLabel = shortenHomeInString(label);
if (isUnsafeRemovalTarget(resolved)) {
runtime.error(`Refusing to remove unsafe path: ${label}`);
runtime.error(`Refusing to remove unsafe path: ${displayLabel}`);
return { ok: false };
}
if (opts?.dryRun) {
runtime.log(`[dry-run] remove ${label}`);
runtime.log(`[dry-run] remove ${displayLabel}`);
return { ok: true, skipped: true };
}
try {
await fs.rm(resolved, { recursive: true, force: true });
runtime.log(`Removed ${label}`);
runtime.log(`Removed ${displayLabel}`);
return { ok: true };
} catch (err) {
runtime.error(`Failed to remove ${label}: ${String(err)}`);
runtime.error(`Failed to remove ${displayLabel}: ${String(err)}`);
return { ok: false };
}
}

View File

@@ -4,6 +4,7 @@ import type { ClawdbotConfig } from "../config/config.js";
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
import type { RuntimeEnv } from "../runtime.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
import { confirm, select } from "./configure.shared.js";
import { guardCancel } from "./onboard-helpers.js";
@@ -51,7 +52,7 @@ export async function removeChannelConfigWizard(
const label = getChannelPlugin(channel)?.meta.label ?? channel;
const confirmed = guardCancel(
await confirm({
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}?`,
initialValue: false,
}),
runtime,

View File

@@ -1,11 +1,7 @@
import { formatCliCommand } from "../cli/command-format.js";
import type { ClawdbotConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDBOT,
readConfigFileSnapshot,
resolveGatewayPort,
writeConfigFile,
} from "../config/config.js";
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
import { logConfigUpdated } from "../config/logging.js";
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
@@ -253,7 +249,7 @@ export async function runConfigureWizard(
mode,
});
await writeConfigFile(remoteConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
outro("Remote gateway configured.");
return;
}
@@ -286,7 +282,7 @@ export async function runConfigureWizard(
mode,
});
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
};
if (opts.sections) {

View File

@@ -6,6 +6,7 @@ import { promisify } from "node:util";
import type { ClawdbotConfig } from "../config/config.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
const execFileAsync = promisify(execFile);
@@ -19,10 +20,11 @@ export async function noteMacLaunchAgentOverrides() {
const hasMarker = fs.existsSync(markerPath);
if (!hasMarker) return;
const displayMarkerPath = shortenHomePath(markerPath);
const lines = [
`- LaunchAgent writes are disabled via ${markerPath}.`,
`- LaunchAgent writes are disabled via ${displayMarkerPath}.`,
"- To restore default behavior:",
` rm ${markerPath}`,
` rm ${displayMarkerPath}`,
].filter((line): line is string => Boolean(line));
note(lines.join("\n"), "Gateway (macOS)");
}

View File

@@ -13,6 +13,7 @@ import {
resolveStorePath,
} from "../config/sessions.js";
import { note } from "../terminal/note.js";
import { shortenHomePath } from "../utils.js";
type DoctorPrompterLike = {
confirmSkipInNonInteractive: (params: {
@@ -131,11 +132,16 @@ export async function noteStateIntegrity(
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
const storePath = resolveStorePath(cfg.session?.store, { agentId });
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);
if (!stateDirExists) {
warnings.push(
`- CRITICAL: state directory missing (${stateDir}). Sessions, credentials, logs, and config are stored there.`,
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`,
);
if (cfg.gateway?.mode === "remote") {
warnings.push(
@@ -143,26 +149,26 @@ export async function noteStateIntegrity(
);
}
const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${stateDir} now?`,
message: `Create ${displayStateDir} now?`,
initialValue: false,
});
if (create) {
const created = ensureDir(stateDir);
if (created.ok) {
changes.push(`- Created ${stateDir}`);
changes.push(`- Created ${displayStateDir}`);
stateDirExists = true;
} else {
warnings.push(`- Failed to create ${stateDir}: ${created.error}`);
warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`);
}
}
}
if (stateDirExists && !canWriteDir(stateDir)) {
warnings.push(`- State directory not writable (${stateDir}).`);
warnings.push(`- State directory not writable (${displayStateDir}).`);
const hint = dirPermissionHint(stateDir);
if (hint) warnings.push(` ${hint}`);
const repair = await prompter.confirmSkipInNonInteractive({
message: `Repair permissions on ${stateDir}?`,
message: `Repair permissions on ${displayStateDir}?`,
initialValue: true,
});
if (repair) {
@@ -170,9 +176,9 @@ export async function noteStateIntegrity(
const stat = fs.statSync(stateDir);
const target = addUserRwx(stat.mode);
fs.chmodSync(stateDir, target);
changes.push(`- Repaired permissions on ${stateDir}`);
changes.push(`- Repaired permissions on ${displayStateDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${stateDir}: ${String(err)}`);
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`);
}
}
}
@@ -181,19 +187,19 @@ export async function noteStateIntegrity(
const stat = fs.statSync(stateDir);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${stateDir} to 700?`,
message: `Tighten permissions on ${displayStateDir} to 700?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(stateDir, 0o700);
changes.push(`- Tightened permissions on ${stateDir} to 700`);
changes.push(`- Tightened permissions on ${displayStateDir} to 700`);
}
}
} catch (err) {
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`);
}
}
@@ -202,19 +208,21 @@ export async function noteStateIntegrity(
const stat = fs.statSync(configPath);
if ((stat.mode & 0o077) !== 0) {
warnings.push(
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`,
);
const tighten = await prompter.confirmSkipInNonInteractive({
message: `Tighten permissions on ${configPath} to 600?`,
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`,
initialValue: true,
});
if (tighten) {
fs.chmodSync(configPath, 0o600);
changes.push(`- Tightened permissions on ${configPath} to 600`);
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`);
}
}
} catch (err) {
warnings.push(`- Failed to read config permissions (${configPath}): ${String(err)}`);
warnings.push(
`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`,
);
}
}
@@ -223,26 +231,33 @@ export async function noteStateIntegrity(
dirCandidates.set(sessionsDir, "Sessions dir");
dirCandidates.set(storeDir, "Session store 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) {
const displayDir = displayDirFor(dir);
if (!existsDir(dir)) {
warnings.push(`- CRITICAL: ${label} missing (${dir}).`);
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`);
const create = await prompter.confirmSkipInNonInteractive({
message: `Create ${label} at ${dir}?`,
message: `Create ${label} at ${displayDir}?`,
initialValue: true,
});
if (create) {
const created = ensureDir(dir);
if (created.ok) {
changes.push(`- Created ${label}: ${dir}`);
changes.push(`- Created ${label}: ${displayDir}`);
} else {
warnings.push(`- Failed to create ${dir}: ${created.error}`);
warnings.push(`- Failed to create ${displayDir}: ${created.error}`);
}
}
continue;
}
if (!canWriteDir(dir)) {
warnings.push(`- ${label} not writable (${dir}).`);
warnings.push(`- ${label} not writable (${displayDir}).`);
const hint = dirPermissionHint(dir);
if (hint) warnings.push(` ${hint}`);
const repair = await prompter.confirmSkipInNonInteractive({
@@ -254,9 +269,9 @@ export async function noteStateIntegrity(
const stat = fs.statSync(dir);
const target = addUserRwx(stat.mode);
fs.chmodSync(dir, target);
changes.push(`- Repaired permissions on ${label}: ${dir}`);
changes.push(`- Repaired permissions on ${label}: ${displayDir}`);
} catch (err) {
warnings.push(`- Failed to repair ${dir}: ${String(err)}`);
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`);
}
}
}
@@ -274,8 +289,8 @@ export async function noteStateIntegrity(
warnings.push(
[
"- Multiple state directories detected. This can split session history.",
...Array.from(extraStateDirs).map((dir) => ` - ${dir}`),
` Active state dir: ${stateDir}`,
...Array.from(extraStateDirs).map((dir) => ` - ${shortenHomePath(dir)}`),
` Active state dir: ${displayStateDir}`,
].join("\n"),
);
}
@@ -311,7 +326,7 @@ export async function noteStateIntegrity(
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
if (!existsFile(transcriptPath)) {
warnings.push(
`- Main session transcript missing (${transcriptPath}). History will appear to reset.`,
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`,
);
} else {
const lineCount = countJsonlLines(transcriptPath);

View File

@@ -8,6 +8,7 @@ import {
DEFAULT_SOUL_FILENAME,
DEFAULT_USER_FILENAME,
} from "../agents/workspace.js";
import { shortenHomePath } from "../utils.js";
export const MEMORY_SYSTEM_PROMPT = [
"Memory system not found in workspace.",
@@ -80,8 +81,8 @@ export function detectLegacyWorkspaceDirs(params: {
export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string {
return [
"Extra workspace directories detected (may contain old agent files):",
...detection.legacyDirs.map((dir) => `- ${dir}`),
`Active workspace: ${detection.activeWorkspace}`,
...detection.legacyDirs.map((dir) => `- ${shortenHomePath(dir)}`),
`Active workspace: ${shortenHomePath(detection.activeWorkspace)}`,
"If unused, archive or move to Trash (e.g. trash ~/clawdbot).",
].join("\n");
}

View File

@@ -12,13 +12,16 @@ import {
import { formatCliCommand } from "../cli/command-format.js";
import type { ClawdbotConfig } 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 { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
import type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { note } from "../terminal/note.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { shortenHomePath } from "../utils.js";
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
@@ -111,10 +114,11 @@ export async function doctorCommand(
note(gatewayDetails.remoteFallbackNote, "Gateway");
}
if (resolveMode(cfg) === "local") {
const authMode = cfg.gateway?.auth?.mode;
const token =
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
const needsToken = authMode !== "password" && (authMode !== "token" || !token);
const auth = resolveGatewayAuth({
authConfig: cfg.gateway?.auth,
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
});
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
if (needsToken) {
note(
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
@@ -267,10 +271,10 @@ export async function doctorCommand(
if (shouldWriteConfig) {
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
await writeConfigFile(cfg);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`;
if (fs.existsSync(backupPath)) {
runtime.log(`Backup: ${backupPath}`);
runtime.log(`Backup: ${shortenHomePath(backupPath)}`);
}
} else {
runtime.log(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply changes.`);

View File

@@ -389,4 +389,39 @@ describe("doctor command", () => {
);
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);
});
});

View File

@@ -1,4 +1,5 @@
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
ensureFlagCompatibility,
@@ -74,7 +75,7 @@ export async function modelsAliasesAddCommand(
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
}
@@ -105,7 +106,7 @@ export async function modelsAliasesRemoveCommand(aliasRaw: string, runtime: Runt
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
if (
!updated.agents?.defaults?.models ||
Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim())

View File

@@ -16,11 +16,8 @@ import {
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
import { parseDurationMs } from "../../cli/parse-duration.js";
import { formatCliCommand } from "../../cli/command-format.js";
import {
CONFIG_PATH_CLAWDBOT,
readConfigFileSnapshot,
type ClawdbotConfig,
} from "../../config/config.js";
import { readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
import { applyAuthProfileConfig } from "../onboard-auth.js";
@@ -117,7 +114,7 @@ export async function modelsAuthSetupTokenCommand(
}),
);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
}
@@ -159,7 +156,7 @@ export async function modelsAuthPasteTokenCommand(
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
}
@@ -425,7 +422,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
return next;
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
for (const profile of result.profiles) {
runtime.log(
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,

View File

@@ -1,5 +1,6 @@
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
DEFAULT_PROVIDER,
@@ -78,7 +79,7 @@ export async function modelsFallbacksAddCommand(modelRaw: string, runtime: Runti
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
}
@@ -124,7 +125,7 @@ export async function modelsFallbacksRemoveCommand(modelRaw: string, runtime: Ru
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
}
@@ -148,6 +149,6 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log("Fallback list cleared.");
}

View File

@@ -1,5 +1,6 @@
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
DEFAULT_PROVIDER,
@@ -78,7 +79,7 @@ export async function modelsImageFallbacksAddCommand(modelRaw: string, runtime:
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
);
@@ -126,7 +127,7 @@ export async function modelsImageFallbacksRemoveCommand(modelRaw: string, runtim
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
);
@@ -152,6 +153,6 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log("Image fallback list cleared.");
}

View File

@@ -250,7 +250,7 @@ export async function modelsStatusCommand(
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
runtime.log(
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`,
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, shortenHomePath(CONFIG_PATH_CLAWDBOT))}`,
);
runtime.log(
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(

View File

@@ -2,7 +2,8 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompt
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
import { withProgressTotals } from "../../cli/progress.js";
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
import { loadConfig } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import {
stylePromptHint,
@@ -343,7 +344,7 @@ export async function modelsScanCommand(
return;
}
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Fallbacks: ${selected.join(", ")}`);
if (selectedImages.length > 0) {
runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`);

View File

@@ -1,4 +1,4 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveModelTarget, updateConfig } from "./shared.js";
@@ -27,6 +27,6 @@ export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEn
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`);
}

View File

@@ -1,4 +1,4 @@
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveModelTarget, updateConfig } from "./shared.js";
@@ -27,6 +27,6 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
};
});
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`);
}

View File

@@ -18,7 +18,13 @@ import { runCommandWithTimeout } from "../process/exec.js";
import type { RuntimeEnv } from "../runtime.js";
import { stylePromptTitle } from "../terminal/prompt-style.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { CONFIG_DIR, resolveUserPath, sleep } from "../utils.js";
import {
CONFIG_DIR,
resolveUserPath,
shortenHomeInString,
shortenHomePath,
sleep,
} from "../utils.js";
import { VERSION } from "../version.js";
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
@@ -33,21 +39,21 @@ export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
export function summarizeExistingConfig(config: ClawdbotConfig): string {
const rows: string[] = [];
const defaults = config.agents?.defaults;
if (defaults?.workspace) rows.push(`workspace: ${defaults.workspace}`);
if (defaults?.workspace) rows.push(shortenHomeInString(`workspace: ${defaults.workspace}`));
if (defaults?.model) {
const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary;
if (model) rows.push(`model: ${model}`);
if (model) rows.push(shortenHomeInString(`model: ${model}`));
}
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
if (config.gateway?.mode) rows.push(shortenHomeInString(`gateway.mode: ${config.gateway.mode}`));
if (typeof config.gateway?.port === "number") {
rows.push(`gateway.port: ${config.gateway.port}`);
rows.push(shortenHomeInString(`gateway.port: ${config.gateway.port}`));
}
if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`);
if (config.gateway?.bind) rows.push(shortenHomeInString(`gateway.bind: ${config.gateway.bind}`));
if (config.gateway?.remote?.url) {
rows.push(`gateway.remote.url: ${config.gateway.remote.url}`);
rows.push(shortenHomeInString(`gateway.remote.url: ${config.gateway.remote.url}`));
}
if (config.skills?.install?.nodeManager) {
rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`);
rows.push(shortenHomeInString(`skills.nodeManager: ${config.skills.install.nodeManager}`));
}
return rows.length ? rows.join("\n") : "No key settings detected.";
}
@@ -211,6 +217,19 @@ 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(
workspaceDir: string,
runtime: RuntimeEnv,
@@ -220,10 +239,10 @@ export async function ensureWorkspaceAndSessions(
dir: workspaceDir,
ensureBootstrapFiles: !options?.skipBootstrap,
});
runtime.log(`Workspace OK: ${ws.dir}`);
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${sessionsDir}`);
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
}
export function resolveNodeManagerOptions(): Array<{
@@ -246,9 +265,9 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
}
try {
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
runtime.log(`Moved to Trash: ${pathname}`);
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
} catch {
runtime.log(`Failed to move to Trash (manual delete): ${pathname}`);
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
}
}

View File

@@ -1,213 +0,0 @@
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);
});

View File

@@ -3,7 +3,7 @@ import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import {
@@ -13,33 +13,32 @@ import {
} from "../infra/device-identity.js";
import { buildDeviceAuthPayload } from "../gateway/device-auth.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 { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
async function isPortFree(port: number): Promise<boolean> {
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
return await new Promise((resolve) => {
async function getFreePort(): Promise<number> {
return await new Promise((resolve, reject) => {
const srv = createServer();
srv.once("error", () => resolve(false));
srv.listen(port, "127.0.0.1", () => {
srv.close(() => resolve(true));
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 getFreeGatewayPort(): Promise<number> {
// 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");
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] });
}
async function onceMessage<T = unknown>(
@@ -121,47 +120,180 @@ async function connectReq(params: { url: string; token?: string }) {
return res;
}
describe("onboard (non-interactive): lan bind auto-token", () => {
const runtime = {
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 () => {
if (process.platform === "win32") {
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
return;
}
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");
const stateDir = await initStateDir("state-lan-");
process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
const port = await getFreeGatewayPort();
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 workspace = path.join(stateDir, "clawd");
// 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
@@ -226,14 +358,6 @@ describe("onboard (non-interactive): lan bind auto-token", () => {
await server.close({ reason: "lan auto-token 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;
await fs.rm(stateDir, { recursive: true, force: true });
}, 60_000);
});

View File

@@ -1,113 +0,0 @@
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);
});

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { CONFIG_PATH_CLAWDBOT, resolveGatewayPort, writeConfigFile } from "../../config/config.js";
import { resolveGatewayPort, writeConfigFile } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js";
@@ -74,7 +75,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),

View File

@@ -36,6 +36,7 @@ import {
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
import { resolveNonInteractiveApiKey } from "../api-keys.js";
import { shortenHomePath } from "../../../utils.js";
export async function applyNonInteractiveAuthChoice(params: {
nextConfig: ClawdbotConfig;
@@ -172,7 +173,7 @@ export async function applyNonInteractiveAuthChoice(params: {
const key = resolved.key;
const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: key });
process.env.OPENAI_API_KEY = key;
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
runtime.log(`Saved OPENAI_API_KEY to ${shortenHomePath(result.path)}`);
return nextConfig;
}

View File

@@ -1,5 +1,6 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
import { writeConfigFile } from "../../config/config.js";
import { logConfigUpdated } from "../../config/logging.js";
import type { RuntimeEnv } from "../../runtime.js";
import { formatCliCommand } from "../../cli/command-format.js";
import { applyWizardMetadata } from "../onboard-helpers.js";
@@ -33,7 +34,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
};
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
await writeConfigFile(nextConfig);
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
logConfigUpdated(runtime);
const payload = {
mode,

View File

@@ -4,9 +4,11 @@ import JSON5 from "json5";
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.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 type { RuntimeEnv } from "../runtime.js";
import { defaultRuntime } from "../runtime.js";
import { shortenHomePath } from "../utils.js";
async function readConfigFileRaw(): Promise<{
exists: boolean;
@@ -52,22 +54,22 @@ export async function setupCommand(
if (!existingRaw.exists || defaults.workspace !== workspace) {
await writeConfigFile(next);
runtime.log(
!existingRaw.exists
? `Wrote ${CONFIG_PATH_CLAWDBOT}`
: `Updated ${CONFIG_PATH_CLAWDBOT} (set agents.defaults.workspace)`,
);
if (!existingRaw.exists) {
runtime.log(`Wrote ${formatConfigPath()}`);
} else {
logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" });
}
} else {
runtime.log(`Config OK: ${CONFIG_PATH_CLAWDBOT}`);
runtime.log(`Config OK: ${formatConfigPath()}`);
}
const ws = await ensureAgentWorkspace({
dir: workspace,
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
});
runtime.log(`Workspace OK: ${ws.dir}`);
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
const sessionsDir = resolveSessionTranscriptsDir();
await fs.mkdir(sessionsDir, { recursive: true });
runtime.log(`Sessions OK: ${sessionsDir}`);
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
}

18
src/config/logging.ts Normal file
View File

@@ -0,0 +1,18 @@
import type { RuntimeEnv } from "../runtime.js";
import { displayPath } from "../utils.js";
import { CONFIG_PATH_CLAWDBOT } from "./paths.js";
type LogConfigUpdatedOptions = {
path?: string;
suffix?: string;
};
export function formatConfigPath(path: string = CONFIG_PATH_CLAWDBOT): string {
return displayPath(path);
}
export function logConfigUpdated(runtime: RuntimeEnv, opts: LogConfigUpdatedOptions = {}): void {
const path = formatConfigPath(opts.path ?? CONFIG_PATH_CLAWDBOT);
const suffix = opts.suffix ? ` ${opts.suffix}` : "";
runtime.log(`Updated ${path}${suffix}`);
}

View File

@@ -0,0 +1,275 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
connectDeviceAuthReq,
connectGatewayClient,
getFreeGatewayPort,
} from "./test-helpers.e2e.js";
import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js";
import { startGatewayServer } from "./server.js";
function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
describe("gateway e2e", () => {
it(
"runs a mock OpenAI tool call end-to-end via gateway agent loop",
{ timeout: 90_000 },
async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
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,
};
const { baseUrl: openaiBaseUrl, restore } = installOpenAiResponsesMock();
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-"));
process.env.HOME = tempHome;
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";
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const workspaceDir = path.join(tempHome, "clawd");
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const configDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "clawdbot.json");
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
models: {
mode: "replace",
providers: {
openai: {
baseUrl: openaiBaseUrl,
apiKey: "test",
api: "openai-responses",
models: [
{
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
},
],
},
},
},
gateway: { auth: { token } },
};
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.env.CLAWDBOT_CONFIG_PATH = configPath;
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
token,
clientDisplayName: "vitest-mock-openai",
});
try {
const sessionKey = "agent:dev:mock-openai";
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
});
const runId = randomUUID();
const payload = await client.request<{
status?: unknown;
result?: unknown;
}>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
`Call the read tool on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
expect(payload?.status).toBe("ok");
const text = extractPayloadText(payload?.result);
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
restore();
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
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;
}
},
);
it("runs wizard over ws and writes auth token config", { timeout: 90_000 }, async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
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,
};
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;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-wizard-home-"));
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "none" },
controlUiEnabled: false,
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.intro("Wizard E2E");
await prompter.note("write token");
const token = await prompter.text({ message: "token" });
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: { auth: { mode: "token", token: String(token) } },
});
await prompter.outro("ok");
},
});
const client = await connectGatewayClient({
url: `ws://127.0.0.1:${port}`,
clientDisplayName: "vitest-wizard",
});
try {
const start = await client.request<{
sessionId?: string;
done: boolean;
status: "running" | "done" | "cancelled" | "error";
step?: {
id: string;
type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress";
};
error?: string;
}>("wizard.start", { mode: "local" });
const sessionId = start.sessionId;
expect(typeof sessionId).toBe("string");
let next = start;
let didSendToken = false;
while (!next.done) {
const step = next.step;
if (!step) throw new Error("wizard missing step");
const value = step.type === "text" ? wizardToken : null;
if (step.type === "text") didSendToken = true;
next = await client.request("wizard.next", {
sessionId,
answer: { stepId: step.id, value },
});
}
expect(didSendToken).toBe(true);
expect(next.status).toBe("done");
const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown>
| undefined;
expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken);
} finally {
client.stop();
await server.close({ reason: "wizard e2e complete" });
}
const port2 = await getFreeGatewayPort();
const server2 = await startGatewayServer(port2, {
bind: "loopback",
controlUiEnabled: false,
});
try {
const resNoToken = await connectDeviceAuthReq({
url: `ws://127.0.0.1:${port2}`,
});
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectDeviceAuthReq({
url: `ws://127.0.0.1:${port2}`,
token: wizardToken,
});
expect(resToken.ok).toBe(true);
} finally {
await server2.close({ reason: "wizard auth verify" });
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_GATEWAY_TOKEN = prev.token;
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;
}
});
});

View File

@@ -1,367 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
import { GatewayClient } from "./client.js";
import { startGatewayServer } from "./server.js";
type OpenAIResponsesParams = {
input?: unknown[];
};
type OpenAIResponseStreamEvent =
| { type: "response.output_item.added"; item: Record<string, unknown> }
| { type: "response.function_call_arguments.delta"; delta: string }
| { type: "response.output_item.done"; item: Record<string, unknown> }
| {
type: "response.completed";
response: {
status: "completed";
usage: {
input_tokens: number;
output_tokens: number;
total_tokens: number;
input_tokens_details?: { cached_tokens?: number };
};
};
};
function extractLastUserText(input: unknown[]): string {
for (let i = input.length - 1; i >= 0; i -= 1) {
const item = input[i] as Record<string, unknown> | undefined;
if (!item || item.role !== "user") continue;
const content = item.content;
if (Array.isArray(content)) {
const text = content
.filter(
(c): c is { type: "input_text"; text: string } =>
!!c &&
typeof c === "object" &&
(c as { type?: unknown }).type === "input_text" &&
typeof (c as { text?: unknown }).text === "string",
)
.map((c) => c.text)
.join("\n")
.trim();
if (text) return text;
}
}
return "";
}
function extractToolOutput(input: unknown[]): string {
for (const itemRaw of input) {
const item = itemRaw as Record<string, unknown> | undefined;
if (!item || item.type !== "function_call_output") continue;
return typeof item.output === "string" ? item.output : "";
}
return "";
}
async function* fakeOpenAIResponsesStream(
params: OpenAIResponsesParams,
): AsyncGenerator<OpenAIResponseStreamEvent> {
const input = Array.isArray(params.input) ? params.input : [];
const toolOutput = extractToolOutput(input);
// Turn 1: return a tool call to `read`.
if (!toolOutput) {
const prompt = extractLastUserText(input);
const quoted = /"([^"]+)"/.exec(prompt)?.[1];
const toolPath = quoted ?? "package.json";
const argsJson = JSON.stringify({ path: toolPath });
yield {
type: "response.output_item.added",
item: {
type: "function_call",
id: "fc_test_1",
call_id: "call_test_1",
name: "read",
arguments: "",
},
};
yield { type: "response.function_call_arguments.delta", delta: argsJson };
yield {
type: "response.output_item.done",
item: {
type: "function_call",
id: "fc_test_1",
call_id: "call_test_1",
name: "read",
arguments: argsJson,
},
};
yield {
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
},
};
return;
}
// Turn 2: echo the nonces extracted from the Read tool output.
const nonceA = /nonceA=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
const nonceB = /nonceB=([^\s]+)/.exec(toolOutput)?.[1] ?? "";
const reply = `${nonceA} ${nonceB}`.trim();
yield {
type: "response.output_item.added",
item: {
type: "message",
id: "msg_test_1",
role: "assistant",
content: [],
status: "in_progress",
},
};
yield {
type: "response.output_item.done",
item: {
type: "message",
id: "msg_test_1",
role: "assistant",
status: "completed",
content: [{ type: "output_text", text: reply, annotations: [] }],
},
};
yield {
type: "response.completed",
response: {
status: "completed",
usage: { input_tokens: 10, output_tokens: 10, total_tokens: 20 },
},
};
}
function decodeBodyText(body: unknown): string {
if (!body) return "";
if (typeof body === "string") return body;
if (body instanceof Uint8Array) return Buffer.from(body).toString("utf8");
if (body instanceof ArrayBuffer) return Buffer.from(new Uint8Array(body)).toString("utf8");
return "";
}
async function buildOpenAIResponsesSse(params: OpenAIResponsesParams): Promise<Response> {
const events: OpenAIResponseStreamEvent[] = [];
for await (const event of fakeOpenAIResponsesStream(params)) {
events.push(event);
}
const sse = `${events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("")}data: [DONE]\n\n`;
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(sse));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { "content-type": "text/event-stream" },
});
}
async function getFreeGatewayPort(): Promise<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] });
}
function extractPayloadText(result: unknown): string {
const record = result as Record<string, unknown>;
const payloads = Array.isArray(record.payloads) ? record.payloads : [];
const texts = payloads
.map((p) => (p && typeof p === "object" ? (p as Record<string, unknown>).text : undefined))
.filter((t): t is string => typeof t === "string" && t.trim().length > 0);
return texts.join("\n").trim();
}
async function connectClient(params: { url: string; token: string }) {
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-mock-openai",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
}
describe("gateway (mock openai): tool calling", () => {
it("runs a Read tool call end-to-end via gateway agent loop", { timeout: 90_000 }, async () => {
const prev = {
home: process.env.HOME,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
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,
};
const originalFetch = globalThis.fetch;
const openaiBaseUrl = "https://api.openai.com/v1";
const openaiResponsesUrl = `${openaiBaseUrl}/responses`;
const isOpenAIResponsesRequest = (url: string) =>
url === openaiResponsesUrl ||
url.startsWith(`${openaiResponsesUrl}/`) ||
url.startsWith(`${openaiResponsesUrl}?`);
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (isOpenAIResponsesRequest(url)) {
const bodyText =
typeof (init as { body?: unknown } | undefined)?.body !== "undefined"
? decodeBodyText((init as { body?: unknown }).body)
: input instanceof Request
? await input.clone().text()
: "";
const parsed = bodyText ? (JSON.parse(bodyText) as Record<string, unknown>) : {};
const inputItems = Array.isArray(parsed.input) ? parsed.input : [];
return await buildOpenAIResponsesSse({ input: inputItems });
}
if (url.startsWith(openaiBaseUrl)) {
throw new Error(`unexpected OpenAI request in mock test: ${url}`);
}
if (!originalFetch) {
throw new Error(`fetch is not available (url=${url})`);
}
return await originalFetch(input, init);
};
// TypeScript: Bun's fetch typing includes extra properties; keep this test portable.
(globalThis as unknown as { fetch: unknown }).fetch = fetchImpl;
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-mock-home-"));
process.env.HOME = tempHome;
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";
const token = `test-${randomUUID()}`;
process.env.CLAWDBOT_GATEWAY_TOKEN = token;
const workspaceDir = path.join(tempHome, "clawd");
await fs.mkdir(workspaceDir, { recursive: true });
const nonceA = randomUUID();
const nonceB = randomUUID();
const toolProbePath = path.join(workspaceDir, `.clawdbot-tool-probe.${nonceA}.txt`);
await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
const configDir = path.join(tempHome, ".clawdbot");
await fs.mkdir(configDir, { recursive: true });
const configPath = path.join(configDir, "clawdbot.json");
const cfg = {
agents: { defaults: { workspace: workspaceDir } },
models: {
mode: "replace",
providers: {
openai: {
baseUrl: openaiBaseUrl,
apiKey: "test",
api: "openai-responses",
models: [
{
id: "gpt-5.2",
name: "gpt-5.2",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4096,
},
],
},
},
},
gateway: { auth: { token } },
};
await fs.writeFile(configPath, `${JSON.stringify(cfg, null, 2)}\n`);
process.env.CLAWDBOT_CONFIG_PATH = configPath;
const port = await getFreeGatewayPort();
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "token", token },
controlUiEnabled: false,
});
const client = await connectClient({
url: `ws://127.0.0.1:${port}`,
token,
});
try {
const sessionKey = "agent:dev:mock-openai";
await client.request<Record<string, unknown>>("sessions.patch", {
key: sessionKey,
model: "openai/gpt-5.2",
});
const runId = randomUUID();
const payload = await client.request<{
status?: unknown;
result?: unknown;
}>(
"agent",
{
sessionKey,
idempotencyKey: `idem-${runId}`,
message:
`Call the read tool on "${toolProbePath}". ` +
`Then reply with exactly: ${nonceA} ${nonceB}. No extra text.`,
deliver: false,
},
{ expectFinal: true },
);
expect(payload?.status).toBe("ok");
const text = extractPayloadText(payload?.result);
expect(text).toContain(nonceA);
expect(text).toContain(nonceB);
} finally {
client.stop();
await server.close({ reason: "mock openai test complete" });
await fs.rm(tempHome, { recursive: true, force: true });
(globalThis as unknown as { fetch: unknown }).fetch = originalFetch;
process.env.HOME = prev.home;
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
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;
}
});
});

View File

@@ -1,255 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
loadOrCreateDeviceIdentity,
publicKeyRawBase64UrlFromPem,
signDevicePayload,
} from "../infra/device-identity.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 { buildDeviceAuthPayload } from "./device-auth.js";
import { PROTOCOL_VERSION } from "./protocol/index.js";
async function getFreeGatewayPort(): Promise<number> {
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 3, 4] });
}
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;
}
async function connectClient(params: { url: string; token?: string }) {
const { GatewayClient } = await import("./client.js");
return await new Promise<InstanceType<typeof GatewayClient>>((resolve, reject) => {
let settled = false;
const stop = (err?: Error, client?: InstanceType<typeof GatewayClient>) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (err) reject(err);
else resolve(client as InstanceType<typeof GatewayClient>);
};
const client = new GatewayClient({
url: params.url,
token: params.token,
clientName: GATEWAY_CLIENT_NAMES.TEST,
clientDisplayName: "vitest-wizard",
clientVersion: "dev",
mode: GATEWAY_CLIENT_MODES.TEST,
onHelloOk: () => stop(undefined, client),
onConnectError: (err) => stop(err),
onClose: (code, reason) =>
stop(new Error(`gateway closed during connect (${code}): ${reason}`)),
});
const timer = setTimeout(() => stop(new Error("gateway connect timeout")), 10_000);
timer.unref();
client.start();
});
}
type WizardStep = {
id: string;
type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress";
};
type WizardNextPayload = {
sessionId?: string;
done: boolean;
status: "running" | "done" | "cancelled" | "error";
step?: WizardStep;
error?: string;
};
describe("gateway wizard (e2e)", () => {
it("runs wizard over ws and writes auth token config", async () => {
const prev = {
home: process.env.HOME,
stateDir: process.env.CLAWDBOT_STATE_DIR,
configPath: process.env.CLAWDBOT_CONFIG_PATH,
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
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,
};
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-wizard-home-"));
process.env.HOME = tempHome;
delete process.env.CLAWDBOT_STATE_DIR;
delete process.env.CLAWDBOT_CONFIG_PATH;
const wizardToken = `wiz-${randomUUID()}`;
const port = await getFreeGatewayPort();
const { startGatewayServer } = await import("./server.js");
const server = await startGatewayServer(port, {
bind: "loopback",
auth: { mode: "none" },
controlUiEnabled: false,
wizardRunner: async (_opts, _runtime, prompter) => {
await prompter.intro("Wizard E2E");
await prompter.note("write token");
const token = await prompter.text({ message: "token" });
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({
gateway: { auth: { mode: "token", token: String(token) } },
});
await prompter.outro("ok");
},
});
const client = await connectClient({ url: `ws://127.0.0.1:${port}` });
try {
const start = await client.request<WizardNextPayload>("wizard.start", {
mode: "local",
});
const sessionId = start.sessionId;
expect(typeof sessionId).toBe("string");
let next: WizardNextPayload = start;
let didSendToken = false;
while (!next.done) {
const step = next.step;
if (!step) throw new Error("wizard missing step");
const value = step.type === "text" ? wizardToken : null;
if (step.type === "text") didSendToken = true;
next = await client.request<WizardNextPayload>("wizard.next", {
sessionId,
answer: { stepId: step.id, value },
});
}
expect(didSendToken).toBe(true);
expect(next.status).toBe("done");
const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown>
| undefined;
expect((token?.auth as { token?: string } | undefined)?.token).toBe(wizardToken);
} finally {
client.stop();
await server.close({ reason: "wizard e2e complete" });
}
const port2 = await getFreeGatewayPort();
const { startGatewayServer: startGatewayServer2 } = await import("./server.js");
const server2 = await startGatewayServer2(port2, {
bind: "loopback",
controlUiEnabled: false,
});
try {
const resNoToken = await connectReq({
url: `ws://127.0.0.1:${port2}`,
});
expect(resNoToken.ok).toBe(false);
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
const resToken = await connectReq({
url: `ws://127.0.0.1:${port2}`,
token: wizardToken,
});
expect(resToken.ok).toBe(true);
} finally {
await server2.close({ reason: "wizard auth verify" });
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_GATEWAY_TOKEN = prev.token;
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;
}
}, 90_000);
});

View File

@@ -49,469 +49,372 @@ function parseSseDataLines(text: string): string[] {
}
describe("OpenAI-compatible HTTP API (e2e)", () => {
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
const port = await getFreePort();
const server = await startServerWithDefaultConfig(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
{
const port = await getFreePort();
const server = await startServerWithDefaultConfig(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
}
{
const port = await getFreePort();
const server = await startServer(port, {
openAiChatCompletionsEnabled: false,
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
}
});
it("can be disabled via config (404)", async () => {
const port = await getFreePort();
const server = await startServer(port, {
openAiChatCompletionsEnabled: false,
});
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(404);
} finally {
await server.close({ reason: "test done" });
}
});
it("rejects non-POST", async () => {
it("handles request validation and routing", async () => {
const port = await getFreePort();
const server = await startServer(port);
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads } as never);
};
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "GET",
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(405);
} finally {
await server.close({ reason: "test done" });
}
});
{
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "GET",
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(405);
await res.text();
}
it("rejects missing auth", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(res.status).toBe(401);
} finally {
await server.close({ reason: "test done" });
}
});
{
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
});
expect(res.status).toBe(401);
await res.text();
}
it("routes to a specific agent via header", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await res.text();
}
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("routes to a specific agent via model (no custom headers)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
it("prefers explicit header agent over model agent", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
},
{ "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
} finally {
await server.close({ reason: "test done" });
}
});
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await res.text();
}
it("honors x-clawdbot-session-key override", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{
"x-clawdbot-agent-id": "beta",
"x-clawdbot-session-key": "agent:beta:openai:custom",
},
);
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("uses OpenAI user for a stable session key", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("extracts user message text from array content", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
model: "clawdbot:beta",
messages: [{ role: "user", content: "hi" }],
},
],
});
expect(res.status).toBe(200);
{ "x-clawdbot-agent-id": "alpha" },
);
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:alpha:/,
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(
port,
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
{
"x-clawdbot-agent-id": "beta",
"x-clawdbot-session-key": "agent:beta:openai:custom",
},
);
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
"agent:beta:openai:custom",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
user: "alice",
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openai-user:alice",
);
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{
role: "user",
content: [
{ type: "text", text: "hello" },
{ type: "input_text", text: "world" },
],
},
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
await res.text();
}
{
mockAgentOnce([{ text: "I am Claude" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
await res.text();
}
{
mockAgentOnce([{ text: "ok" }]);
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
await res.text();
}
{
mockAgentOnce([{ text: "hello" }]);
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
}
{
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const missingUserJson = (await res.json()) as Record<string, unknown>;
expect((missingUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
}
} finally {
await server.close({ reason: "test done" });
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
it("streams SSE chunks when stream=true", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello, who are you?" },
{ role: "assistant", content: "I am Claude." },
{ role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
{
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
} finally {
await server.close({ reason: "test done" });
}
});
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
it("does not include history markers for single message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const text = await res.text();
const data = parseSseDataLines(text);
expect(data[data.length - 1]).toBe("[DONE]");
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");
}
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
expect(message).toBe("Hello");
} finally {
await server.close({ reason: "test done" });
}
});
{
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
it("treats developer role same as system role", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const repeatedRes = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(repeatedRes.status).toBe(200);
const repeatedText = await repeatedRes.text();
const repeatedData = parseSseDataLines(repeatedText);
const repeatedChunks = repeatedData
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const repeatedContent = repeatedChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(repeatedContent).toBe("hihi");
}
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "developer", content: "You are a helpful assistant." },
{ role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
{
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
} finally {
await server.close({ reason: "test done" });
}
});
it("includes tool output when it is the latest message", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "What's the weather?" },
{ role: "assistant", content: "Checking the weather." },
{ role: "tool", content: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: What's the weather?");
expect(message).toContain("Assistant: Checking the weather.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("Tool: Sunny, 70F.");
} finally {
await server.close({ reason: "test done" });
}
});
it("returns a non-streaming OpenAI chat.completion response", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: false,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("chat.completion");
expect(Array.isArray(json.choices)).toBe(true);
const choice0 = (json.choices as Array<Record<string, unknown>>)[0] ?? {};
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
expect(msg.role).toBe("assistant");
expect(msg.content).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("requires a user message", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
model: "clawdbot",
messages: [{ role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (delta events)", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
const text = await res.text();
const data = parseSseDataLines(text);
expect(data[data.length - 1]).toBe("[DONE]");
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
expect(jsonChunks.some((c) => c.object === "chat.completion.chunk")).toBe(true);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hello");
} finally {
await server.close({ reason: "test done" });
}
});
it("preserves repeated identical deltas when streaming SSE", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
return { payloads: [{ text: "hihi" }] } as never;
});
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
const data = parseSseDataLines(text);
const jsonChunks = data
.filter((d) => d !== "[DONE]")
.map((d) => JSON.parse(d) as Record<string, unknown>);
const allContent = jsonChunks
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
.filter((v): v is string => typeof v === "string")
.join("");
expect(allContent).toBe("hihi");
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("[DONE]");
expect(text).toContain("hello");
const fallbackRes = await postChatCompletions(port, {
stream: true,
model: "clawdbot",
messages: [{ role: "user", content: "hi" }],
});
expect(fallbackRes.status).toBe(200);
const fallbackText = await fallbackRes.text();
expect(fallbackText).toContain("[DONE]");
expect(fallbackText).toContain("hello");
}
} finally {
await server.close({ reason: "test done" });
}

View File

@@ -70,7 +70,7 @@ async function ensureResponseConsumed(res: Response) {
}
describe("OpenResponses HTTP API (e2e)", () => {
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
const port = await getFreePort();
const server = await startServerWithDefaultConfig(port);
try {
@@ -83,201 +83,112 @@ describe("OpenResponses HTTP API (e2e)", () => {
} finally {
await server.close({ reason: "test done" });
}
});
it("can be disabled via config (404)", async () => {
const port = await getFreePort();
const server = await startServer(port, {
const disabledPort = await getFreePort();
const disabledServer = await startServer(disabledPort, {
openResponsesEnabled: false,
});
try {
const res = await postResponses(port, {
const res = await postResponses(disabledPort, {
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(404);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
await disabledServer.close({ reason: "test done" });
}
});
it("rejects non-POST", async () => {
it("handles OpenResponses request parsing and validation", async () => {
const port = await getFreePort();
const server = await startServer(port);
const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => {
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
};
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
method: "GET",
headers: { authorization: "Bearer secret" },
});
expect(res.status).toBe(405);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(resNonPost.status).toBe(405);
await ensureResponseConsumed(resNonPost);
it("rejects missing auth", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
});
expect(res.status).toBe(401);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(resMissingAuth.status).toBe(401);
await ensureResponseConsumed(resMissingAuth);
it("rejects invalid request body (missing model)", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, { input: "hi" });
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
const resMissingModel = await postResponses(port, { input: "hi" });
expect(resMissingModel.status).toBe(400);
const missingModelJson = (await resMissingModel.json()) as Record<string, unknown>;
expect((missingModelJson.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resMissingModel);
it("routes to a specific agent via header", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(
mockAgentOnce([{ text: "hello" }]);
const resHeader = await postResponses(
port,
{ model: "clawdbot", input: "hi" },
{ "x-clawdbot-agent-id": "beta" },
);
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
expect(resHeader.status).toBe(200);
const [optsHeader] = agentCommand.mock.calls[0] ?? [];
expect((optsHeader as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resHeader);
it("routes to a specific agent via model (no custom headers)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
model: "clawdbot:beta",
input: "hi",
});
expect(res.status).toBe(200);
expect(agentCommand).toHaveBeenCalledTimes(1);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
mockAgentOnce([{ text: "hello" }]);
const resModel = await postResponses(port, { model: "clawdbot:beta", input: "hi" });
expect(resModel.status).toBe(200);
const [optsModel] = agentCommand.mock.calls[0] ?? [];
expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
/^agent:beta:/,
);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resModel);
it("uses OpenResponses user for a stable session key", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resUser = await postResponses(port, {
user: "alice",
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
expect(resUser.status).toBe(200);
const [optsUser] = agentCommand.mock.calls[0] ?? [];
expect((optsUser as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
"openresponses-user:alice",
);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resUser);
it("accepts string input", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resString = await postResponses(port, {
model: "clawdbot",
input: "hello world",
});
expect(res.status).toBe(200);
expect(resString.status).toBe(200);
const [optsString] = agentCommand.mock.calls[0] ?? [];
expect((optsString as { message?: string } | undefined)?.message).toBe("hello world");
await ensureResponseConsumed(resString);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello world");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("accepts array input with message items", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resArray = await postResponses(port, {
model: "clawdbot",
input: [{ type: "message", role: "user", content: "hello there" }],
});
expect(res.status).toBe(200);
expect(resArray.status).toBe(200);
const [optsArray] = agentCommand.mock.calls[0] ?? [];
expect((optsArray as { message?: string } | undefined)?.message).toBe("hello there");
await ensureResponseConsumed(resArray);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { message?: string } | undefined)?.message).toBe("hello there");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("extracts system and developer messages as extraSystemPrompt", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resSystemDeveloper = await postResponses(port, {
model: "clawdbot",
input: [
{ type: "message", role: "system", content: "You are a helpful assistant." },
@@ -285,53 +196,30 @@ describe("OpenResponses HTTP API (e2e)", () => {
{ type: "message", role: "user", content: "Hello" },
],
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(resSystemDeveloper.status).toBe(200);
const [optsSystemDeveloper] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
(optsSystemDeveloper as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ??
"";
expect(extraSystemPrompt).toContain("You are a helpful assistant.");
expect(extraSystemPrompt).toContain("Be concise.");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resSystemDeveloper);
it("includes instructions in extraSystemPrompt", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resInstructions = await postResponses(port, {
model: "clawdbot",
input: "hi",
instructions: "Always respond in French.",
});
expect(res.status).toBe(200);
expect(resInstructions.status).toBe(200);
const [optsInstructions] = agentCommand.mock.calls[0] ?? [];
const instructionPrompt =
(optsInstructions as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(instructionPrompt).toContain("Always respond in French.");
await ensureResponseConsumed(resInstructions);
const [opts] = agentCommand.mock.calls[0] ?? [];
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(extraSystemPrompt).toContain("Always respond in French.");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("includes conversation history when multiple messages are provided", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "I am Claude" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "I am Claude" }]);
const resHistory = await postResponses(port, {
model: "clawdbot",
input: [
{ type: "message", role: "system", content: "You are a helpful assistant." },
@@ -340,56 +228,33 @@ describe("OpenResponses HTTP API (e2e)", () => {
{ type: "message", role: "user", content: "What did I just ask you?" },
],
});
expect(res.status).toBe(200);
expect(resHistory.status).toBe(200);
const [optsHistory] = agentCommand.mock.calls[0] ?? [];
const historyMessage = (optsHistory as { message?: string } | undefined)?.message ?? "";
expect(historyMessage).toContain(HISTORY_CONTEXT_MARKER);
expect(historyMessage).toContain("User: Hello, who are you?");
expect(historyMessage).toContain("Assistant: I am Claude.");
expect(historyMessage).toContain(CURRENT_MESSAGE_MARKER);
expect(historyMessage).toContain("User: What did I just ask you?");
await ensureResponseConsumed(resHistory);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain(HISTORY_CONTEXT_MARKER);
expect(message).toContain("User: Hello, who are you?");
expect(message).toContain("Assistant: I am Claude.");
expect(message).toContain(CURRENT_MESSAGE_MARKER);
expect(message).toContain("User: What did I just ask you?");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("includes function_call_output when it is the latest item", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "ok" }]);
const resFunctionOutput = await postResponses(port, {
model: "clawdbot",
input: [
{ type: "message", role: "user", content: "What's the weather?" },
{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." },
],
});
expect(res.status).toBe(200);
expect(resFunctionOutput.status).toBe(200);
const [optsFunctionOutput] = agentCommand.mock.calls[0] ?? [];
const functionOutputMessage =
(optsFunctionOutput as { message?: string } | undefined)?.message ?? "";
expect(functionOutputMessage).toContain("Sunny, 70F.");
await ensureResponseConsumed(resFunctionOutput);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
expect(message).toContain("Sunny, 70F.");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("moves input_file content into extraSystemPrompt", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "ok" }]);
const resInputFile = await postResponses(port, {
model: "clawdbot",
input: [
{
@@ -410,29 +275,17 @@ describe("OpenResponses HTTP API (e2e)", () => {
},
],
});
expect(res.status).toBe(200);
expect(resInputFile.status).toBe(200);
const [optsInputFile] = agentCommand.mock.calls[0] ?? [];
const inputFileMessage = (optsInputFile as { message?: string } | undefined)?.message ?? "";
const inputFilePrompt =
(optsInputFile as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(inputFileMessage).toBe("read this");
expect(inputFilePrompt).toContain('<file name="hello.txt">');
await ensureResponseConsumed(resInputFile);
const [opts] = agentCommand.mock.calls[0] ?? [];
const message = (opts as { message?: string } | undefined)?.message ?? "";
const extraSystemPrompt =
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
expect(message).toBe("read this");
expect(extraSystemPrompt).toContain('<file name="hello.txt">');
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("applies tool_choice=none by dropping tools", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "ok" }]);
const resToolNone = await postResponses(port, {
model: "clawdbot",
input: "hi",
tools: [
@@ -443,25 +296,15 @@ describe("OpenResponses HTTP API (e2e)", () => {
],
tool_choice: "none",
});
expect(res.status).toBe(200);
expect(resToolNone.status).toBe(200);
const [optsToolNone] = agentCommand.mock.calls[0] ?? [];
expect(
(optsToolNone as { clientTools?: unknown[] } | undefined)?.clientTools,
).toBeUndefined();
await ensureResponseConsumed(resToolNone);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect((opts as { clientTools?: unknown[] } | undefined)?.clientTools).toBeUndefined();
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
it("applies tool_choice to a specific tool", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "ok" }]);
const resToolChoice = await postResponses(port, {
model: "clawdbot",
input: "hi",
tools: [
@@ -476,24 +319,16 @@ describe("OpenResponses HTTP API (e2e)", () => {
],
tool_choice: { type: "function", function: { name: "get_time" } },
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(resToolChoice.status).toBe(200);
const [optsToolChoice] = agentCommand.mock.calls[0] ?? [];
const clientTools =
(opts as { clientTools?: Array<{ function?: { name?: string } }> })?.clientTools ?? [];
(optsToolChoice as { clientTools?: Array<{ function?: { name?: string } }> })
?.clientTools ?? [];
expect(clientTools).toHaveLength(1);
expect(clientTools[0]?.function?.name).toBe("get_time");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resToolChoice);
it("rejects tool_choice that references an unknown tool", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
const resUnknownTool = await postResponses(port, {
model: "clawdbot",
input: "hi",
tools: [
@@ -504,85 +339,51 @@ describe("OpenResponses HTTP API (e2e)", () => {
],
tool_choice: { type: "function", function: { name: "unknown_tool" } },
});
expect(res.status).toBe(400);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(resUnknownTool.status).toBe(400);
await ensureResponseConsumed(resUnknownTool);
it("passes max_output_tokens through to the agent stream params", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "ok" }]);
const resMaxTokens = await postResponses(port, {
model: "clawdbot",
input: "hi",
max_output_tokens: 123,
});
expect(res.status).toBe(200);
const [opts] = agentCommand.mock.calls[0] ?? [];
expect(resMaxTokens.status).toBe(200);
const [optsMaxTokens] = agentCommand.mock.calls[0] ?? [];
expect(
(opts as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams?.maxTokens,
(optsMaxTokens as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams
?.maxTokens,
).toBe(123);
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resMaxTokens);
it("returns usage when available", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "ok" }],
meta: {
mockAgentOnce([{ text: "ok" }], {
agentMeta: {
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
},
},
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
});
const resUsage = await postResponses(port, {
stream: false,
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(resUsage.status).toBe(200);
const usageJson = (await resUsage.json()) as Record<string, unknown>;
expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
await ensureResponseConsumed(resUsage);
it("returns a non-streaming response with correct shape", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
mockAgentOnce([{ text: "hello" }]);
const resShape = await postResponses(port, {
stream: false,
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
const json = (await res.json()) as Record<string, unknown>;
expect(json.object).toBe("response");
expect(json.status).toBe("completed");
expect(Array.isArray(json.output)).toBe(true);
expect(resShape.status).toBe(200);
const shapeJson = (await resShape.json()) as Record<string, unknown>;
expect(shapeJson.object).toBe("response");
expect(shapeJson.status).toBe("completed");
expect(Array.isArray(shapeJson.output)).toBe(true);
const output = json.output as Array<Record<string, unknown>>;
const output = shapeJson.output as Array<Record<string, unknown>>;
expect(output.length).toBe(1);
const item = output[0] ?? {};
expect(item.type).toBe("message");
@@ -592,55 +393,48 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(content.length).toBe(1);
expect(content[0]?.type).toBe("output_text");
expect(content[0]?.text).toBe("hello");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
await ensureResponseConsumed(resShape);
it("requires a user message in input", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
const resNoUser = await postResponses(port, {
model: "clawdbot",
input: [{ type: "message", role: "system", content: "yo" }],
});
expect(res.status).toBe(400);
const json = (await res.json()) as Record<string, unknown>;
expect((json.error as Record<string, unknown> | undefined)?.type).toBe(
expect(resNoUser.status).toBe(400);
const noUserJson = (await resNoUser.json()) as Record<string, unknown>;
expect((noUserJson.error as Record<string, unknown> | undefined)?.type).toBe(
"invalid_request_error",
);
await ensureResponseConsumed(res);
await ensureResponseConsumed(resNoUser);
} finally {
await server.close({ reason: "test done" });
}
});
it("streams SSE events when stream=true (delta events)", async () => {
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
it("streams OpenResponses SSE events", async () => {
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
agentCommand.mockReset();
agentCommand.mockImplementationOnce(async (opts: unknown) => {
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
emitAgentEvent({ runId, stream: "assistant", data: { delta: "llo" } });
return { payloads: [{ text: "hello" }] } as never;
});
const resDelta = await postResponses(port, {
stream: true,
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
expect(resDelta.status).toBe(200);
expect(resDelta.headers.get("content-type") ?? "").toContain("text/event-stream");
const text = await res.text();
const events = parseSseEvents(text);
const deltaText = await resDelta.text();
const deltaEvents = parseSseEvents(deltaText);
// Check for required event types
const eventTypes = events.map((e) => e.event).filter(Boolean);
const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean);
expect(eventTypes).toContain("response.created");
expect(eventTypes).toContain("response.output_item.added");
expect(eventTypes).toContain("response.in_progress");
@@ -649,72 +443,51 @@ describe("OpenResponses HTTP API (e2e)", () => {
expect(eventTypes).toContain("response.output_text.done");
expect(eventTypes).toContain("response.content_part.done");
expect(eventTypes).toContain("response.completed");
expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true);
// Check for [DONE] terminal event
expect(events.some((e) => e.data === "[DONE]")).toBe(true);
// Verify delta content
const deltaEvents = events.filter((e) => e.event === "response.output_text.delta");
const allDeltas = deltaEvents
const deltas = deltaEvents
.filter((e) => e.event === "response.output_text.delta")
.map((e) => {
const parsed = JSON.parse(e.data) as { delta?: string };
return parsed.delta ?? "";
})
.join("");
expect(allDeltas).toBe("hello");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(deltas).toBe("hello");
it("streams SSE events when stream=true (fallback when no deltas)", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
const resFallback = await postResponses(port, {
stream: true,
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
const text = await res.text();
expect(text).toContain("[DONE]");
expect(text).toContain("hello");
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}
});
expect(resFallback.status).toBe(200);
const fallbackText = await resFallback.text();
expect(fallbackText).toContain("[DONE]");
expect(fallbackText).toContain("hello");
it("event type matches JSON type field", async () => {
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
agentCommand.mockReset();
agentCommand.mockResolvedValueOnce({
payloads: [{ text: "hello" }],
} as never);
const port = await getFreePort();
const server = await startServer(port);
try {
const res = await postResponses(port, {
const resTypeMatch = await postResponses(port, {
stream: true,
model: "clawdbot",
input: "hi",
});
expect(res.status).toBe(200);
expect(resTypeMatch.status).toBe(200);
const text = await res.text();
const events = parseSseEvents(text);
for (const event of events) {
const typeText = await resTypeMatch.text();
const typeEvents = parseSseEvents(typeText);
for (const event of typeEvents) {
if (event.data === "[DONE]") continue;
const parsed = JSON.parse(event.data) as { type?: string };
expect(event.event).toBe(parsed.type);
}
await ensureResponseConsumed(res);
} finally {
await server.close({ reason: "test done" });
}

View File

@@ -25,46 +25,7 @@ function _expectChannels(call: Record<string, unknown>, channel: string) {
}
describe("gateway server agent", () => {
test("agent events include sessionKey in agent payloads", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
const agentEvtP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
const evt = await agentEvtP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
ws.close();
await server.close();
});
test("suppresses tool stream events when verbose is off", async () => {
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
@@ -87,153 +48,153 @@ describe("gateway server agent", () => {
},
});
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
registerAgentRunContext("run-tool-1", {
sessionKey: "main",
verboseLevel: "on",
});
const evt = await onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
{
const agentEvtP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-1",
8000,
);
ws.close();
await server.close();
});
test("agent.wait resolves after lifecycle end", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-1",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-tool-1",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
const evt = await agentEvtP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
}
{
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
emitAgentEvent({
runId: "run-tool-off",
stream: "tool",
data: { phase: "start", name: "read", toolCallId: "tool-1" },
});
emitAgentEvent({
runId: "run-tool-off",
stream: "assistant",
data: { text: "hello" },
});
const evt = await onceMessage(
ws,
(o) => o.type === "event" && o.event === "agent" && o.payload?.runId === "run-tool-off",
8000,
);
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.stream).toBe("assistant");
}
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-1",
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
timeoutMs: 1000,
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(200);
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-1",
stream: "lifecycle",
data: { phase: "end", startedAt: 200, endedAt: 210 },
});
}, 5);
ws.close();
await server.close();
});
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(200);
}
test("agent.wait resolves when lifecycle ended before wait call", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
emitAgentEvent({
runId: "run-wait-early",
stream: "lifecycle",
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-early",
timeoutMs: 1000,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(50);
ws.close();
await server.close();
});
test("agent.wait times out when no lifecycle ends", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-3",
timeoutMs: 20,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("timeout");
ws.close();
await server.close();
});
test("agent.wait returns error on lifecycle error", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
setTimeout(() => {
{
emitAgentEvent({
runId: "run-wait-err",
runId: "run-wait-early",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
data: { phase: "end", startedAt: 50, endedAt: 55 },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-early",
timeoutMs: 1000,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(50);
}
ws.close();
await server.close();
});
{
const res = await rpcReq(ws, "agent.wait", {
runId: "run-wait-3",
timeoutMs: 30,
});
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("timeout");
}
test("agent.wait uses lifecycle start timestamp when end omits it", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-err",
timeoutMs: 1000,
});
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-err",
stream: "lifecycle",
data: { phase: "error", error: "boom" },
});
}, 5);
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "start", startedAt: 123 },
});
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("error");
expect(res.payload.error).toBe("boom");
}
{
const waitP = rpcReq(ws, "agent.wait", {
runId: "run-wait-start",
timeoutMs: 1000,
});
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
data: { phase: "start", startedAt: 123 },
});
}, 10);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
setTimeout(() => {
emitAgentEvent({
runId: "run-wait-start",
stream: "lifecycle",
data: { phase: "end", endedAt: 456 },
});
}, 5);
const res = await waitP;
expect(res.ok).toBe(true);
expect(res.payload.status).toBe("ok");
expect(res.payload.startedAt).toBe(123);
expect(res.payload.endedAt).toBe(456);
}
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.sessionStorePath = undefined;
});
});

View File

@@ -30,11 +30,11 @@ describe("gateway server auth/connect", () => {
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
vi.useRealTimers();
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
try {
const { server, ws } = await startServerWithClient();
const handshakeTimeoutMs = getHandshakeTimeoutMs();
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
expect(closed).toBe(true);
await server.close();
} finally {

View File

@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
@@ -13,9 +14,7 @@ import {
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
@@ -24,106 +23,300 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
}
throw new Error("timeout waiting for condition");
}
const sendReq = (
ws: { send: (payload: string) => void },
id: string,
method: string,
params: unknown,
) => {
ws.send(
JSON.stringify({
type: "req",
id,
method,
params,
}),
);
};
const withSessionStore = async <T>(
tempDirs: string[],
entries: Record<
string,
{ sessionId: string; updatedAt: number; lastChannel?: string; lastTo?: string }
>,
fn: (dir: string) => Promise<T>,
): Promise<T> => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(dir);
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ entries });
try {
return await fn(dir);
} finally {
testState.sessionStorePath = undefined;
}
};
describe("gateway server chat", () => {
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => {
const tempDirs: string[] = [];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const bigText = "x".repeat(200_000);
const largeLines: string[] = [];
for (let i = 0; i < 40; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `${i}:${bigText}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8");
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
expect(cappedMsgs.length).toBeLessThan(60);
ws.close();
await server.close();
});
test("chat.send does not overwrite last delivery route", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-route",
});
expect(res.ok).toBe(true);
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
string,
{ lastChannel?: string; lastTo?: string } | undefined
>;
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
ws.close();
await server.close();
});
test("chat.abort cancels an in-flight chat.send", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
let inFlight: Promise<unknown> | undefined;
const spy = vi.mocked(agentCommand);
const resetSpy = () => {
spy.mockReset();
spy.mockResolvedValue(undefined);
};
try {
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async (historyDir) => {
const bigText = "x".repeat(200_000);
const largeLines: string[] = [];
for (let i = 0; i < 40; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `${i}:${bigText}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(
path.join(historyDir, "sess-main.jsonl"),
largeLines.join("\n"),
"utf-8",
);
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
expect(cappedMsgs.length).toBeLessThan(60);
},
);
await withSessionStore(
tempDirs,
{
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
async () => {
const routeRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-route",
});
expect(routeRes.ok).toBe(true);
const stored = JSON.parse(
await fs.readFile(testState.sessionStorePath as string, "utf-8"),
) as Record<string, { lastChannel?: string; lastTo?: string } | undefined>;
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
let abortInFlight: Promise<unknown> | undefined;
try {
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-1",
8000,
);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
8000,
);
abortInFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
sendReq(ws, "send-abort-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
});
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline)
return reject(new Error("timeout waiting for agentCommand"));
setTimeout(tick, 5);
};
tick();
});
sendReq(ws, "abort-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
await abortInFlight;
}
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
sessionStoreSaveDelayMs.value = 120;
resetSpy();
try {
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-abort-save-1",
);
sendReq(ws, "send-abort-save-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-1",
timeoutMs: 30_000,
});
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
sendReq(ws, "abort-save-1", "chat.abort", {
sessionKey: "main",
runId: "idem-abort-save-1",
});
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-save-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
sessionStoreSaveDelayMs.value = 0;
}
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
const callsBeforeStop = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const stopSendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-stop-1",
8000,
);
sendReq(ws, "send-stop-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
});
const stopSendRes = await stopSendResP;
expect(stopSendRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBeforeStop);
const abortedStopEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
sendReq(ws, "send-stop-2", "chat.send", {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
});
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const stopEvt = await abortedStopEventP;
expect(stopEvt.payload?.sessionKey).toBe("main");
expect(spy.mock.calls.length).toBe(callsBeforeStop + 1);
},
);
resetSpy();
let resolveRun: (() => void) | undefined;
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlightRes = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(inFlightRes.ok).toBe(true);
expect(inFlightRes.payload?.status).toBe("in_flight");
resolveRun?.();
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
}
expect(completed).toBe(true);
resetSpy();
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
@@ -132,260 +325,198 @@ describe("gateway server chat", () => {
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-1", 8000);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
8000,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-abort-all-1",
);
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
ws.send(
JSON.stringify({
type: "req",
id: "send-abort-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-1",
timeoutMs: 30_000,
},
}),
);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await new Promise<void>((resolve, reject) => {
const deadline = Date.now() + 1000;
const tick = () => {
if (spy.mock.calls.length > callsBefore) return resolve();
if (Date.now() > deadline) return reject(new Error("timeout waiting for agentCommand"));
setTimeout(tick, 5);
};
tick();
});
ws.send(
JSON.stringify({
type: "req",
id: "abort-1",
method: "chat.abort",
params: { sessionKey: "main", runId: "idem-abort-1" },
}),
);
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-1");
expect(evt.payload?.sessionKey).toBe("main");
} finally {
ws.close();
await inFlight;
await server.close();
}
});
test("chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
sessionStoreSaveDelayMs.value = 120;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
);
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-save-1");
ws.send(
JSON.stringify({
type: "req",
id: "send-abort-save-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-1",
timeoutMs: 30_000,
},
}),
);
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
ws.send(
JSON.stringify({
type: "req",
id: "abort-save-1",
method: "chat.abort",
params: { sessionKey: "main", runId: "idem-abort-save-1" },
}),
);
const abortRes = await abortResP;
expect(abortRes.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.runId).toBe("idem-abort-save-1");
expect(evt.payload?.sessionKey).toBe("main");
ws.close();
await server.close();
});
test("chat.send treats /stop as an out-of-band abort", { timeout: 60_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: { sessionId: "sess-main", updatedAt: Date.now() },
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-1", 8000);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-stop-run",
},
}),
);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore);
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-stop-run",
8000,
);
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
ws.send(
JSON.stringify({
type: "req",
id: "send-stop-2",
method: "chat.send",
params: {
sessionKey: "main",
message: "/stop",
idempotencyKey: "idem-stop-req",
},
}),
);
const stopRes = await stopResP;
expect(stopRes.ok).toBe(true);
const evt = await abortedEventP;
expect(evt.payload?.sessionKey).toBe("main");
expect(spy.mock.calls.length).toBe(callsBefore + 1);
ws.close();
await server.close();
});
test("chat.send idempotency returns started → in_flight → ok", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
let resolveRun: (() => void) | undefined;
const runDone = new Promise<void>((resolve) => {
resolveRun = resolve;
});
spy.mockImplementationOnce(async () => {
await runDone;
});
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(started.ok).toBe(true);
expect(started.payload?.status).toBe("started");
const inFlight = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
});
expect(inFlight.ok).toBe(true);
expect(inFlight.payload?.status).toBe("in_flight");
resolveRun?.();
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
const startedAbortAll = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-status-1",
idempotencyKey: "idem-abort-all-1",
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
expect(startedAbortAll.ok).toBe(true);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
runIds?: string[];
}>(ws, "chat.abort", { sessionKey: "main" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(true);
expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1");
await abortedEventP;
const noDeltaP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
(o.payload?.state === "delta" || o.payload?.state === "final") &&
o.payload?.runId === "idem-abort-all-1",
250,
);
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "assistant",
data: { text: "should be suppressed" },
});
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "lifecycle",
data: { phase: "end" },
});
await expect(noDeltaP).rejects.toThrow(/timeout/i);
await withSessionStore(tempDirs, {}, async () => {
const abortUnknown = await rpcReq<{
ok?: boolean;
aborted?: boolean;
}>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" });
expect(abortUnknown.ok).toBe(true);
expect(abortUnknown.payload?.aborted).toBe(false);
});
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
let agentStartedResolve: (() => void) | undefined;
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-mismatch-1",
10_000,
);
sendReq(ws, "send-mismatch-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-mismatch-1",
timeoutMs: 30_000,
});
await agentStartedP;
const abortMismatch = await rpcReq(ws, "chat.abort", {
sessionKey: "other",
runId: "idem-mismatch-1",
});
expect(abortMismatch.ok).toBe(false);
expect(abortMismatch.error?.code).toBe("INVALID_REQUEST");
const abortMismatch2 = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-mismatch-1",
});
expect(abortMismatch2.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
resetSpy();
spy.mockResolvedValueOnce(undefined);
sendReq(ws, "send-complete-1", "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
const sendCompleteRes = await onceMessage(
ws,
(o) => o.type === "res" && o.id === "send-complete-1",
);
expect(sendCompleteRes.ok).toBe(true);
let completedRun = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
if (again.ok && again.payload?.status === "ok") {
completedRun = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
}
expect(completedRun).toBe(true);
const abortCompleteRes = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-complete-1",
});
expect(abortCompleteRes.ok).toBe(true);
expect(abortCompleteRes.payload?.aborted).toBe(false);
},
);
await withSessionStore(
tempDirs,
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
async () => {
const res1 = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "first",
idempotencyKey: "idem-1",
});
expect(res1.ok).toBe(true);
const res2 = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "second",
idempotencyKey: "idem-2",
});
expect(res2.ok).toBe(true);
const final1P = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);
emitAgentEvent({
runId: "idem-1",
stream: "lifecycle",
data: { phase: "end" },
});
const final1 = await final1P;
const run1 =
final1.payload && typeof final1.payload === "object"
? (final1.payload as { runId?: string }).runId
: undefined;
expect(run1).toBe("idem-1");
const final2P = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);
emitAgentEvent({
runId: "idem-2",
stream: "lifecycle",
data: { phase: "end" },
});
const final2 = await final2P;
const run2 =
final2.payload && typeof final2.payload === "object"
? (final2.payload as { runId?: string }).runId
: undefined;
expect(run2).toBe("idem-2");
},
);
} finally {
testState.sessionStorePath = undefined;
sessionStoreSaveDelayMs.value = 0;
ws.close();
await server.close();
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
}
expect(completed).toBe(true);
ws.close();
await server.close();
});
});

View File

@@ -1,318 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
installGatewayTestHooks();
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
describe("gateway server chat", () => {
test("chat.abort without runId aborts active runs and suppresses chat events after abort", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const abortedEventP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
o.payload?.state === "aborted" &&
o.payload?.runId === "idem-abort-all-1",
);
const started = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-all-1",
});
expect(started.ok).toBe(true);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
runIds?: string[];
}>(ws, "chat.abort", { sessionKey: "main" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(true);
expect(abortRes.payload?.runIds ?? []).toContain("idem-abort-all-1");
await abortedEventP;
const noDeltaP = onceMessage(
ws,
(o) =>
o.type === "event" &&
o.event === "chat" &&
(o.payload?.state === "delta" || o.payload?.state === "final") &&
o.payload?.runId === "idem-abort-all-1",
250,
);
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "assistant",
data: { text: "should be suppressed" },
});
emitAgentEvent({
runId: "idem-abort-all-1",
stream: "lifecycle",
data: { phase: "end" },
});
await expect(noDeltaP).rejects.toThrow(/timeout/i);
ws.close();
await server.close();
});
test("chat.abort returns aborted=false for unknown runId", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({ entries: {} });
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const abortRes = await rpcReq<{
ok?: boolean;
aborted?: boolean;
}>(ws, "chat.abort", { sessionKey: "main", runId: "missing-run" });
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(false);
ws.close();
await server.close();
});
test("chat.abort rejects mismatched sessionKey", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
let agentStartedResolve: (() => void) | undefined;
const agentStartedP = new Promise<void>((resolve) => {
agentStartedResolve = resolve;
});
spy.mockImplementationOnce(async (opts) => {
agentStartedResolve?.();
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-mismatch-1", 10_000);
ws.send(
JSON.stringify({
type: "req",
id: "send-mismatch-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-mismatch-1",
timeoutMs: 30_000,
},
}),
);
await agentStartedP;
const abortRes = await rpcReq(ws, "chat.abort", {
sessionKey: "other",
runId: "idem-mismatch-1",
});
expect(abortRes.ok).toBe(false);
expect(abortRes.error?.code).toBe("INVALID_REQUEST");
const abortRes2 = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-mismatch-1",
});
expect(abortRes2.ok).toBe(true);
const sendRes = await sendResP;
expect(sendRes.ok).toBe(true);
ws.close();
await server.close();
}, 15_000);
test("chat.abort is a no-op after chat.send completes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
spy.mockResolvedValueOnce(undefined);
ws.send(
JSON.stringify({
type: "req",
id: "send-complete-1",
method: "chat.send",
params: {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
},
}),
);
const sendRes = await onceMessage(ws, (o) => o.type === "res" && o.id === "send-complete-1");
expect(sendRes.ok).toBe(true);
// chat.send returns before the run ends; wait until dedupe is populated
// (meaning the run completed and the abort controller was cleared).
let completed = false;
for (let i = 0; i < 50; i++) {
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-complete-1",
timeoutMs: 30_000,
});
if (again.ok && again.payload?.status === "ok") {
completed = true;
break;
}
await new Promise((r) => setTimeout(r, 10));
}
expect(completed).toBe(true);
const abortRes = await rpcReq(ws, "chat.abort", {
sessionKey: "main",
runId: "idem-complete-1",
});
expect(abortRes.ok).toBe(true);
expect(abortRes.payload?.aborted).toBe(false);
ws.close();
await server.close();
});
test("chat.send preserves run ordering for queued runs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res1 = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "first",
idempotencyKey: "idem-1",
});
expect(res1.ok).toBe(true);
const res2 = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "second",
idempotencyKey: "idem-2",
});
expect(res2.ok).toBe(true);
const final1P = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);
emitAgentEvent({
runId: "idem-1",
stream: "lifecycle",
data: { phase: "end" },
});
const final1 = await final1P;
const run1 =
final1.payload && typeof final1.payload === "object"
? (final1.payload as { runId?: string }).runId
: undefined;
expect(run1).toBe("idem-1");
const final2P = onceMessage(
ws,
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "final",
8000,
);
emitAgentEvent({
runId: "idem-2",
stream: "lifecycle",
data: { phase: "end" },
});
const final2 = await final2P;
const run2 =
final2.payload && typeof final2.payload === "object"
? (final2.payload as { runId?: string }).runId
: undefined;
expect(run2).toBe("idem-2");
ws.close();
await server.close();
});
});

View File

@@ -2,13 +2,13 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { WebSocket } from "ws";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
connectOk,
installGatewayTestHooks,
onceMessage,
piSdkMock,
rpcReq,
startServerWithClient,
testState,
@@ -27,468 +27,222 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
}
describe("gateway server chat", () => {
test("webchat can chat.send without a mobile node", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "dev",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
test("handles chat send and history flows", async () => {
const tempDirs: string[] = [];
const { server, ws, port } = await startServerWithClient();
let webchatWs: WebSocket | undefined;
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-webchat-1",
});
expect(res.ok).toBe(true);
try {
await connectOk(ws);
ws.close();
await server.close();
});
test("chat.send defaults to agent timeout config", async () => {
testState.agentConfig = { timeoutSeconds: 123 };
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const res = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-timeout-1",
});
expect(res.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore);
const call = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
expect(call?.timeout).toBe("123");
ws.close();
await server.close();
});
test("chat.send forwards sessionKey to agentCommand", async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const res = await rpcReq(ws, "chat.send", {
sessionKey: "agent:main:subagent:abc",
message: "hello",
idempotencyKey: "idem-session-key-1",
});
expect(res.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore);
const call = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
expect(call?.sessionKey).toBe("agent:main:subagent:abc");
ws.close();
await server.close();
});
test("chat.send blocked by send policy", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [
{
action: "deny",
match: { channel: "discord", chatType: "group" },
},
],
},
};
await writeSessionStore({
entries: {
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => webchatWs?.once("open", resolve));
await connectOk(webchatWs, {
client: {
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
version: "dev",
platform: "web",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
},
});
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const webchatRes = await rpcReq(webchatWs, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-webchat-1",
});
expect(webchatRes.ok).toBe(true);
const res = await rpcReq(ws, "chat.send", {
sessionKey: "discord:group:dev",
message: "hello",
idempotencyKey: "idem-1",
});
expect(res.ok).toBe(false);
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
webchatWs.close();
webchatWs = undefined;
ws.close();
await server.close();
});
const spy = vi.mocked(agentCommand);
spy.mockClear();
testState.agentConfig = { timeoutSeconds: 123 };
const callsBeforeTimeout = spy.mock.calls.length;
const timeoutRes = await rpcReq(ws, "chat.send", {
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-timeout-1",
});
expect(timeoutRes.ok).toBe(true);
test("agent blocked by send policy for sessionKey", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
},
};
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
expect(timeoutCall?.timeout).toBe("123");
testState.agentConfig = undefined;
await writeSessionStore({
entries: {
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
},
},
});
spy.mockClear();
const callsBeforeSession = spy.mock.calls.length;
const sessionRes = await rpcReq(ws, "chat.send", {
sessionKey: "agent:main:subagent:abc",
message: "hello",
idempotencyKey: "idem-session-key-1",
});
expect(sessionRes.ok).toBe(true);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
const res = await rpcReq(ws, "agent", {
sessionKey: "cron:job-1",
message: "hi",
idempotencyKey: "idem-2",
});
expect(res.ok).toBe(false);
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
ws.close();
await server.close();
});
test("chat.send accepts image attachment", { timeout: 12000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const reqId = "chat-img";
ws.send(
JSON.stringify({
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-img",
attachments: [
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(sendPolicyDir);
testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
action: "deny",
match: { channel: "discord", chatType: "group" },
},
],
},
}),
);
};
const res = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
expect(res.ok).toBe(true);
expect(res.payload?.runId).toBeDefined();
await waitFor(() => spy.mock.calls.length > callsBefore, 8000);
const call = spy.mock.calls.at(-1)?.[0] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
ws.close();
await server.close();
});
test("chat.history caps large histories and honors limit", async () => {
const firstContentText = (msg: unknown): string | undefined => {
if (!msg || typeof msg !== "object") return undefined;
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const first = content[0];
if (!first || typeof first !== "object") return undefined;
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
};
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
await writeSessionStore({
entries: {
"discord:group:dev": {
sessionId: "sess-discord",
updatedAt: Date.now(),
chatType: "group",
channel: "discord",
},
},
},
});
});
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
lines.push(
const blockedRes = await rpcReq(ws, "chat.send", {
sessionKey: "discord:group:dev",
message: "hello",
idempotencyKey: "idem-1",
});
expect(blockedRes.ok).toBe(false);
expect((blockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
/send blocked/i,
);
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
const agentBlockedDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(agentBlockedDir);
testState.sessionStorePath = path.join(agentBlockedDir, "sessions.json");
testState.sessionConfig = {
sendPolicy: {
default: "allow",
rules: [{ action: "deny", match: { keyPrefix: "cron:" } }],
},
};
await writeSessionStore({
entries: {
"cron:job-1": {
sessionId: "sess-cron",
updatedAt: Date.now(),
},
},
});
const agentBlockedRes = await rpcReq(ws, "agent", {
sessionKey: "cron:job-1",
message: "hi",
idempotencyKey: "idem-2",
});
expect(agentBlockedRes.ok).toBe(false);
expect((agentBlockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
/send blocked/i,
);
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
spy.mockClear();
const callsBeforeImage = spy.mock.calls.length;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const reqId = "chat-img";
ws.send(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `m${i}` }],
timestamp: Date.now() + i,
type: "req",
id: reqId,
method: "chat.send",
params: {
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-img",
attachments: [
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
},
}),
);
}
await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const imgRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
expect(imgRes.ok).toBe(true);
expect(imgRes.payload?.runId).toBeDefined();
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100");
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
const imgCall = spy.mock.calls.at(-1)?.[0] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
const limitedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 5,
});
expect(limitedRes.ok).toBe(true);
const limitedMsgs = limitedRes.payload?.messages ?? [];
expect(limitedMsgs.length).toBe(5);
expect(firstContentText(limitedMsgs[0])).toBe("m295");
const largeLines: string[] = [];
for (let i = 0; i < 1500; i += 1) {
largeLines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `b${i}` }],
timestamp: Date.now() + i,
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
tempDirs.push(historyDir);
testState.sessionStorePath = path.join(historyDir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
}),
);
},
});
const lines: string[] = [];
for (let i = 0; i < 300; i += 1) {
lines.push(
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: `m${i}` }],
timestamp: Date.now() + i,
},
}),
);
}
await fs.writeFile(path.join(historyDir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(defaultRes.ok).toBe(true);
const defaultMsgs = defaultRes.payload?.messages ?? [];
const firstContentText = (msg: unknown): string | undefined => {
if (!msg || typeof msg !== "object") return undefined;
const content = (msg as { content?: unknown }).content;
if (!Array.isArray(content) || content.length === 0) return undefined;
const first = content[0];
if (!first || typeof first !== "object") return undefined;
const text = (first as { text?: unknown }).text;
return typeof text === "string" ? text : undefined;
};
expect(defaultMsgs.length).toBe(200);
expect(firstContentText(defaultMsgs[0])).toBe("m100");
} finally {
testState.agentConfig = undefined;
testState.sessionStorePath = undefined;
testState.sessionConfig = undefined;
if (webchatWs) webchatWs.close();
ws.close();
await server.close();
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
}
await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8");
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(cappedRes.ok).toBe(true);
const cappedMsgs = cappedRes.payload?.messages ?? [];
expect(cappedMsgs.length).toBe(200);
expect(firstContentText(cappedMsgs[0])).toBe("b1300");
const maxRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
limit: 1000,
});
expect(maxRes.ok).toBe(true);
const maxMsgs = maxRes.payload?.messages ?? [];
expect(maxMsgs.length).toBe(1000);
expect(firstContentText(maxMsgs[0])).toBe("b500");
ws.close();
await server.close();
});
test("chat.history strips inbound envelopes for user messages", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const enveloped = "[WebChat agent:main:main +2m 2026-01-19 09:29 UTC] hello world";
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: enveloped }],
timestamp: Date.now(),
},
}),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(res.ok).toBe(true);
const message = (res.payload?.messages ?? [])[0] as
| { content?: Array<{ text?: string }> }
| undefined;
expect(message?.content?.[0]?.text).toBe("hello world");
ws.close();
await server.close();
});
test("chat.history prefers sessionFile when set", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
const forkedPath = path.join(dir, "sess-forked.jsonl");
await fs.writeFile(
forkedPath,
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "from-fork" }],
timestamp: Date.now(),
},
}),
"utf-8",
);
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "from-default" }],
timestamp: Date.now(),
},
}),
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
sessionFile: forkedPath,
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
sessionKey: "main",
});
expect(res.ok).toBe(true);
const messages = res.payload?.messages ?? [];
expect(messages.length).toBe(1);
const first = messages[0] as { content?: { text?: string }[] };
expect(first.content?.[0]?.text).toBe("from-fork");
ws.close();
await server.close();
});
test("chat.inject appends to the session transcript", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
const transcriptPath = path.join(dir, "sess-main.jsonl");
await fs.writeFile(
transcriptPath,
`${JSON.stringify({
type: "message",
id: "m1",
timestamp: new Date().toISOString(),
message: { role: "user", content: [{ type: "text", text: "seed" }], timestamp: Date.now() },
})}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ messageId?: string }>(ws, "chat.inject", {
sessionKey: "main",
message: "injected text",
label: "note",
});
expect(res.ok).toBe(true);
const raw = await fs.readFile(transcriptPath, "utf-8");
const lines = raw.split(/\r?\n/).filter(Boolean);
expect(lines.length).toBe(2);
const last = JSON.parse(lines[1]) as {
message?: { role?: string; content?: Array<{ text?: string }> };
};
expect(last.message?.role).toBe("assistant");
expect(last.message?.content?.[0]?.text).toContain("injected text");
ws.close();
await server.close();
});
test("chat.history defaults thinking to low for reasoning-capable models", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [
{
id: "claude-opus-4-5",
name: "Opus 4.5",
provider: "anthropic",
reasoning: true,
},
];
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "hello" }],
timestamp: Date.now(),
},
}),
"utf-8",
);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const res = await rpcReq<{ thinkingLevel?: string }>(ws, "chat.history", {
sessionKey: "main",
});
expect(res.ok).toBe(true);
expect(res.payload?.thinkingLevel).toBe("low");
ws.close();
await server.close();
});
});

View File

@@ -1,95 +1,106 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { WebSocket } from "ws";
import {
connectOk,
getFreePort,
installGatewayTestHooks,
onceMessage,
startServerWithClient,
startGatewayServer,
} from "./test-helpers.js";
installGatewayTestHooks();
installGatewayTestHooks({ scope: "suite" });
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
afterEach(async () => {
for (const { server, ws } of servers) {
try {
ws.close();
await server.close();
} catch {
/* ignore */
}
}
servers.length = 0;
await new Promise((resolve) => setTimeout(resolve, 50));
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async () => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws);
return ws;
};
describe("gateway config.apply", () => {
it("writes config, stores sentinel, and schedules restart", async () => {
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
const ws = await openClient();
try {
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
} catch {
// File may not exist if signal delivery is mocked, verify response was ok instead
const id = "req-1";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: '{ "agents": { "list": [{ "id": "main", "workspace": "~/clawd" }] } }',
sessionKey: "agent:main:whatsapp:dm:+15555550123",
restartDelayMs: 0,
},
}),
);
const res = await onceMessage<{ ok: boolean; payload?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(true);
// Verify sentinel file was created (restart was scheduled)
const sentinelPath = path.join(os.homedir(), ".clawdbot", "restart-sentinel.json");
// Wait for file to be written
await new Promise((resolve) => setTimeout(resolve, 100));
try {
const raw = await fs.readFile(sentinelPath, "utf-8");
const parsed = JSON.parse(raw) as { payload?: { kind?: string } };
expect(parsed.payload?.kind).toBe("config-apply");
} catch {
// File may not exist if signal delivery is mocked, verify response was ok instead
expect(res.ok).toBe(true);
}
} finally {
ws.close();
}
});
it("rejects invalid raw config", async () => {
const result = await startServerWithClient();
servers.push(result);
const { ws } = result;
await connectOk(ws);
const id = "req-2";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: "{",
},
}),
);
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
const ws = await openClient();
try {
const id = "req-2";
ws.send(
JSON.stringify({
type: "req",
id,
method: "config.apply",
params: {
raw: "{",
},
}),
);
const res = await onceMessage<{ ok: boolean; error?: unknown }>(
ws,
(o) => o.type === "res" && o.id === id,
);
expect(res.ok).toBe(false);
} finally {
ws.close();
}
});
});

View File

@@ -1,17 +1,18 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { describe, expect, test, vi } from "vitest";
import {
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
testState,
waitForSystemEvent,
} from "./test-helpers.js";
installGatewayTestHooks();
installGatewayTestHooks({ scope: "suite" });
async function yieldToEventLoop() {
// Avoid relying on timers (fake timers can leak between tests).
@@ -35,246 +36,35 @@ async function rmTempDir(dir: string) {
await fs.rm(dir, { recursive: true, force: true });
}
async function waitForCronFinished(ws: { send: (data: string) => void }, jobId: string) {
await onceMessage(
ws as never,
(o) =>
o.type === "event" &&
o.event === "cron" &&
o.payload?.action === "finished" &&
o.payload?.jobId === jobId,
10_000,
);
}
async function waitForNonEmptyFile(pathname: string, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
const raw = await fs.readFile(pathname, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
if (Date.now() >= deadline) {
throw new Error(`timeout waiting for file ${pathname}`);
}
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
describe("gateway server cron", () => {
test("supports cron.add and cron.list", { timeout: 120_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "daily",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
const listRes = await rpcReq(ws, "cron.list", {
includeDisabled: true,
});
expect(listRes.ok).toBe(true);
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("enqueues main cron system events to the resolved main session key", async () => {
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.sessionConfig = { mainKey: "primary" };
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "route test",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "cron route check" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
const events = await waitForSystemEvent();
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
});
test("normalizes wrapped cron.add payloads", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() + 1000;
const addRes = await rpcReq(ws, "cron.add", {
data: {
name: "wrapped",
schedule: { atMs },
payload: { kind: "systemEvent", text: "hello" },
},
});
expect(addRes.ok).toBe(true);
const payload = addRes.payload as
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
| undefined;
expect(payload?.sessionTarget).toBe("main");
expect(payload?.wakeMode).toBe("next-heartbeat");
expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("normalizes cron.update patch payloads", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const atMs = Date.now() + 1_000;
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
schedule: { atMs },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| { schedule?: { kind?: unknown }; payload?: { kind?: unknown } }
| undefined;
expect(updated?.schedule?.kind).toBe("at");
expect(updated?.payload?.kind).toBe("systemEvent");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("merges agentTurn payload patches", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch merge",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", model: "opus" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
deliver?: unknown;
channel?: unknown;
to?: unknown;
};
}
| undefined;
expect(updated?.payload?.kind).toBe("agentTurn");
expect(updated?.payload?.message).toBe("hello");
expect(updated?.payload?.model).toBe("opus");
expect(updated?.payload?.deliver).toBe(true);
expect(updated?.payload?.channel).toBe("telegram");
expect(updated?.payload?.to).toBe("19098680");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("rejects payload kind changes without required fields", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "patch reject",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: {
payload: { kind: "agentTurn", deliver: true },
},
});
expect(updateRes.ok).toBe(false);
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
});
test("accepts jobId for cron.update", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = false;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
@@ -282,218 +72,256 @@ describe("gateway server cron", () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "jobId test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
try {
const addRes = await rpcReq(ws, "cron.add", {
name: "daily",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
expect(typeof (addRes.payload as { id?: unknown } | null)?.id).toBe("string");
const atMs = Date.now() + 2_000;
const updateRes = await rpcReq(ws, "cron.update", {
jobId,
patch: {
schedule: { atMs },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
const listRes = await rpcReq(ws, "cron.list", {
includeDisabled: true,
});
expect(listRes.ok).toBe(true);
const jobs = (listRes.payload as { jobs?: unknown } | null)?.jobs;
expect(Array.isArray(jobs)).toBe(true);
expect((jobs as unknown[]).length).toBe(1);
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
const routeAtMs = Date.now() - 1;
const routeRes = await rpcReq(ws, "cron.add", {
name: "route test",
enabled: true,
schedule: { kind: "at", atMs: routeAtMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "cron route check" },
});
expect(routeRes.ok).toBe(true);
const routeJobIdValue = (routeRes.payload as { id?: unknown } | null)?.id;
const routeJobId = typeof routeJobIdValue === "string" ? routeJobIdValue : "";
expect(routeJobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
const events = await waitForSystemEvent();
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
const wrappedAtMs = Date.now() + 1000;
const wrappedRes = await rpcReq(ws, "cron.add", {
data: {
name: "wrapped",
schedule: { atMs: wrappedAtMs },
payload: { kind: "systemEvent", text: "hello" },
},
});
expect(wrappedRes.ok).toBe(true);
const wrappedPayload = wrappedRes.payload as
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
| undefined;
expect(wrappedPayload?.sessionTarget).toBe("main");
expect(wrappedPayload?.wakeMode).toBe("next-heartbeat");
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
const patchRes = await rpcReq(ws, "cron.add", {
name: "patch test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(patchRes.ok).toBe(true);
const patchJobIdValue = (patchRes.payload as { id?: unknown } | null)?.id;
const patchJobId = typeof patchJobIdValue === "string" ? patchJobIdValue : "";
expect(patchJobId.length > 0).toBe(true);
const atMs = Date.now() + 1_000;
const updateRes = await rpcReq(ws, "cron.update", {
id: patchJobId,
patch: {
schedule: { atMs },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as
| { schedule?: { kind?: unknown }; payload?: { kind?: unknown } }
| undefined;
expect(updated?.schedule?.kind).toBe("at");
expect(updated?.payload?.kind).toBe("systemEvent");
const mergeRes = await rpcReq(ws, "cron.add", {
name: "patch merge",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "isolated",
wakeMode: "next-heartbeat",
payload: { kind: "agentTurn", message: "hello", model: "opus" },
});
expect(mergeRes.ok).toBe(true);
const mergeJobIdValue = (mergeRes.payload as { id?: unknown } | null)?.id;
const mergeJobId = typeof mergeJobIdValue === "string" ? mergeJobIdValue : "";
expect(mergeJobId.length > 0).toBe(true);
const mergeUpdateRes = await rpcReq(ws, "cron.update", {
id: mergeJobId,
patch: {
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
},
});
expect(mergeUpdateRes.ok).toBe(true);
const merged = mergeUpdateRes.payload as
| {
payload?: {
kind?: unknown;
message?: unknown;
model?: unknown;
deliver?: unknown;
channel?: unknown;
to?: unknown;
};
}
| undefined;
expect(merged?.payload?.kind).toBe("agentTurn");
expect(merged?.payload?.message).toBe("hello");
expect(merged?.payload?.model).toBe("opus");
expect(merged?.payload?.deliver).toBe(true);
expect(merged?.payload?.channel).toBe("telegram");
expect(merged?.payload?.to).toBe("19098680");
const rejectRes = await rpcReq(ws, "cron.add", {
name: "patch reject",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(rejectRes.ok).toBe(true);
const rejectJobIdValue = (rejectRes.payload as { id?: unknown } | null)?.id;
const rejectJobId = typeof rejectJobIdValue === "string" ? rejectJobIdValue : "";
expect(rejectJobId.length > 0).toBe(true);
const rejectUpdateRes = await rpcReq(ws, "cron.update", {
id: rejectJobId,
patch: {
payload: { kind: "agentTurn", deliver: true },
},
});
expect(rejectUpdateRes.ok).toBe(false);
const jobIdRes = await rpcReq(ws, "cron.add", {
name: "jobId test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(jobIdRes.ok).toBe(true);
const jobIdValue = (jobIdRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
jobId,
patch: {
schedule: { atMs: Date.now() + 2_000 },
payload: { kind: "systemEvent", text: "updated" },
},
});
expect(jobIdUpdateRes.ok).toBe(true);
const disableRes = await rpcReq(ws, "cron.add", {
name: "disable test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(disableRes.ok).toBe(true);
const disableJobIdValue = (disableRes.payload as { id?: unknown } | null)?.id;
const disableJobId = typeof disableJobIdValue === "string" ? disableJobIdValue : "";
expect(disableJobId.length > 0).toBe(true);
const disableUpdateRes = await rpcReq(ws, "cron.update", {
id: disableJobId,
patch: { enabled: false },
});
expect(disableUpdateRes.ok).toBe(true);
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
expect(disabled?.enabled).toBe(false);
} finally {
ws.close();
await server.close();
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.sessionConfig = undefined;
testState.cronEnabled = undefined;
}
});
test("disables cron jobs via enabled:false patches", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const addRes = await rpcReq(ws, "cron.add", {
name: "disable test",
enabled: true,
schedule: { kind: "every", everyMs: 60_000 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const updateRes = await rpcReq(ws, "cron.update", {
id: jobId,
patch: { enabled: false },
});
expect(updateRes.ok).toBe(true);
const updated = updateRes.payload as { enabled?: unknown } | undefined;
expect(updated?.enabled).toBe(false);
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("writes cron run history to runs/<jobId>.jsonl", async () => {
test("writes cron run history and auto-runs due jobs", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = undefined;
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "log test",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
// Full-suite runs can starve the event loop; give cron.run extra time to respond.
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
const waitForLog = async () => {
for (let i = 0; i < 200; i += 1) {
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
await yieldToEventLoop();
}
throw new Error("timeout waiting for cron run log");
};
const raw = await waitForLog();
const line = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.at(-1);
const last = JSON.parse(line ?? "{}") as {
jobId?: unknown;
action?: unknown;
status?: unknown;
summary?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
expect(last.status).toBe("ok");
expect(last.summary).toBe("hello");
const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 });
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("writes cron run history to per-job runs/ when store is jobs.json", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-jobs-"));
const cronDir = path.join(dir, "cron");
testState.cronStorePath = path.join(cronDir, "jobs.json");
await fs.mkdir(cronDir, { recursive: true });
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "log test (jobs.json)",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" });
expect(runRes.ok).toBe(true);
const logPath = path.join(cronDir, "runs", `${jobId}.jsonl`);
const waitForLog = async () => {
for (let i = 0; i < 200; i += 1) {
const raw = await fs.readFile(logPath, "utf-8").catch(() => "");
if (raw.trim().length > 0) return raw;
await yieldToEventLoop();
}
throw new Error("timeout waiting for per-job cron run log");
};
const raw = await waitForLog();
const line = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.at(-1);
const last = JSON.parse(line ?? "{}") as {
jobId?: unknown;
action?: unknown;
summary?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
expect(last.summary).toBe("hello");
const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 20 }, 20_000);
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
ws.close();
await server.close();
await fs.rm(dir, { recursive: true, force: true });
testState.cronStorePath = undefined;
});
test("enables cron scheduler by default and runs due jobs automatically", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-default-on-"));
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
testState.cronEnabled = undefined;
try {
await fs.mkdir(path.dirname(testState.cronStorePath), {
recursive: true,
const atMs = Date.now() - 1;
const addRes = await rpcReq(ws, "cron.add", {
name: "log test",
enabled: true,
schedule: { kind: "at", atMs },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "hello" },
});
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const finishedP = waitForCronFinished(ws, jobId);
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
expect(runRes.ok).toBe(true);
await finishedP;
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
const raw = await waitForNonEmptyFile(logPath);
const line = raw
.split("\n")
.map((l) => l.trim())
.filter(Boolean)
.at(-1);
const last = JSON.parse(line ?? "{}") as {
jobId?: unknown;
action?: unknown;
status?: unknown;
summary?: unknown;
};
expect(last.action).toBe("finished");
expect(last.jobId).toBe(jobId);
expect(last.status).toBe("ok");
expect(last.summary).toBe("hello");
const runsRes = await rpcReq(ws, "cron.runs", { id: jobId, limit: 50 });
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
expect(Array.isArray(entries)).toBe(true);
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
const statusRes = await rpcReq(ws, "cron.status", {});
expect(statusRes.ok).toBe(true);
@@ -504,45 +332,41 @@ describe("gateway server cron", () => {
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
expect(storePath).toContain("jobs.json");
// Keep the job due immediately; we poll run logs instead of relying on
// the cron finished event to avoid timing races under heavy load.
const atMs = Date.now() - 10;
const addRes = await rpcReq(ws, "cron.add", {
const autoRes = await rpcReq(ws, "cron.add", {
name: "auto run test",
enabled: true,
schedule: { kind: "at", atMs },
schedule: { kind: "at", atMs: Date.now() - 10 },
sessionTarget: "main",
wakeMode: "next-heartbeat",
payload: { kind: "systemEvent", text: "auto" },
});
expect(addRes.ok).toBe(true);
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
expect(jobId.length > 0).toBe(true);
expect(autoRes.ok).toBe(true);
const autoJobIdValue = (autoRes.payload as { id?: unknown } | null)?.id;
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
expect(autoJobId.length > 0).toBe(true);
const waitForRuns = async () => {
for (let i = 0; i < 500; i += 1) {
const runsRes = await rpcReq(ws, "cron.runs", {
id: jobId,
limit: 10,
});
expect(runsRes.ok).toBe(true);
const entries = (runsRes.payload as { entries?: unknown } | null)?.entries;
if (Array.isArray(entries) && entries.length > 0) return entries;
await yieldToEventLoop();
}
throw new Error("timeout waiting for cron.runs entries");
};
const entries = (await waitForRuns()) as Array<{ jobId?: unknown }>;
expect(entries.at(-1)?.jobId).toBe(jobId);
vi.useFakeTimers();
try {
const autoFinishedP = waitForCronFinished(ws, autoJobId);
await vi.advanceTimersByTimeAsync(1000);
await autoFinishedP;
} finally {
vi.useRealTimers();
}
await waitForNonEmptyFile(path.join(dir, "cron", "runs", `${autoJobId}.jsonl`));
const autoEntries = (await rpcReq(ws, "cron.runs", { id: autoJobId, limit: 10 })).payload as
| { entries?: Array<{ jobId?: unknown }> }
| undefined;
expect(Array.isArray(autoEntries?.entries)).toBe(true);
const runs = autoEntries?.entries ?? [];
expect(runs.at(-1)?.jobId).toBe(autoJobId);
} finally {
ws.close();
await server.close();
} finally {
testState.cronEnabled = false;
testState.cronStorePath = undefined;
await rmTempDir(dir);
testState.cronStorePath = undefined;
testState.cronEnabled = undefined;
}
}, 45_000);
});

View File

@@ -1,7 +1,7 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { afterAll, beforeAll, describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
@@ -21,12 +21,35 @@ import {
} from "./test-helpers.js";
import { buildDeviceAuthPayload } from "./device-auth.js";
installGatewayTestHooks();
installGatewayTestHooks({ scope: "suite" });
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
let previousToken: string | undefined;
beforeAll(async () => {
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
});
const openClient = async (opts?: Parameters<typeof connectOk>[1]) => {
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
await connectOk(ws, opts);
return ws;
};
describe("gateway server health/presence", () => {
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ws = await openClient();
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
@@ -51,7 +74,6 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(presence.payload)).toBe(true);
ws.close();
await server.close();
});
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
@@ -76,8 +98,7 @@ describe("gateway server health/presence", () => {
payload?: unknown;
};
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ws = await openClient();
const waitHeartbeat = onceMessage<EventFrame>(
ws,
@@ -117,12 +138,10 @@ describe("gateway server health/presence", () => {
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
ws.close();
await server.close();
});
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ws = await openClient();
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
ws.send(
@@ -140,12 +159,10 @@ describe("gateway server health/presence", () => {
expect(Array.isArray(evt.payload?.presence)).toBe(true);
ws.close();
await server.close();
});
test("agent events stream with seq", { timeout: 8000 }, async () => {
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const ws = await openClient();
const runId = randomUUID();
const evtPromise = onceMessage(
@@ -163,7 +180,6 @@ describe("gateway server health/presence", () => {
expect(evt.payload.data.msg).toBe("hi");
ws.close();
await server.close();
});
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
@@ -177,16 +193,7 @@ describe("gateway server health/presence", () => {
});
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const mkClient = async () => {
const c = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => c.once("open", resolve));
await connectOk(c);
return c;
};
const clients = await Promise.all([mkClient(), mkClient(), mkClient()]);
const clients = await Promise.all([openClient(), openClient(), openClient()]);
const waits = clients.map((c) =>
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
);
@@ -204,7 +211,6 @@ describe("gateway server health/presence", () => {
expect(typeof evt.seq).toBe("number");
}
for (const c of clients) c.close();
await server.close();
});
test("presence includes client fingerprint", async () => {
@@ -222,8 +228,7 @@ describe("gateway server health/presence", () => {
signedAtMs,
token: null,
});
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
const ws = await openClient({
role,
scopes,
client: {
@@ -264,13 +269,11 @@ describe("gateway server health/presence", () => {
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close();
await server.close();
});
test("cli connections are not tracked as instances", async () => {
const { server, ws } = await startServerWithClient();
const cliId = `cli-${randomUUID()}`;
await connectOk(ws, {
const ws = await openClient({
client: {
id: GATEWAY_CLIENT_NAMES.CLI,
version: "dev",
@@ -294,6 +297,5 @@ describe("gateway server health/presence", () => {
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
ws.close();
await server.close();
});
});

View File

@@ -1,9 +1,21 @@
import { test } from "vitest";
import { afterAll, beforeAll, test } from "vitest";
import WebSocket from "ws";
import { PROTOCOL_VERSION } from "./protocol/index.js";
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.js";
let server: Awaited<ReturnType<typeof startGatewayServer>>;
let port = 0;
beforeAll(async () => {
port = await getFreePort();
server = await startGatewayServer(port);
});
afterAll(async () => {
await server.close();
});
function connectReq(
ws: WebSocket,
params: { clientId: string; platform: string; token?: string; password?: string },
@@ -43,8 +55,6 @@ function connectReq(
}
test("accepts clawdbot-ios as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -61,12 +71,9 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
}
ws.close();
await server.close();
});
test("accepts clawdbot-android as a valid gateway client id", async () => {
const port = await getFreePort();
const server = await startGatewayServer(port);
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => ws.once("open", resolve));
@@ -83,5 +90,4 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
}
ws.close();
await server.close();
});

View File

@@ -66,161 +66,132 @@ const connectNodeClient = async (params: {
};
describe("gateway node command allowlist", () => {
test("rejects commands outside platform allowlist", async () => {
test("enforces command allowlists across node clients", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const nodeClient = await connectNodeClient({
port,
commands: ["system.run"],
});
const waitForConnectedCount = async (count: number) => {
await expect
.poll(
async () => {
const listRes = await rpcReq<{
nodes?: Array<{ nodeId: string; connected?: boolean }>;
}>(ws, "node.list", {});
const nodes = listRes.payload?.nodes ?? [];
return nodes.filter((node) => node.connected).length;
},
{ timeout: 2_000 },
)
.toBe(count);
};
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const getConnectedNodeId = async () => {
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string; connected?: boolean }> }>(
ws,
"node.list",
{},
);
const nodeId = listRes.payload?.nodes?.find((node) => node.connected)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
return nodeId;
};
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "system.run",
params: { command: "echo hi" },
idempotencyKey: "allowlist-1",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("node command not allowed");
try {
const systemClient = await connectNodeClient({
port,
commands: ["system.run"],
instanceId: "node-system-run",
displayName: "node-system-run",
});
const systemNodeId = await getConnectedNodeId();
const disallowedRes = await rpcReq(ws, "node.invoke", {
nodeId: systemNodeId,
command: "system.run",
params: { command: "echo hi" },
idempotencyKey: "allowlist-1",
});
expect(disallowedRes.ok).toBe(false);
expect(disallowedRes.error?.message).toContain("node command not allowed");
systemClient.stop();
await waitForConnectedCount(0);
nodeClient.stop();
ws.close();
await server.close();
});
const emptyClient = await connectNodeClient({
port,
commands: [],
instanceId: "node-empty",
displayName: "node-empty",
});
const emptyNodeId = await getConnectedNodeId();
const missingRes = await rpcReq(ws, "node.invoke", {
nodeId: emptyNodeId,
command: "canvas.snapshot",
params: {},
idempotencyKey: "allowlist-2",
});
expect(missingRes.ok).toBe(false);
expect(missingRes.error?.message).toContain("node command not allowed");
emptyClient.stop();
await waitForConnectedCount(0);
test("rejects commands not declared by node", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const waitForInvoke = () =>
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const allowedClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-allowed",
displayName: "node-allowed",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const allowedNodeId = await getConnectedNodeId();
const nodeClient = await connectNodeClient({
port,
commands: [],
instanceId: "node-empty",
displayName: "node-empty",
});
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId: allowedNodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-3",
});
const payload = await waitForInvoke();
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-allowed";
await allowedClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeNullResP = rpcReq(ws, "node.invoke", {
nodeId: allowedNodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-null-payloadjson",
});
const payloadNull = await waitForInvoke();
const requestIdNull = payloadNull?.id ?? "";
const nodeIdNull = payloadNull?.nodeId ?? "node-allowed";
await allowedClient.request("node.invoke.result", {
id: requestIdNull,
nodeId: nodeIdNull,
ok: true,
payloadJSON: null,
});
const invokeNullRes = await invokeNullResP;
expect(invokeNullRes.ok).toBe(true);
const res = await rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: {},
idempotencyKey: "allowlist-2",
});
expect(res.ok).toBe(false);
expect(res.error?.message).toContain("node command not allowed");
nodeClient.stop();
ws.close();
await server.close();
});
test("allows declared command within allowlist", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const nodeClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-allowed",
displayName: "node-allowed",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-3",
});
const payload = await invokeReqP;
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-allowed";
await nodeClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
nodeClient.stop();
ws.close();
await server.close();
});
test("accepts node invoke result with null payloadJSON", async () => {
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
resolveInvoke = resolve;
});
const nodeClient = await connectNodeClient({
port,
commands: ["canvas.snapshot"],
instanceId: "node-null-payloadjson",
displayName: "node-null-payloadjson",
onEvent: (evt) => {
if (evt.event === "node.invoke.request") {
const payload = evt.payload as { id?: string; nodeId?: string };
resolveInvoke?.(payload);
}
},
});
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
expect(nodeId).toBeTruthy();
const invokeResP = rpcReq(ws, "node.invoke", {
nodeId,
command: "canvas.snapshot",
params: { format: "png" },
idempotencyKey: "allowlist-null-payloadjson",
});
const payload = await invokeReqP;
const requestId = payload?.id ?? "";
const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson";
await nodeClient.request("node.invoke.result", {
id: requestId,
nodeId: nodeIdFromReq,
ok: true,
payloadJSON: null,
});
const invokeRes = await invokeResP;
expect(invokeRes.ok).toBe(true);
nodeClient.stop();
ws.close();
await server.close();
allowedClient.stop();
} finally {
ws.close();
await server.close();
}
});
});

Some files were not shown because too many files have changed in this diff Show More