Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cf4fc3867 | ||
|
|
fb7159be5e |
No files matched your search
+58
-35
@@ -2,23 +2,24 @@
|
|||||||
|
|
||||||
Docs: https://docs.clawd.bot
|
Docs: https://docs.clawd.bot
|
||||||
|
|
||||||
## 2026.1.22
|
## 2026.1.22 (unreleased)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
|
- Highlight: Mattermost plugin channel support with pairing + allowlist gating. (#1428) Thanks @damoahdominic.
|
||||||
|
- Highlight: OpenProse plugin skill pack with `/prose` slash command, plugin-shipped skills, and docs. https://docs.clawd.bot/prose
|
||||||
|
- TUI: run local shell commands with `!` after per-session consent, and warn when local exec stays disabled. (#1463) Thanks @vignesh07.
|
||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
- Lobster: allow workflow file args via `argsJson` in the plugin tool. https://docs.clawd.bot/tools/lobster
|
|
||||||
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
|
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
- Docs: add /model allowlist troubleshooting note. (#1405)
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
|
||||||
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
|
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
|
||||||
|
- macOS: add attach-only debug toggle + `--attach-only`/`--no-launchd` flag to skip launchd installs.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
@@ -31,18 +32,15 @@ Docs: https://docs.clawd.bot
|
|||||||
- Agents: surface concrete API error details instead of generic AI service errors.
|
- Agents: surface concrete API error details instead of generic AI service errors.
|
||||||
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
- Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.
|
||||||
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
- Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.
|
||||||
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
|
<<<<<<< Updated upstream
|
||||||
- Agents: make tool summaries more readable and only show optional params when set.
|
- Agents: make tool summaries more readable and only show optional params when set.
|
||||||
- CLI: prefer `~` for home paths in output.
|
|
||||||
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.
|
||||||
|
||||||| Stash base
|
||||||
|
=======
|
||||||
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
- Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.
|
||||||
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
|
>>>>>>> Stashed changes
|
||||||
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
|
|
||||||
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
|
||||||
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
|
|
||||||
- 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.
|
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
|
||||||
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
|
|
||||||
|
|
||||||
## 2026.1.21-2
|
## 2026.1.21-2
|
||||||
|
|
||||||
@@ -52,38 +50,63 @@ Docs: https://docs.clawd.bot
|
|||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|
||||||
|
### Highlights
|
||||||
|
- Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
|
- Custom assistant identity + avatars in the Control UI. https://docs.clawd.bot/cli/agents https://docs.clawd.bot/web/control-ui
|
||||||
|
- Cache optimizations: cache-ttl pruning + defaults reduce token spend on cold requests. https://docs.clawd.bot/concepts/session-pruning
|
||||||
|
- Exec approvals + elevated ask/full modes. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/elevated
|
||||||
|
- Signal typing/read receipts + MSTeams attachments. https://docs.clawd.bot/channels/signal https://docs.clawd.bot/channels/msteams
|
||||||
|
- `/models` UX refresh + `clawdbot update wizard`. https://docs.clawd.bot/cli/models https://docs.clawd.bot/cli/update
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster (#1152) Thanks @vignesh07.
|
||||||
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
|
- Agents/UI: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer. https://docs.clawd.bot/gateway/configuration https://docs.clawd.bot/cli/agents
|
||||||
- CLI: exec approvals mutations render tables instead of raw JSON.
|
- Control UI: add custom assistant identity support and per-session identity display. (#1420) Thanks @robbyczgw-cla. https://docs.clawd.bot/web/control-ui
|
||||||
- Exec approvals: support wildcard agent allowlists (`*`) across all agents.
|
- CLI: add `clawdbot update wizard` with interactive channel selection + restart prompts, plus preflight checks before rebasing. https://docs.clawd.bot/cli/update
|
||||||
- Exec approvals: allowlist matches resolved binary paths only, add safe stdin-only bins, and tighten allowlist shell parsing.
|
- Models/Commands: add `/models`, improve `/model` listing UX, and expand `clawdbot models` paging. (#1398) Thanks @vignesh07. https://docs.clawd.bot/cli/models
|
||||||
- Nodes: expose node PATH in status/describe and bootstrap PATH for node-host execution.
|
- CLI: move gateway service commands under `clawdbot gateway`, flatten node service commands under `clawdbot node`, and add `gateway probe` for reachability. https://docs.clawd.bot/cli/gateway https://docs.clawd.bot/cli/node
|
||||||
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
|
- Exec: add elevated ask/full modes, tighten allowlist gating, and render approvals tables on write. https://docs.clawd.bot/tools/elevated https://docs.clawd.bot/tools/exec-approvals
|
||||||
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
|
- Exec approvals: default to local host, add gateway/node targeting + target details, support wildcard agent allowlists, and tighten allowlist parsing/safe bins. https://docs.clawd.bot/cli/approvals https://docs.clawd.bot/tools/exec-approvals
|
||||||
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
|
- Heartbeat: allow explicit session keys and active hours. (#1256) Thanks @zknicker. https://docs.clawd.bot/gateway/heartbeat
|
||||||
|
- Sessions: add per-channel idle durations via `sessions.channelIdleMinutes`. (#1353) Thanks @cash-echo-bot.
|
||||||
|
- Nodes: run exec-style, expose PATH in status/describe, and bootstrap PATH for node-host execution. https://docs.clawd.bot/cli/node
|
||||||
|
- Cache: add `cache.ttlPrune` mode and auth-aware defaults for cache TTL behavior.
|
||||||
|
- Queue: add per-channel debounce overrides for auto-reply. https://docs.clawd.bot/concepts/queue
|
||||||
|
- Discord: add wildcard channel config support. (#1334) Thanks @pvoo. https://docs.clawd.bot/channels/discord
|
||||||
|
- Signal: add typing indicators and DM read receipts via signal-cli. https://docs.clawd.bot/channels/signal
|
||||||
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero. https://docs.clawd.bot/channels/msteams
|
||||||
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
|
- macOS: refresh Settings (location access in Permissions, connection mode in menu, remove CLI install UI).
|
||||||
|
- Diagnostics: add cache trace config for debugging. (#1370) Thanks @parubets.
|
||||||
|
- Docs: Lobster guides + org URL updates, /model allowlist troubleshooting, Gmail message search examples, gateway.mode troubleshooting, prompt injection guidance, npm prefix/node CLI notes, control UI dev gatewayUrl note, tool_use FAQ, showcase video, and sharp/node-gyp workaround. (#1427, #1220, #1405) Thanks @vignesh07, @mbelinky.
|
||||||
|
|
||||||
### Breaking
|
### Breaking
|
||||||
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
|
||||||
|
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
|
- Streaming/Typing/Media: keep reply tags across streamed chunks, start typing indicators at run start, and accept MEDIA paths with spaces/tilde while preferring the message tool hint for image replies.
|
||||||
|
- Agents/Providers: drop unsigned thinking blocks for Claude models (Google Antigravity) and enforce alphanumeric tool call ids for strict providers (Mistral/OpenRouter). (#1372) Thanks @zerone0x.
|
||||||
|
- Exec approvals: treat main as the default agent, align node/gateway allowlist prechecks, validate resolved paths, avoid allowlist resolve races, and avoid null optional params. (#1417, #1414, #1425) Thanks @czekaj.
|
||||||
|
- Exec/Windows: resolve Windows exec paths with extensions and handle safe-bin exe names.
|
||||||
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
- Nodes/macOS: prompt on allowlist miss for node exec approvals, persist allowlist decisions, and flatten node invoke errors. (#1394) Thanks @ngutman.
|
||||||
- Gateway: keep auto bind loopback-first and add explicit tailnet binding to avoid Tailscale taking over local UI. (#1380)
|
- Gateway: prevent multiple gateways from sharing the same config/state (singleton lock), keep auto bind loopback-first with explicit tailnet binding, and improve SSH auth handling. (#1380)
|
||||||
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
|
- Control UI: remove the chat stop button, keep the composer aligned to the bottom edge, stabilize session previews, and refresh the debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||||
|
- UI/config: export `SECTION_META` for config form modules. (#1418) Thanks @MaudeBot.
|
||||||
|
- macOS: keep chat pinned during streaming replies, include Textual resources, respect wildcard exec approvals, allow SSH agent auth, and default distribution builds to universal binaries. (#1279, #1362, #1384, #1396) Thanks @ameno-, @JustYannicc.
|
||||||
|
- BlueBubbles: resolve short message IDs safely, expose full IDs in templates, and harden short-id fetch wrappers. (#1369, #1387) Thanks @tyler6204.
|
||||||
|
- Models/Configure: inherit session model overrides in threads/topics, map OpenCode Zen models to the correct APIs, narrow Anthropic OAuth allowlist handling, seed allowlist fallbacks, list the full catalog when no allowlist is set, and limit `/model` list output. (#1376, #1416)
|
||||||
|
- Memory: prevent CLI hangs by deferring vector probes, add sqlite-vec/embedding timeouts, and make session memory indexing async.
|
||||||
|
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||||
|
- Cache: restore the 1h cache TTL option and reset the pruning window.
|
||||||
|
- Zalo Personal: tolerate ANSI/log-prefixed JSON output from `zca`. (#1379) Thanks @ptn1411.
|
||||||
|
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
||||||
|
- Infra: preserve fetch helper methods/preconnect when wrapping abort signals and normalize Telegram fetch aborts.
|
||||||
|
- Config/Doctor: avoid stack traces for invalid configs, log the config path, avoid WhatsApp config resurrection, and warn when `gateway.mode` is unset. (#900)
|
||||||
|
- CLI: read Codex CLI account_id for workspace billing. (#1422) Thanks @aj47.
|
||||||
|
- Logs/Status: align rolling log filenames with local time and report sandboxed runtime in `clawdbot status`. (#1343)
|
||||||
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
- Embedded runner: persist injected history images so attachments aren’t reloaded each turn. (#1374) Thanks @Nicell.
|
||||||
- Nodes tool: include agent/node/gateway context in tool failure logs to speed approval debugging.
|
- Nodes/Subagents: include agent/node/gateway context in tool failure logs and ensure subagent list uses the command session.
|
||||||
- macOS: exec approvals now respect wildcard agent allowlists (`*`).
|
|
||||||
- macOS: allow SSH agent auth when no identity file is set. (#1384) Thanks @ameno-.
|
|
||||||
- Gateway: prevent multiple gateways from sharing the same config/state at once (singleton lock).
|
|
||||||
- UI: remove the chat stop button and keep the composer aligned to the bottom edge.
|
|
||||||
- Typing: start instant typing indicators at run start so DMs and mentions show immediately.
|
|
||||||
- Configure: restrict the model allowlist picker to OAuth-compatible Anthropic models and preselect Opus 4.5.
|
|
||||||
- Configure: seed model fallbacks from the allowlist selection when multiple models are chosen.
|
|
||||||
- Model picker: list the full catalog when no model allowlist is configured.
|
|
||||||
- Discord: honor wildcard channel configs via shared match helpers. (#1334) Thanks @pvoo.
|
|
||||||
- BlueBubbles: resolve short message IDs safely and expose full IDs in templates. (#1387) Thanks @tyler6204.
|
|
||||||
- Infra: preserve fetch helper methods when wrapping abort signals. (#1387)
|
|
||||||
- macOS: default distribution packaging to universal binaries. (#1396) Thanks @JustYannicc.
|
|
||||||
|
|
||||||
## 2026.1.20
|
## 2026.1.20
|
||||||
|
|
||||||
|
|||||||
@@ -477,29 +477,28 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
|
|||||||
Thanks to all clawtributors:
|
Thanks to all clawtributors:
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a>
|
<a href="https://github.com/steipete"><img src="https://avatars.githubusercontent.com/u/58493?v=4&s=48" width="48" height="48" alt="steipete" title="steipete"/></a> <a href="https://github.com/bohdanpodvirnyi"><img src="https://avatars.githubusercontent.com/u/31819391?v=4&s=48" width="48" height="48" alt="bohdanpodvirnyi" title="bohdanpodvirnyi"/></a> <a href="https://github.com/joaohlisboa"><img src="https://avatars.githubusercontent.com/u/8200873?v=4&s=48" width="48" height="48" alt="joaohlisboa" title="joaohlisboa"/></a> <a href="https://github.com/mneves75"><img src="https://avatars.githubusercontent.com/u/2423436?v=4&s=48" width="48" height="48" alt="mneves75" title="mneves75"/></a> <a href="https://github.com/MatthieuBizien"><img src="https://avatars.githubusercontent.com/u/173090?v=4&s=48" width="48" height="48" alt="MatthieuBizien" title="MatthieuBizien"/></a> <a href="https://github.com/MaudeBot"><img src="https://avatars.githubusercontent.com/u/255777700?v=4&s=48" width="48" height="48" alt="MaudeBot" title="MaudeBot"/></a> <a href="https://github.com/rahthakor"><img src="https://avatars.githubusercontent.com/u/8470553?v=4&s=48" width="48" height="48" alt="rahthakor" title="rahthakor"/></a> <a href="https://github.com/vrknetha"><img src="https://avatars.githubusercontent.com/u/20596261?v=4&s=48" width="48" height="48" alt="vrknetha" title="vrknetha"/></a> <a href="https://github.com/radek-paclt"><img src="https://avatars.githubusercontent.com/u/50451445?v=4&s=48" width="48" height="48" alt="radek-paclt" title="radek-paclt"/></a> <a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a>
|
||||||
<a href="https://github.com/joshp123"><img src="https://avatars.githubusercontent.com/u/1497361?v=4&s=48" width="48" height="48" alt="joshp123" title="joshp123"/></a> <a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
<a href="https://github.com/mukhtharcm"><img src="https://avatars.githubusercontent.com/u/56378562?v=4&s=48" width="48" height="48" alt="mukhtharcm" title="mukhtharcm"/></a> <a href="https://github.com/maxsumrall"><img src="https://avatars.githubusercontent.com/u/628843?v=4&s=48" width="48" height="48" alt="maxsumrall" title="maxsumrall"/></a> <a href="https://github.com/xadenryan"><img src="https://avatars.githubusercontent.com/u/165437834?v=4&s=48" width="48" height="48" alt="xadenryan" title="xadenryan"/></a> <a href="https://github.com/tobiasbischoff"><img src="https://avatars.githubusercontent.com/u/711564?v=4&s=48" width="48" height="48" alt="Tobias Bischoff" title="Tobias Bischoff"/></a> <a href="https://github.com/juanpablodlc"><img src="https://avatars.githubusercontent.com/u/92012363?v=4&s=48" width="48" height="48" alt="juanpablodlc" title="juanpablodlc"/></a> <a href="https://github.com/hsrvc"><img src="https://avatars.githubusercontent.com/u/129702169?v=4&s=48" width="48" height="48" alt="hsrvc" title="hsrvc"/></a> <a href="https://github.com/magimetal"><img src="https://avatars.githubusercontent.com/u/36491250?v=4&s=48" width="48" height="48" alt="magimetal" title="magimetal"/></a> <a href="https://github.com/meaningfool"><img src="https://avatars.githubusercontent.com/u/2862331?v=4&s=48" width="48" height="48" alt="meaningfool" title="meaningfool"/></a> <a href="https://github.com/NicholasSpisak"><img src="https://avatars.githubusercontent.com/u/129075147?v=4&s=48" width="48" height="48" alt="NicholasSpisak" title="NicholasSpisak"/></a> <a href="https://github.com/sebslight"><img src="https://avatars.githubusercontent.com/u/19554889?v=4&s=48" width="48" height="48" alt="sebslight" title="sebslight"/></a>
|
||||||
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
<a href="https://github.com/AbhisekBasu1"><img src="https://avatars.githubusercontent.com/u/40645221?v=4&s=48" width="48" height="48" alt="abhisekbasu1" title="abhisekbasu1"/></a> <a href="https://github.com/jamesgroat"><img src="https://avatars.githubusercontent.com/u/2634024?v=4&s=48" width="48" height="48" alt="jamesgroat" title="jamesgroat"/></a> <a href="https://github.com/zerone0x"><img src="https://avatars.githubusercontent.com/u/39543393?v=4&s=48" width="48" height="48" alt="zerone0x" title="zerone0x"/></a> <a href="https://github.com/claude"><img src="https://avatars.githubusercontent.com/u/81847?v=4&s=48" width="48" height="48" alt="claude" title="claude"/></a> <a href="https://github.com/SocialNerd42069"><img src="https://avatars.githubusercontent.com/u/118244303?v=4&s=48" width="48" height="48" alt="SocialNerd42069" title="SocialNerd42069"/></a> <a href="https://github.com/Hyaxia"><img src="https://avatars.githubusercontent.com/u/36747317?v=4&s=48" width="48" height="48" alt="Hyaxia" title="Hyaxia"/></a> <a href="https://github.com/dantelex"><img src="https://avatars.githubusercontent.com/u/631543?v=4&s=48" width="48" height="48" alt="dantelex" title="dantelex"/></a> <a href="https://github.com/daveonkels"><img src="https://avatars.githubusercontent.com/u/533642?v=4&s=48" width="48" height="48" alt="daveonkels" title="daveonkels"/></a> <a href="https://github.com/mteam88"><img src="https://avatars.githubusercontent.com/u/84196639?v=4&s=48" width="48" height="48" alt="mteam88" title="mteam88"/></a> <a href="https://github.com/omniwired"><img src="https://avatars.githubusercontent.com/u/322761?v=4&s=48" width="48" height="48" alt="Eng. Juan Combetto" title="Eng. Juan Combetto"/></a>
|
||||||
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
<a href="https://github.com/mbelinky"><img src="https://avatars.githubusercontent.com/u/132747814?v=4&s=48" width="48" height="48" alt="Mariano Belinky" title="Mariano Belinky"/></a> <a href="https://github.com/dbhurley"><img src="https://avatars.githubusercontent.com/u/5251425?v=4&s=48" width="48" height="48" alt="dbhurley" title="dbhurley"/></a> <a href="https://github.com/TSavo"><img src="https://avatars.githubusercontent.com/u/877990?v=4&s=48" width="48" height="48" alt="TSavo" title="TSavo"/></a> <a href="https://github.com/julianengel"><img src="https://avatars.githubusercontent.com/u/10634231?v=4&s=48" width="48" height="48" alt="julianengel" title="julianengel"/></a> <a href="https://github.com/benithors"><img src="https://avatars.githubusercontent.com/u/20652882?v=4&s=48" width="48" height="48" alt="benithors" title="benithors"/></a> <a href="https://github.com/bradleypriest"><img src="https://avatars.githubusercontent.com/u/167215?v=4&s=48" width="48" height="48" alt="bradleypriest" title="bradleypriest"/></a> <a href="https://github.com/timolins"><img src="https://avatars.githubusercontent.com/u/1440854?v=4&s=48" width="48" height="48" alt="timolins" title="timolins"/></a> <a href="https://github.com/Nachx639"><img src="https://avatars.githubusercontent.com/u/71144023?v=4&s=48" width="48" height="48" alt="nachx639" title="nachx639"/></a> <a href="https://github.com/sreekaransrinath"><img src="https://avatars.githubusercontent.com/u/50989977?v=4&s=48" width="48" height="48" alt="sreekaransrinath" title="sreekaransrinath"/></a> <a href="https://github.com/gupsammy"><img src="https://avatars.githubusercontent.com/u/20296019?v=4&s=48" width="48" height="48" alt="gupsammy" title="gupsammy"/></a>
|
||||||
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
<a href="https://github.com/cristip73"><img src="https://avatars.githubusercontent.com/u/24499421?v=4&s=48" width="48" height="48" alt="cristip73" title="cristip73"/></a> <a href="https://github.com/nachoiacovino"><img src="https://avatars.githubusercontent.com/u/50103937?v=4&s=48" width="48" height="48" alt="nachoiacovino" title="nachoiacovino"/></a> <a href="https://github.com/vsabavat"><img src="https://avatars.githubusercontent.com/u/50385532?v=4&s=48" width="48" height="48" alt="Vasanth Rao Naik Sabavat" title="Vasanth Rao Naik Sabavat"/></a> <a href="https://github.com/cpojer"><img src="https://avatars.githubusercontent.com/u/13352?v=4&s=48" width="48" height="48" alt="cpojer" title="cpojer"/></a> <a href="https://github.com/lc0rp"><img src="https://avatars.githubusercontent.com/u/2609441?v=4&s=48" width="48" height="48" alt="lc0rp" title="lc0rp"/></a> <a href="https://github.com/scald"><img src="https://avatars.githubusercontent.com/u/1215913?v=4&s=48" width="48" height="48" alt="scald" title="scald"/></a> <a href="https://github.com/gumadeiras"><img src="https://avatars.githubusercontent.com/u/5599352?v=4&s=48" width="48" height="48" alt="gumadeiras" title="gumadeiras"/></a> <a href="https://github.com/andranik-sahakyan"><img src="https://avatars.githubusercontent.com/u/8908029?v=4&s=48" width="48" height="48" alt="andranik-sahakyan" title="andranik-sahakyan"/></a> <a href="https://github.com/davidguttman"><img src="https://avatars.githubusercontent.com/u/431696?v=4&s=48" width="48" height="48" alt="davidguttman" title="davidguttman"/></a> <a href="https://github.com/sleontenko"><img src="https://avatars.githubusercontent.com/u/7135949?v=4&s=48" width="48" height="48" alt="sleontenko" title="sleontenko"/></a>
|
||||||
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
<a href="https://github.com/sircrumpet"><img src="https://avatars.githubusercontent.com/u/4436535?v=4&s=48" width="48" height="48" alt="sircrumpet" title="sircrumpet"/></a> <a href="https://github.com/peschee"><img src="https://avatars.githubusercontent.com/u/63866?v=4&s=48" width="48" height="48" alt="peschee" title="peschee"/></a> <a href="https://github.com/rafaelreis-r"><img src="https://avatars.githubusercontent.com/u/57492577?v=4&s=48" width="48" height="48" alt="rafaelreis-r" title="rafaelreis-r"/></a> <a href="https://github.com/thewilloftheshadow"><img src="https://avatars.githubusercontent.com/u/35580099?v=4&s=48" width="48" height="48" alt="thewilloftheshadow" title="thewilloftheshadow"/></a> <a href="https://github.com/ratulsarna"><img src="https://avatars.githubusercontent.com/u/105903728?v=4&s=48" width="48" height="48" alt="ratulsarna" title="ratulsarna"/></a> <a href="https://github.com/lutr0"><img src="https://avatars.githubusercontent.com/u/76906369?v=4&s=48" width="48" height="48" alt="lutr0" title="lutr0"/></a> <a href="https://github.com/danielz1z"><img src="https://avatars.githubusercontent.com/u/235270390?v=4&s=48" width="48" height="48" alt="danielz1z" title="danielz1z"/></a> <a href="https://github.com/emanuelst"><img src="https://avatars.githubusercontent.com/u/9994339?v=4&s=48" width="48" height="48" alt="emanuelst" title="emanuelst"/></a> <a href="https://github.com/KristijanJovanovski"><img src="https://avatars.githubusercontent.com/u/8942284?v=4&s=48" width="48" height="48" alt="KristijanJovanovski" title="KristijanJovanovski"/></a> <a href="https://github.com/CashWilliams"><img src="https://avatars.githubusercontent.com/u/613573?v=4&s=48" width="48" height="48" alt="CashWilliams" title="CashWilliams"/></a>
|
||||||
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
<a href="https://github.com/rdev"><img src="https://avatars.githubusercontent.com/u/8418866?v=4&s=48" width="48" height="48" alt="rdev" title="rdev"/></a> <a href="https://github.com/osolmaz"><img src="https://avatars.githubusercontent.com/u/2453968?v=4&s=48" width="48" height="48" alt="osolmaz" title="osolmaz"/></a> <a href="https://github.com/joshrad-dev"><img src="https://avatars.githubusercontent.com/u/62785552?v=4&s=48" width="48" height="48" alt="joshrad-dev" title="joshrad-dev"/></a> <a href="https://github.com/kiranjd"><img src="https://avatars.githubusercontent.com/u/25822851?v=4&s=48" width="48" height="48" alt="kiranjd" title="kiranjd"/></a> <a href="https://github.com/adityashaw2"><img src="https://avatars.githubusercontent.com/u/41204444?v=4&s=48" width="48" height="48" alt="adityashaw2" title="adityashaw2"/></a> <a href="https://github.com/search?q=sheeek"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="sheeek" title="sheeek"/></a> <a href="https://github.com/artuskg"><img src="https://avatars.githubusercontent.com/u/11966157?v=4&s=48" width="48" height="48" alt="artuskg" title="artuskg"/></a> <a href="https://github.com/onutc"><img src="https://avatars.githubusercontent.com/u/152018508?v=4&s=48" width="48" height="48" alt="onutc" title="onutc"/></a> <a href="https://github.com/tyler6204"><img src="https://avatars.githubusercontent.com/u/64381258?v=4&s=48" width="48" height="48" alt="tyler6204" title="tyler6204"/></a> <a href="https://github.com/ManuelHettich"><img src="https://avatars.githubusercontent.com/u/17690367?v=4&s=48" width="48" height="48" alt="manuelhettich" title="manuelhettich"/></a>
|
||||||
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
<a href="https://github.com/minghinmatthewlam"><img src="https://avatars.githubusercontent.com/u/14224566?v=4&s=48" width="48" height="48" alt="minghinmatthewlam" title="minghinmatthewlam"/></a> <a href="https://github.com/myfunc"><img src="https://avatars.githubusercontent.com/u/19294627?v=4&s=48" width="48" height="48" alt="myfunc" title="myfunc"/></a> <a href="https://github.com/buddyh"><img src="https://avatars.githubusercontent.com/u/31752869?v=4&s=48" width="48" height="48" alt="buddyh" title="buddyh"/></a> <a href="https://github.com/connorshea"><img src="https://avatars.githubusercontent.com/u/2977353?v=4&s=48" width="48" height="48" alt="connorshea" title="connorshea"/></a> <a href="https://github.com/vignesh07"><img src="https://avatars.githubusercontent.com/u/1436853?v=4&s=48" width="48" height="48" alt="vignesh07" title="vignesh07"/></a> <a href="https://github.com/mcinteerj"><img src="https://avatars.githubusercontent.com/u/3613653?v=4&s=48" width="48" height="48" alt="mcinteerj" title="mcinteerj"/></a> <a href="https://github.com/apps/dependabot"><img src="https://avatars.githubusercontent.com/in/29110?v=4&s=48" width="48" height="48" alt="dependabot[bot]" title="dependabot[bot]"/></a> <a href="https://github.com/John-Rood"><img src="https://avatars.githubusercontent.com/u/62669593?v=4&s=48" width="48" height="48" alt="John-Rood" title="John-Rood"/></a> <a href="https://github.com/timkrase"><img src="https://avatars.githubusercontent.com/u/38947626?v=4&s=48" width="48" height="48" alt="timkrase" title="timkrase"/></a> <a href="https://github.com/gerardward2007"><img src="https://avatars.githubusercontent.com/u/3002155?v=4&s=48" width="48" height="48" alt="gerardward2007" title="gerardward2007"/></a>
|
||||||
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
|
<a href="https://github.com/obviyus"><img src="https://avatars.githubusercontent.com/u/22031114?v=4&s=48" width="48" height="48" alt="obviyus" title="obviyus"/></a> <a href="https://github.com/tosh-hamburg"><img src="https://avatars.githubusercontent.com/u/58424326?v=4&s=48" width="48" height="48" alt="tosh-hamburg" title="tosh-hamburg"/></a> <a href="https://github.com/azade-c"><img src="https://avatars.githubusercontent.com/u/252790079?v=4&s=48" width="48" height="48" alt="azade-c" title="azade-c"/></a> <a href="https://github.com/roshanasingh4"><img src="https://avatars.githubusercontent.com/u/88576930?v=4&s=48" width="48" height="48" alt="roshanasingh4" title="roshanasingh4"/></a> <a href="https://github.com/bjesuiter"><img src="https://avatars.githubusercontent.com/u/2365676?v=4&s=48" width="48" height="48" alt="bjesuiter" title="bjesuiter"/></a> <a href="https://github.com/cheeeee"><img src="https://avatars.githubusercontent.com/u/21245729?v=4&s=48" width="48" height="48" alt="cheeeee" title="cheeeee"/></a> <a href="https://github.com/j1philli"><img src="https://avatars.githubusercontent.com/u/3744255?v=4&s=48" width="48" height="48" alt="Josh Phillips" title="Josh Phillips"/></a> <a href="https://github.com/Whoaa512"><img src="https://avatars.githubusercontent.com/u/1581943?v=4&s=48" width="48" height="48" alt="Whoaa512" title="Whoaa512"/></a> <a href="https://github.com/YuriNachos"><img src="https://avatars.githubusercontent.com/u/19365375?v=4&s=48" width="48" height="48" alt="YuriNachos" title="YuriNachos"/></a> <a href="https://github.com/chriseidhof"><img src="https://avatars.githubusercontent.com/u/5382?v=4&s=48" width="48" height="48" alt="chriseidhof" title="chriseidhof"/></a>
|
||||||
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/damoahdominic"><img src="https://avatars.githubusercontent.com/u/4623434?v=4&s=48" width="48" height="48" alt="damoahdominic" title="damoahdominic"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a>
|
<a href="https://github.com/ysqander"><img src="https://avatars.githubusercontent.com/u/80843820?v=4&s=48" width="48" height="48" alt="ysqander" title="ysqander"/></a> <a href="https://github.com/superman32432432"><img src="https://avatars.githubusercontent.com/u/7228420?v=4&s=48" width="48" height="48" alt="superman32432432" title="superman32432432"/></a> <a href="https://github.com/search?q=Yurii%20Chukhlib"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Yurii Chukhlib" title="Yurii Chukhlib"/></a> <a href="https://github.com/grp06"><img src="https://avatars.githubusercontent.com/u/1573959?v=4&s=48" width="48" height="48" alt="grp06" title="grp06"/></a> <a href="https://github.com/antons"><img src="https://avatars.githubusercontent.com/u/129705?v=4&s=48" width="48" height="48" alt="antons" title="antons"/></a> <a href="https://github.com/austinm911"><img src="https://avatars.githubusercontent.com/u/31991302?v=4&s=48" width="48" height="48" alt="austinm911" title="austinm911"/></a> <a href="https://github.com/apps/blacksmith-sh"><img src="https://avatars.githubusercontent.com/in/807020?v=4&s=48" width="48" height="48" alt="blacksmith-sh[bot]" title="blacksmith-sh[bot]"/></a> <a href="https://github.com/dan-dr"><img src="https://avatars.githubusercontent.com/u/6669808?v=4&s=48" width="48" height="48" alt="dan-dr" title="dan-dr"/></a> <a href="https://github.com/dlauer"><img src="https://avatars.githubusercontent.com/u/757041?v=4&s=48" width="48" height="48" alt="dlauer" title="dlauer"/></a> <a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a>
|
||||||
<a href="https://github.com/HeimdallStrategy"><img src="https://avatars.githubusercontent.com/u/223014405?v=4&s=48" width="48" height="48" alt="HeimdallStrategy" title="HeimdallStrategy"/></a> <a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a>
|
<a href="https://github.com/imfing"><img src="https://avatars.githubusercontent.com/u/5097752?v=4&s=48" width="48" height="48" alt="imfing" title="imfing"/></a> <a href="https://github.com/jalehman"><img src="https://avatars.githubusercontent.com/u/550978?v=4&s=48" width="48" height="48" alt="jalehman" title="jalehman"/></a> <a href="https://github.com/jarvis-medmatic"><img src="https://avatars.githubusercontent.com/u/252428873?v=4&s=48" width="48" height="48" alt="jarvis-medmatic" title="jarvis-medmatic"/></a> <a href="https://github.com/kkarimi"><img src="https://avatars.githubusercontent.com/u/875218?v=4&s=48" width="48" height="48" alt="kkarimi" title="kkarimi"/></a> <a href="https://github.com/mahmoudashraf93"><img src="https://avatars.githubusercontent.com/u/9130129?v=4&s=48" width="48" height="48" alt="mahmoudashraf93" title="mahmoudashraf93"/></a> <a href="https://github.com/ngutman"><img src="https://avatars.githubusercontent.com/u/1540134?v=4&s=48" width="48" height="48" alt="ngutman" title="ngutman"/></a> <a href="https://github.com/petter-b"><img src="https://avatars.githubusercontent.com/u/62076402?v=4&s=48" width="48" height="48" alt="petter-b" title="petter-b"/></a> <a href="https://github.com/pkrmf"><img src="https://avatars.githubusercontent.com/u/1714267?v=4&s=48" width="48" height="48" alt="pkrmf" title="pkrmf"/></a> <a href="https://github.com/RandyVentures"><img src="https://avatars.githubusercontent.com/u/149904821?v=4&s=48" width="48" height="48" alt="RandyVentures" title="RandyVentures"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a>
|
||||||
<a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/search?q=Ryan%20Lisse"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ryan Lisse" title="Ryan Lisse"/></a> <a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a>
|
<a href="https://github.com/dougvk"><img src="https://avatars.githubusercontent.com/u/401660?v=4&s=48" width="48" height="48" alt="dougvk" title="dougvk"/></a> <a href="https://github.com/erikpr1994"><img src="https://avatars.githubusercontent.com/u/6299331?v=4&s=48" width="48" height="48" alt="erikpr1994" title="erikpr1994"/></a> <a href="https://github.com/search?q=Ghost"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ghost" title="Ghost"/></a> <a href="https://github.com/jonasjancarik"><img src="https://avatars.githubusercontent.com/u/2459191?v=4&s=48" width="48" height="48" alt="jonasjancarik" title="jonasjancarik"/></a> <a href="https://github.com/search?q=Keith%20the%20Silly%20Goose"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Keith the Silly Goose" title="Keith the Silly Goose"/></a> <a href="https://github.com/search?q=L36%20Server"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="L36 Server" title="L36 Server"/></a> <a href="https://github.com/search?q=Marc"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Marc" title="Marc"/></a> <a href="https://github.com/mitschabaude-bot"><img src="https://avatars.githubusercontent.com/u/247582884?v=4&s=48" width="48" height="48" alt="mitschabaude-bot" title="mitschabaude-bot"/></a> <a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a>
|
||||||
<a href="https://github.com/mkbehr"><img src="https://avatars.githubusercontent.com/u/1285?v=4&s=48" width="48" height="48" alt="mkbehr" title="mkbehr"/></a> <a href="https://github.com/neist"><img src="https://avatars.githubusercontent.com/u/1029724?v=4&s=48" width="48" height="48" alt="neist" title="neist"/></a> <a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a>
|
<a href="https://github.com/chrisrodz"><img src="https://avatars.githubusercontent.com/u/2967620?v=4&s=48" width="48" height="48" alt="chrisrodz" title="chrisrodz"/></a> <a href="https://github.com/search?q=Friederike%20Seiler"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Friederike Seiler" title="Friederike Seiler"/></a> <a href="https://github.com/gabriel-trigo"><img src="https://avatars.githubusercontent.com/u/38991125?v=4&s=48" width="48" height="48" alt="gabriel-trigo" title="gabriel-trigo"/></a> <a href="https://github.com/Iamadig"><img src="https://avatars.githubusercontent.com/u/102129234?v=4&s=48" width="48" height="48" alt="iamadig" title="iamadig"/></a> <a href="https://github.com/search?q=Kit"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kit" title="Kit"/></a> <a href="https://github.com/koala73"><img src="https://avatars.githubusercontent.com/u/996596?v=4&s=48" width="48" height="48" alt="koala73" title="koala73"/></a> <a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a>
|
||||||
<a href="https://github.com/manmal"><img src="https://avatars.githubusercontent.com/u/142797?v=4&s=48" width="48" height="48" alt="manmal" title="manmal"/></a> <a href="https://github.com/ogulcancelik"><img src="https://avatars.githubusercontent.com/u/7064011?v=4&s=48" width="48" height="48" alt="ogulcancelik" title="ogulcancelik"/></a> <a href="https://github.com/pasogott"><img src="https://avatars.githubusercontent.com/u/23458152?v=4&s=48" width="48" height="48" alt="pasogott" title="pasogott"/></a> <a href="https://github.com/petradonka"><img src="https://avatars.githubusercontent.com/u/7353770?v=4&s=48" width="48" height="48" alt="petradonka" title="petradonka"/></a> <a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a>
|
<a href="https://github.com/rubyrunsstuff"><img src="https://avatars.githubusercontent.com/u/246602379?v=4&s=48" width="48" height="48" alt="rubyrunsstuff" title="rubyrunsstuff"/></a> <a href="https://github.com/sibbl"><img src="https://avatars.githubusercontent.com/u/866535?v=4&s=48" width="48" height="48" alt="sibbl" title="sibbl"/></a> <a href="https://github.com/siddhantjain"><img src="https://avatars.githubusercontent.com/u/4835232?v=4&s=48" width="48" height="48" alt="siddhantjain" title="siddhantjain"/></a> <a href="https://github.com/suminhthanh"><img src="https://avatars.githubusercontent.com/u/2907636?v=4&s=48" width="48" height="48" alt="suminhthanh" title="suminhthanh"/></a> <a href="https://github.com/VACInc"><img src="https://avatars.githubusercontent.com/u/3279061?v=4&s=48" width="48" height="48" alt="VACInc" title="VACInc"/></a> <a href="https://github.com/wes-davis"><img src="https://avatars.githubusercontent.com/u/16506720?v=4&s=48" width="48" height="48" alt="wes-davis" title="wes-davis"/></a> <a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/czekaj"><img src="https://avatars.githubusercontent.com/u/1464539?v=4&s=48" width="48" height="48" alt="czekaj" title="czekaj"/></a>
|
||||||
<a href="https://github.com/zats"><img src="https://avatars.githubusercontent.com/u/2688806?v=4&s=48" width="48" height="48" alt="zats" title="zats"/></a> <a href="https://github.com/24601"><img src="https://avatars.githubusercontent.com/u/1157207?v=4&s=48" width="48" height="48" alt="24601" title="24601"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/search?q=Chris%20Taylor"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Chris Taylor" title="Chris Taylor"/></a> <a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a>
|
<a href="https://github.com/djangonavarro220"><img src="https://avatars.githubusercontent.com/u/251162586?v=4&s=48" width="48" height="48" alt="Django Navarro" title="Django Navarro"/></a> <a href="https://github.com/evalexpr"><img src="https://avatars.githubusercontent.com/u/23485511?v=4&s=48" width="48" height="48" alt="evalexpr" title="evalexpr"/></a> <a href="https://github.com/henrino3"><img src="https://avatars.githubusercontent.com/u/4260288?v=4&s=48" width="48" height="48" alt="henrino3" title="henrino3"/></a> <a href="https://github.com/humanwritten"><img src="https://avatars.githubusercontent.com/u/206531610?v=4&s=48" width="48" height="48" alt="humanwritten" title="humanwritten"/></a> <a href="https://github.com/larlyssa"><img src="https://avatars.githubusercontent.com/u/13128869?v=4&s=48" width="48" height="48" alt="larlyssa" title="larlyssa"/></a> <a href="https://github.com/oswalpalash"><img src="https://avatars.githubusercontent.com/u/6431196?v=4&s=48" width="48" height="48" alt="oswalpalash" title="oswalpalash"/></a> <a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a>
|
||||||
<a href="https://github.com/pcty-nextgen-service-account"><img src="https://avatars.githubusercontent.com/u/112553441?v=4&s=48" width="48" height="48" alt="pcty-nextgen-service-account" title="pcty-nextgen-service-account"/></a> <a href="https://github.com/Syhids"><img src="https://avatars.githubusercontent.com/u/671202?v=4&s=48" width="48" height="48" alt="Syhids" title="Syhids"/></a> <a href="https://github.com/search?q=Aaron%20Konyer"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Aaron Konyer" title="Aaron Konyer"/></a> <a href="https://github.com/aaronveklabs"><img src="https://avatars.githubusercontent.com/u/225997828?v=4&s=48" width="48" height="48" alt="aaronveklabs" title="aaronveklabs"/></a> <a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a>
|
<a href="https://github.com/adam91holt"><img src="https://avatars.githubusercontent.com/u/9592417?v=4&s=48" width="48" height="48" alt="adam91holt" title="adam91holt"/></a> <a href="https://github.com/ameno-"><img src="https://avatars.githubusercontent.com/u/2416135?v=4&s=48" width="48" height="48" alt="ameno-" title="ameno-"/></a> <a href="https://github.com/cash-echo-bot"><img src="https://avatars.githubusercontent.com/u/252747386?v=4&s=48" width="48" height="48" alt="cash-echo-bot" title="cash-echo-bot"/></a> <a href="https://github.com/search?q=Clawd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Clawd" title="Clawd"/></a> <a href="https://github.com/search?q=ClawdFx"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ClawdFx" title="ClawdFx"/></a> <a href="https://github.com/erik-agens"><img src="https://avatars.githubusercontent.com/u/80908960?v=4&s=48" width="48" height="48" alt="erik-agens" title="erik-agens"/></a> <a href="https://github.com/fcatuhe"><img src="https://avatars.githubusercontent.com/u/17382215?v=4&s=48" width="48" height="48" alt="fcatuhe" title="fcatuhe"/></a> <a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a>
|
||||||
<a href="https://github.com/ivanrvpereira"><img src="https://avatars.githubusercontent.com/u/183991?v=4&s=48" width="48" height="48" alt="ivanrvpereira" title="ivanrvpereira"/></a> <a href="https://github.com/jayhickey"><img src="https://avatars.githubusercontent.com/u/1676460?v=4&s=48" width="48" height="48" alt="jayhickey" title="jayhickey"/></a> <a href="https://github.com/jeffersonwarrior"><img src="https://avatars.githubusercontent.com/u/89030989?v=4&s=48" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a>
|
<a href="https://github.com/search?q=jeffersonwarrior"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="jeffersonwarrior" title="jeffersonwarrior"/></a> <a href="https://github.com/jdrhyne"><img src="https://avatars.githubusercontent.com/u/7828464?v=4&s=48" width="48" height="48" alt="Jonathan D. Rhyne (DJ-D)" title="Jonathan D. Rhyne (DJ-D)"/></a> <a href="https://github.com/jverdi"><img src="https://avatars.githubusercontent.com/u/345050?v=4&s=48" width="48" height="48" alt="jverdi" title="jverdi"/></a> <a href="https://github.com/longmaba"><img src="https://avatars.githubusercontent.com/u/9361500?v=4&s=48" width="48" height="48" alt="longmaba" title="longmaba"/></a> <a href="https://github.com/mickahouan"><img src="https://avatars.githubusercontent.com/u/31423109?v=4&s=48" width="48" height="48" alt="mickahouan" title="mickahouan"/></a> <a href="https://github.com/mjrussell"><img src="https://avatars.githubusercontent.com/u/1641895?v=4&s=48" width="48" height="48" alt="mjrussell" title="mjrussell"/></a> <a href="https://github.com/p6l-richard"><img src="https://avatars.githubusercontent.com/u/18185649?v=4&s=48" width="48" height="48" alt="p6l-richard" title="p6l-richard"/></a> <a href="https://github.com/philipp-spiess"><img src="https://avatars.githubusercontent.com/u/458591?v=4&s=48" width="48" height="48" alt="philipp-spiess" title="philipp-spiess"/></a> <a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a>
|
||||||
<a href="https://github.com/robaxelsen"><img src="https://avatars.githubusercontent.com/u/13132899?v=4&s=48" width="48" height="48" alt="robaxelsen" title="robaxelsen"/></a> <a href="https://github.com/search?q=Sash%20Catanzarite"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Sash Catanzarite" title="Sash Catanzarite"/></a> <a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a>
|
<a href="https://github.com/T5-AndyML"><img src="https://avatars.githubusercontent.com/u/22801233?v=4&s=48" width="48" height="48" alt="T5-AndyML" title="T5-AndyML"/></a> <a href="https://github.com/search?q=VAC"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="VAC" title="VAC"/></a> <a href="https://github.com/zknicker"><img src="https://avatars.githubusercontent.com/u/1164085?v=4&s=48" width="48" height="48" alt="zknicker" title="zknicker"/></a> <a href="https://github.com/aj47"><img src="https://avatars.githubusercontent.com/u/8023513?v=4&s=48" width="48" height="48" alt="aj47" title="aj47"/></a> <a href="https://github.com/search?q=alejandro%20maza"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="alejandro maza" title="alejandro maza"/></a> <a href="https://github.com/andrewting19"><img src="https://avatars.githubusercontent.com/u/10536704?v=4&s=48" width="48" height="48" alt="andrewting19" title="andrewting19"/></a> <a href="https://github.com/search?q=Andrii"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Andrii" title="Andrii"/></a> <a href="https://github.com/anpoirier"><img src="https://avatars.githubusercontent.com/u/1245729?v=4&s=48" width="48" height="48" alt="anpoirier" title="anpoirier"/></a> <a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a>
|
||||||
<a href="https://github.com/Asleep123"><img src="https://avatars.githubusercontent.com/u/122379135?v=4&s=48" width="48" height="48" alt="Asleep123" title="Asleep123"/></a> <a href="https://github.com/bolismauro"><img src="https://avatars.githubusercontent.com/u/771999?v=4&s=48" width="48" height="48" alt="bolismauro" title="bolismauro"/></a> <a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a>
|
<a href="https://github.com/conhecendoia"><img src="https://avatars.githubusercontent.com/u/82890727?v=4&s=48" width="48" height="48" alt="conhecendoia" title="conhecendoia"/></a> <a href="https://github.com/search?q=Dimitrios%20Ploutarchos"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Dimitrios Ploutarchos" title="Dimitrios Ploutarchos"/></a> <a href="https://github.com/search?q=Drake%20Thomsen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Drake Thomsen" title="Drake Thomsen"/></a> <a href="https://github.com/search?q=Felix%20Krause"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Felix Krause" title="Felix Krause"/></a> <a href="https://github.com/gtsifrikas"><img src="https://avatars.githubusercontent.com/u/8904378?v=4&s=48" width="48" height="48" alt="gtsifrikas" title="gtsifrikas"/></a> <a href="https://github.com/HazAT"><img src="https://avatars.githubusercontent.com/u/363802?v=4&s=48" width="48" height="48" alt="HazAT" title="HazAT"/></a> <a href="https://github.com/hrdwdmrbl"><img src="https://avatars.githubusercontent.com/u/554881?v=4&s=48" width="48" height="48" alt="hrdwdmrbl" title="hrdwdmrbl"/></a> <a href="https://github.com/hugobarauna"><img src="https://avatars.githubusercontent.com/u/2719?v=4&s=48" width="48" height="48" alt="hugobarauna" title="hugobarauna"/></a> <a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a>
|
||||||
<a href="https://github.com/search?q=Jamie%20Openshaw"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jamie Openshaw" title="Jamie Openshaw"/></a> <a href="https://github.com/search?q=Jarvis"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jarvis" title="Jarvis"/></a> <a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Matt%20mini"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Matt mini" title="Matt mini"/></a>
|
<a href="https://github.com/search?q=Jefferson%20Nunn"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Jefferson Nunn" title="Jefferson Nunn"/></a> <a href="https://github.com/search?q=Kevin%20Lin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Kevin Lin" title="Kevin Lin"/></a> <a href="https://github.com/kitze"><img src="https://avatars.githubusercontent.com/u/1160594?v=4&s=48" width="48" height="48" alt="kitze" title="kitze"/></a> <a href="https://github.com/levifig"><img src="https://avatars.githubusercontent.com/u/1605?v=4&s=48" width="48" height="48" alt="levifig" title="levifig"/></a> <a href="https://github.com/search?q=Lloyd"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Lloyd" title="Lloyd"/></a> <a href="https://github.com/loukotal"><img src="https://avatars.githubusercontent.com/u/18210858?v=4&s=48" width="48" height="48" alt="loukotal" title="loukotal"/></a> <a href="https://github.com/martinpucik"><img src="https://avatars.githubusercontent.com/u/5503097?v=4&s=48" width="48" height="48" alt="martinpucik" title="martinpucik"/></a> <a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a>
|
||||||
<a href="https://github.com/search?q=Miles"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Miles" title="Miles"/></a> <a href="https://github.com/mrdbstn"><img src="https://avatars.githubusercontent.com/u/58957632?v=4&s=48" width="48" height="48" alt="mrdbstn" title="mrdbstn"/></a> <a href="https://github.com/MSch"><img src="https://avatars.githubusercontent.com/u/7475?v=4&s=48" width="48" height="48" alt="MSch" title="MSch"/></a> <a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a>
|
<a href="https://github.com/search?q=Mustafa%20Tag%20Eldeen"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mustafa Tag Eldeen" title="Mustafa Tag Eldeen"/></a> <a href="https://github.com/ndraiman"><img src="https://avatars.githubusercontent.com/u/12609607?v=4&s=48" width="48" height="48" alt="ndraiman" title="ndraiman"/></a> <a href="https://github.com/nexty5870"><img src="https://avatars.githubusercontent.com/u/3869659?v=4&s=48" width="48" height="48" alt="nexty5870" title="nexty5870"/></a> <a href="https://github.com/odysseus0"><img src="https://avatars.githubusercontent.com/u/8635094?v=4&s=48" width="48" height="48" alt="odysseus0" title="odysseus0"/></a> <a href="https://github.com/prathamdby"><img src="https://avatars.githubusercontent.com/u/134331217?v=4&s=48" width="48" height="48" alt="prathamdby" title="prathamdby"/></a> <a href="https://github.com/ptn1411"><img src="https://avatars.githubusercontent.com/u/57529765?v=4&s=48" width="48" height="48" alt="ptn1411" title="ptn1411"/></a> <a href="https://github.com/reeltimeapps"><img src="https://avatars.githubusercontent.com/u/637338?v=4&s=48" width="48" height="48" alt="reeltimeapps" title="reeltimeapps"/></a> <a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/robbyczgw-cla"><img src="https://avatars.githubusercontent.com/u/239660374?v=4&s=48" width="48" height="48" alt="robbyczgw-cla" title="robbyczgw-cla"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a>
|
||||||
<a href="https://github.com/RLTCmpe"><img src="https://avatars.githubusercontent.com/u/10762242?v=4&s=48" width="48" height="48" alt="RLTCmpe" title="RLTCmpe"/></a> <a href="https://github.com/rodrigouroz"><img src="https://avatars.githubusercontent.com/u/384037?v=4&s=48" width="48" height="48" alt="rodrigouroz" title="rodrigouroz"/></a> <a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/testingabc321"><img src="https://avatars.githubusercontent.com/u/8577388?v=4&s=48" width="48" height="48" alt="testingabc321" title="testingabc321"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a>
|
<a href="https://github.com/search?q=Rolf%20Fredheim"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rolf Fredheim" title="Rolf Fredheim"/></a> <a href="https://github.com/search?q=Rony%20Kelner"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Rony Kelner" title="Rony Kelner"/></a> <a href="https://github.com/search?q=Samrat%20Jha"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Samrat Jha" title="Samrat Jha"/></a> <a href="https://github.com/siraht"><img src="https://avatars.githubusercontent.com/u/73152895?v=4&s=48" width="48" height="48" alt="siraht" title="siraht"/></a> <a href="https://github.com/snopoke"><img src="https://avatars.githubusercontent.com/u/249606?v=4&s=48" width="48" height="48" alt="snopoke" title="snopoke"/></a> <a href="https://github.com/search?q=The%20Admiral"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="The Admiral" title="The Admiral"/></a> <a href="https://github.com/thesash"><img src="https://avatars.githubusercontent.com/u/1166151?v=4&s=48" width="48" height="48" alt="thesash" title="thesash"/></a> <a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a>
|
||||||
<a href="https://github.com/search?q=Ubuntu"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Ubuntu" title="Ubuntu"/></a> <a href="https://github.com/voidserf"><img src="https://avatars.githubusercontent.com/u/477673?v=4&s=48" width="48" height="48" alt="voidserf" title="voidserf"/></a> <a href="https://github.com/search?q=Vultr-Clawd%20Admin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Vultr-Clawd Admin" title="Vultr-Clawd Admin"/></a> <a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a>
|
<a href="https://github.com/search?q=Wimmie"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Wimmie" title="Wimmie"/></a> <a href="https://github.com/wstock"><img src="https://avatars.githubusercontent.com/u/1394687?v=4&s=48" width="48" height="48" alt="wstock" title="wstock"/></a> <a href="https://github.com/yazinsai"><img src="https://avatars.githubusercontent.com/u/1846034?v=4&s=48" width="48" height="48" alt="yazinsai" title="yazinsai"/></a> <a href="https://github.com/search?q=Zach%20Knickerbocker"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Zach Knickerbocker" title="Zach Knickerbocker"/></a> <a href="https://github.com/Alphonse-arianee"><img src="https://avatars.githubusercontent.com/u/254457365?v=4&s=48" width="48" height="48" alt="Alphonse-arianee" title="Alphonse-arianee"/></a> <a href="https://github.com/search?q=Azade"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Azade" title="Azade"/></a> <a href="https://github.com/carlulsoe"><img src="https://avatars.githubusercontent.com/u/34673973?v=4&s=48" width="48" height="48" alt="carlulsoe" title="carlulsoe"/></a> <a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a>
|
||||||
<a href="https://github.com/search?q=ddyo"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="ddyo" title="ddyo"/></a> <a href="https://github.com/search?q=Erik"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Erik" title="Erik"/></a> <a href="https://github.com/latitudeki5223"><img src="https://avatars.githubusercontent.com/u/119656367?v=4&s=48" width="48" height="48" alt="latitudeki5223" title="latitudeki5223"/></a> <a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pauloportella"><img src="https://avatars.githubusercontent.com/u/22947229?v=4&s=48" width="48" height="48" alt="pauloportella" title="pauloportella"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a>
|
<a href="https://github.com/search?q=Manuel%20Maly"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Manuel Maly" title="Manuel Maly"/></a> <a href="https://github.com/search?q=Mourad%20Boustani"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Mourad Boustani" title="Mourad Boustani"/></a> <a href="https://github.com/odrobnik"><img src="https://avatars.githubusercontent.com/u/333270?v=4&s=48" width="48" height="48" alt="odrobnik" title="odrobnik"/></a> <a href="https://github.com/pcty-nextgen-ios-builder"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="pcty-nextgen-ios-builder" title="pcty-nextgen-ios-builder"/></a> <a href="https://github.com/search?q=Quentin"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Quentin" title="Quentin"/></a> <a href="https://github.com/search?q=Randy%20Torres"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="Randy Torres" title="Randy Torres"/></a> <a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
||||||
<a href="https://github.com/rhjoh"><img src="https://avatars.githubusercontent.com/u/105699450?v=4&s=48" width="48" height="48" alt="rhjoh" title="rhjoh"/></a> <a href="https://github.com/ronak-guliani"><img src="https://avatars.githubusercontent.com/u/23518228?v=4&s=48" width="48" height="48" alt="ronak-guliani" title="ronak-guliani"/></a> <a href="https://github.com/search?q=William%20Stock"><img src="assets/avatar-placeholder.svg" width="48" height="48" alt="William Stock" title="William Stock"/></a>
|
|
||||||
</p>
|
</p>
|
||||||
@@ -147,8 +147,7 @@ Available actions:
|
|||||||
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
- **addParticipant**: Add someone to a group (`chatGuid`, `address`)
|
||||||
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
- **removeParticipant**: Remove someone from a group (`chatGuid`, `address`)
|
||||||
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
- **leaveGroup**: Leave a group chat (`chatGuid`)
|
||||||
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`, `asVoice`)
|
- **sendAttachment**: Send media/files (`to`, `buffer`, `filename`)
|
||||||
- Voice memos: set `asVoice: true` with **MP3** or **CAF** audio to send as an iMessage voice message. BlueBubbles converts MP3 → CAF when sending voice memos.
|
|
||||||
|
|
||||||
### Message IDs (short vs full)
|
### Message IDs (short vs full)
|
||||||
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
Clawdbot may surface *short* message IDs (e.g., `1`, `2`) to save tokens.
|
||||||
|
|||||||
@@ -141,6 +141,14 @@
|
|||||||
"source": "/message/",
|
"source": "/message/",
|
||||||
"destination": "/cli/message"
|
"destination": "/cli/message"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"source": "/mattermost",
|
||||||
|
"destination": "/channels/mattermost"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/mattermost/",
|
||||||
|
"destination": "/channels/mattermost"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"source": "/providers/discord",
|
"source": "/providers/discord",
|
||||||
"destination": "/channels/discord"
|
"destination": "/channels/discord"
|
||||||
|
|||||||
+1
-1
@@ -128,4 +128,4 @@ If your tool allowlist blocks these tools, OpenProse programs will fail. See [Sk
|
|||||||
|
|
||||||
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
Treat `.prose` files like code. Review before running. Use Clawdbot tool allowlists and approval gates to control side effects.
|
||||||
|
|
||||||
For deterministic, approval-gated workflows, compare with [Lobster](/tools/lobster).
|
For deterministic, approval-gated workflows, compare with [Lobster](/lobster).
|
||||||
@@ -16,9 +16,9 @@ provider in two different ways.
|
|||||||
|
|
||||||
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
### 1) Built-in GitHub Copilot provider (`github-copilot`)
|
||||||
|
|
||||||
Use the native device-login flow to obtain a GitHub token and use it directly
|
Use the native device-login flow to obtain a GitHub token, then exchange it for
|
||||||
against the Copilot API. This is the **default** and simplest path because it
|
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
|
||||||
does not require VS Code. Enterprise domains are supported.
|
because it does not require VS Code.
|
||||||
|
|
||||||
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
### 2) Copilot Proxy plugin (`copilot-proxy`)
|
||||||
|
|
||||||
@@ -39,8 +39,6 @@ clawdbot models auth login-github-copilot
|
|||||||
|
|
||||||
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
|
||||||
open until it completes.
|
open until it completes.
|
||||||
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
|
|
||||||
domain (for example `company.ghe.com`).
|
|
||||||
|
|
||||||
### Optional flags
|
### Optional flags
|
||||||
|
|
||||||
@@ -68,7 +66,5 @@ clawdbot models set github-copilot/gpt-4o
|
|||||||
- Requires an interactive TTY; run it directly in a terminal.
|
- Requires an interactive TTY; run it directly in a terminal.
|
||||||
- Copilot model availability depends on your plan; if a model is rejected, try
|
- Copilot model availability depends on your plan; if a model is rejected, try
|
||||||
another ID (for example `github-copilot/gpt-4.1`).
|
another ID (for example `github-copilot/gpt-4.1`).
|
||||||
- The login stores a GitHub token in the auth profile store and uses it directly
|
- The login stores a GitHub token in the auth profile store and exchanges it for a
|
||||||
for Copilot API calls.
|
Copilot API token when Clawdbot runs.
|
||||||
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
|
|
||||||
for GitHub Enterprise.
|
|
||||||
+1
-1
@@ -155,7 +155,7 @@ tool usage guidance is injected into prompts. Some plugins ship their own skills
|
|||||||
alongside tools (for example, the voice-call plugin).
|
alongside tools (for example, the voice-call plugin).
|
||||||
|
|
||||||
Optional plugin tools:
|
Optional plugin tools:
|
||||||
- [Lobster](/tools/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
- [Lobster](/lobster): typed workflow runtime with resumable approvals (requires the Lobster CLI on the gateway host).
|
||||||
|
|
||||||
## Tool inventory
|
## Tool inventory
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ read_when:
|
|||||||
|
|
||||||
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
Lobster is a workflow shell that lets Clawdbot run multi-step tool sequences as a single, deterministic operation with explicit approval checkpoints.
|
||||||
|
|
||||||
## Hook
|
|
||||||
|
|
||||||
Your assistant can build the tools that manage itself. Ask for a workflow, and 30 minutes later you have a CLI plus pipelines that run as one call. Lobster is the missing piece: deterministic pipelines, explicit approvals, and resumable state.
|
|
||||||
|
|
||||||
## Why
|
## Why
|
||||||
|
|
||||||
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
Today, complex workflows require many back-and-forth tool calls. Each call costs tokens, and the LLM has to orchestrate every step. Lobster moves that orchestration into a typed runtime:
|
||||||
@@ -28,73 +24,6 @@ Today, complex workflows require many back-and-forth tool calls. Each call costs
|
|||||||
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
Clawdbot launches the local `lobster` CLI in **tool mode** and parses a JSON envelope from stdout.
|
||||||
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
|
If the pipeline pauses for approval, the tool returns a `resumeToken` so you can continue later.
|
||||||
|
|
||||||
## Pattern: small CLI + JSON pipes + approvals
|
|
||||||
|
|
||||||
Build tiny commands that speak JSON, then chain them into a single Lobster call. (Example command names below — swap in your own.)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
inbox list --json
|
|
||||||
inbox categorize --json
|
|
||||||
inbox apply --json
|
|
||||||
```
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "run",
|
|
||||||
"pipeline": "exec --json --shell 'inbox list --json' | exec --stdin json --shell 'inbox categorize --json' | exec --stdin json --shell 'inbox apply --json' | approve --preview-from-stdin --limit 5 --prompt 'Apply changes?'",
|
|
||||||
"timeoutMs": 30000
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
If the pipeline requests approval, resume with the token:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "resume",
|
|
||||||
"token": "<resumeToken>",
|
|
||||||
"approve": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
AI triggers the workflow; Lobster executes the steps. Approval gates keep side effects explicit and auditable.
|
|
||||||
|
|
||||||
Example: map input items into tool calls:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
gog.gmail.search --query 'newer_than:1d' \
|
|
||||||
| clawd.invoke --tool message --action send --each --item-key message --args-json '{"provider":"telegram","to":"..."}'
|
|
||||||
```
|
|
||||||
|
|
||||||
## Workflow files (.lobster)
|
|
||||||
|
|
||||||
Lobster can run YAML/JSON workflow files with `name`, `args`, `steps`, `env`, `condition`, and `approval` fields. In Clawdbot tool calls, set `pipeline` to the file path.
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
name: inbox-triage
|
|
||||||
args:
|
|
||||||
tag:
|
|
||||||
default: "family"
|
|
||||||
steps:
|
|
||||||
- id: collect
|
|
||||||
command: inbox list --json
|
|
||||||
- id: categorize
|
|
||||||
command: inbox categorize --json
|
|
||||||
stdin: $collect.stdout
|
|
||||||
- id: approve
|
|
||||||
command: inbox apply --approve
|
|
||||||
stdin: $categorize.stdout
|
|
||||||
approval: required
|
|
||||||
- id: execute
|
|
||||||
command: inbox apply --execute
|
|
||||||
stdin: $categorize.stdout
|
|
||||||
condition: $approve.approved
|
|
||||||
```
|
|
||||||
|
|
||||||
Notes:
|
|
||||||
|
|
||||||
- `stdin: $step.stdout` and `stdin: $step.json` pass a prior step’s output.
|
|
||||||
- `condition` (or `when`) can gate steps on `$step.approved`.
|
|
||||||
|
|
||||||
## Install Lobster
|
## Install Lobster
|
||||||
|
|
||||||
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
|
Install the Lobster CLI on the **same host** that runs the Clawdbot Gateway (see the [Lobster repo](https://github.com/clawdbot/lobster)), and ensure `lobster` is on `PATH`.
|
||||||
@@ -186,16 +115,6 @@ Run a pipeline in tool mode.
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Run a workflow file with args:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"action": "run",
|
|
||||||
"pipeline": "/path/to/inbox-triage.lobster",
|
|
||||||
"argsJson": "{\"tag\":\"family\"}"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### `resume`
|
### `resume`
|
||||||
|
|
||||||
Continue a halted workflow after approval.
|
Continue a halted workflow after approval.
|
||||||
@@ -214,7 +133,6 @@ Continue a halted workflow after approval.
|
|||||||
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
- `cwd`: Working directory for the pipeline (defaults to the current process working directory).
|
||||||
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
- `timeoutMs`: Kill the subprocess if it exceeds this duration (default: 20000).
|
||||||
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
- `maxStdoutBytes`: Kill the subprocess if stdout exceeds this size (default: 512000).
|
||||||
- `argsJson`: JSON string passed to `lobster run --args-json` (workflow files only).
|
|
||||||
|
|
||||||
## Output envelope
|
## Output envelope
|
||||||
|
|
||||||
@@ -233,8 +151,6 @@ If `requiresApproval` is present, inspect the prompt and decide:
|
|||||||
- `approve: true` → resume and continue side effects
|
- `approve: true` → resume and continue side effects
|
||||||
- `approve: false` → cancel and finalize the workflow
|
- `approve: false` → cancel and finalize the workflow
|
||||||
|
|
||||||
Use `approve --preview-from-stdin --limit N` to attach a JSON preview to approval requests without custom jq/heredoc glue. Resume tokens are now compact: Lobster stores workflow resume state under its state dir and hands back a small token key.
|
|
||||||
|
|
||||||
## OpenProse
|
## OpenProse
|
||||||
|
|
||||||
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
|
OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep, then run a Lobster pipeline for deterministic approvals. If a Prose program needs Lobster, allow the `lobster` tool for sub-agents via `tools.subagents.tools`. See [OpenProse](/prose).
|
||||||
@@ -257,10 +173,3 @@ OpenProse pairs well with Lobster: use `/prose` to orchestrate multi-agent prep,
|
|||||||
|
|
||||||
- [Plugins](/plugin)
|
- [Plugins](/plugin)
|
||||||
- [Plugin tool authoring](/plugins/agent-tools)
|
- [Plugin tool authoring](/plugins/agent-tools)
|
||||||
|
|
||||||
## Case study: community workflows
|
|
||||||
|
|
||||||
One public example: a “second brain” CLI + Lobster pipelines that manage three Markdown vaults (personal, partner, shared). The CLI emits JSON for stats, inbox listings, and stale scans; Lobster chains those commands into workflows like `weekly-review`, `inbox-triage`, `memory-consolidation`, and `shared-task-sync`, each with approval gates. AI handles judgment (categorization) when available and falls back to deterministic rules when not.
|
|
||||||
|
|
||||||
- Thread: https://x.com/plattenschieber/status/2014508656335770033
|
|
||||||
- Repo: https://github.com/bloomedai/brain-cli
|
|
||||||
@@ -521,42 +521,6 @@ describe("bluebubblesMessageActions", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes asVoice through sendAttachment", async () => {
|
|
||||||
const { sendBlueBubblesAttachment } = await import("./attachments.js");
|
|
||||||
|
|
||||||
const cfg: ClawdbotConfig = {
|
|
||||||
channels: {
|
|
||||||
bluebubbles: {
|
|
||||||
serverUrl: "http://localhost:1234",
|
|
||||||
password: "test-password",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const base64Buffer = Buffer.from("voice").toString("base64");
|
|
||||||
|
|
||||||
await bluebubblesMessageActions.handleAction({
|
|
||||||
action: "sendAttachment",
|
|
||||||
params: {
|
|
||||||
to: "+15551234567",
|
|
||||||
filename: "voice.mp3",
|
|
||||||
buffer: base64Buffer,
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
asVoice: true,
|
|
||||||
},
|
|
||||||
cfg,
|
|
||||||
accountId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(sendBlueBubblesAttachment).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
filename: "voice.mp3",
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
asVoice: true,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when buffer is missing for setGroupIcon", async () => {
|
it("throws when buffer is missing for setGroupIcon", async () => {
|
||||||
const cfg: ClawdbotConfig = {
|
const cfg: ClawdbotConfig = {
|
||||||
channels: {
|
channels: {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BLUEBUBBLES_ACTIONS,
|
BLUEBUBBLES_ACTIONS,
|
||||||
createActionGate,
|
createActionGate,
|
||||||
jsonResult,
|
jsonResult,
|
||||||
|
readBooleanParam,
|
||||||
readNumberParam,
|
readNumberParam,
|
||||||
readReactionParams,
|
readReactionParams,
|
||||||
readStringParam,
|
readStringParam,
|
||||||
@@ -50,17 +51,6 @@ function readMessageText(params: Record<string, unknown>): string | undefined {
|
|||||||
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
return readStringParam(params, "text") ?? readStringParam(params, "message");
|
||||||
}
|
}
|
||||||
|
|
||||||
function readBooleanParam(params: Record<string, unknown>, key: string): boolean | undefined {
|
|
||||||
const raw = params[key];
|
|
||||||
if (typeof raw === "boolean") return raw;
|
|
||||||
if (typeof raw === "string") {
|
|
||||||
const trimmed = raw.trim().toLowerCase();
|
|
||||||
if (trimmed === "true") return true;
|
|
||||||
if (trimmed === "false") return false;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Supported action names for BlueBubbles */
|
/** Supported action names for BlueBubbles */
|
||||||
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
const SUPPORTED_ACTIONS = new Set<ChannelMessageActionName>(BLUEBUBBLES_ACTION_NAMES);
|
||||||
|
|
||||||
@@ -366,7 +356,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
const caption = readStringParam(params, "caption");
|
const caption = readStringParam(params, "caption");
|
||||||
const contentType =
|
const contentType =
|
||||||
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
readStringParam(params, "contentType") ?? readStringParam(params, "mimeType");
|
||||||
const asVoice = readBooleanParam(params, "asVoice");
|
|
||||||
|
|
||||||
// Buffer can come from params.buffer (base64) or params.path (file path)
|
// Buffer can come from params.buffer (base64) or params.path (file path)
|
||||||
const base64Buffer = readStringParam(params, "buffer");
|
const base64Buffer = readStringParam(params, "buffer");
|
||||||
@@ -391,7 +380,6 @@ export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
|
|||||||
filename,
|
filename,
|
||||||
contentType: contentType ?? undefined,
|
contentType: contentType ?? undefined,
|
||||||
caption: caption ?? undefined,
|
caption: caption ?? undefined,
|
||||||
asVoice: asVoice ?? undefined,
|
|
||||||
opts,
|
opts,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
|
||||||
import { downloadBlueBubblesAttachment, sendBlueBubblesAttachment } from "./attachments.js";
|
import { downloadBlueBubblesAttachment } from "./attachments.js";
|
||||||
import type { BlueBubblesAttachment } from "./types.js";
|
import type { BlueBubblesAttachment } from "./types.js";
|
||||||
|
|
||||||
vi.mock("./accounts.js", () => ({
|
vi.mock("./accounts.js", () => ({
|
||||||
@@ -238,109 +238,3 @@ describe("downloadBlueBubblesAttachment", () => {
|
|||||||
expect(result.buffer).toEqual(new Uint8Array([1]));
|
expect(result.buffer).toEqual(new Uint8Array([1]));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("sendBlueBubblesAttachment", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.stubGlobal("fetch", mockFetch);
|
|
||||||
mockFetch.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
});
|
|
||||||
|
|
||||||
function decodeBody(body: Uint8Array) {
|
|
||||||
return Buffer.from(body).toString("utf8");
|
|
||||||
}
|
|
||||||
|
|
||||||
it("marks voice memos when asVoice is true and mp3 is provided", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-1" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendBlueBubblesAttachment({
|
|
||||||
to: "chat_guid:iMessage;-;+15551234567",
|
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
filename: "voice.mp3",
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
asVoice: true,
|
|
||||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
||||||
const bodyText = decodeBody(body);
|
|
||||||
expect(bodyText).toContain('name="isAudioMessage"');
|
|
||||||
expect(bodyText).toContain("true");
|
|
||||||
expect(bodyText).toContain('filename="voice.mp3"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("normalizes mp3 filenames for voice memos", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-2" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendBlueBubblesAttachment({
|
|
||||||
to: "chat_guid:iMessage;-;+15551234567",
|
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
filename: "voice",
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
asVoice: true,
|
|
||||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
||||||
const bodyText = decodeBody(body);
|
|
||||||
expect(bodyText).toContain('filename="voice.mp3"');
|
|
||||||
expect(bodyText).toContain('name="voice.mp3"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when asVoice is true but media is not audio", async () => {
|
|
||||||
await expect(
|
|
||||||
sendBlueBubblesAttachment({
|
|
||||||
to: "chat_guid:iMessage;-;+15551234567",
|
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
filename: "image.png",
|
|
||||||
contentType: "image/png",
|
|
||||||
asVoice: true,
|
|
||||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("voice messages require audio");
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when asVoice is true but audio is not mp3 or caf", async () => {
|
|
||||||
await expect(
|
|
||||||
sendBlueBubblesAttachment({
|
|
||||||
to: "chat_guid:iMessage;-;+15551234567",
|
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
filename: "voice.wav",
|
|
||||||
contentType: "audio/wav",
|
|
||||||
asVoice: true,
|
|
||||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
||||||
}),
|
|
||||||
).rejects.toThrow("require mp3 or caf");
|
|
||||||
expect(mockFetch).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("sanitizes filenames before sending", async () => {
|
|
||||||
mockFetch.mockResolvedValueOnce({
|
|
||||||
ok: true,
|
|
||||||
text: () => Promise.resolve(JSON.stringify({ messageId: "msg-3" })),
|
|
||||||
});
|
|
||||||
|
|
||||||
await sendBlueBubblesAttachment({
|
|
||||||
to: "chat_guid:iMessage;-;+15551234567",
|
|
||||||
buffer: new Uint8Array([1, 2, 3]),
|
|
||||||
filename: "../evil.mp3",
|
|
||||||
contentType: "audio/mpeg",
|
|
||||||
opts: { serverUrl: "http://localhost:1234", password: "test" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const body = mockFetch.mock.calls[0][1]?.body as Uint8Array;
|
|
||||||
const bodyText = decodeBody(body);
|
|
||||||
expect(bodyText).toContain('filename="evil.mp3"');
|
|
||||||
expect(bodyText).toContain('name="evil.mp3"');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
import path from "node:path";
|
|
||||||
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
|
||||||
import { resolveBlueBubblesAccount } from "./accounts.js";
|
import { resolveBlueBubblesAccount } from "./accounts.js";
|
||||||
import { resolveChatGuidForTarget } from "./send.js";
|
import { resolveChatGuidForTarget } from "./send.js";
|
||||||
@@ -20,30 +19,6 @@ export type BlueBubblesAttachmentOpts = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
|
||||||
const AUDIO_MIME_MP3 = new Set(["audio/mpeg", "audio/mp3"]);
|
|
||||||
const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]);
|
|
||||||
|
|
||||||
function sanitizeFilename(input: string | undefined, fallback: string): string {
|
|
||||||
const trimmed = input?.trim() ?? "";
|
|
||||||
const base = trimmed ? path.basename(trimmed) : "";
|
|
||||||
return base || fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureExtension(filename: string, extension: string, fallbackBase: string): string {
|
|
||||||
const currentExt = path.extname(filename);
|
|
||||||
if (currentExt.toLowerCase() === extension) return filename;
|
|
||||||
const base = currentExt ? filename.slice(0, -currentExt.length) : filename;
|
|
||||||
return `${base || fallbackBase}${extension}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveVoiceInfo(filename: string, contentType?: string) {
|
|
||||||
const normalizedType = contentType?.trim().toLowerCase();
|
|
||||||
const extension = path.extname(filename).toLowerCase();
|
|
||||||
const isMp3 = extension === ".mp3" || (normalizedType ? AUDIO_MIME_MP3.has(normalizedType) : false);
|
|
||||||
const isCaf = extension === ".caf" || (normalizedType ? AUDIO_MIME_CAF.has(normalizedType) : false);
|
|
||||||
const isAudio = isMp3 || isCaf || Boolean(normalizedType?.startsWith("audio/"));
|
|
||||||
return { isAudio, isMp3, isCaf };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
function resolveAccount(params: BlueBubblesAttachmentOpts) {
|
||||||
const account = resolveBlueBubblesAccount({
|
const account = resolveBlueBubblesAccount({
|
||||||
@@ -129,7 +104,6 @@ function extractMessageId(payload: unknown): string {
|
|||||||
/**
|
/**
|
||||||
* Send an attachment via BlueBubbles API.
|
* Send an attachment via BlueBubbles API.
|
||||||
* Supports sending media files (images, videos, audio, documents) to a chat.
|
* Supports sending media files (images, videos, audio, documents) to a chat.
|
||||||
* When asVoice is true, expects MP3/CAF audio and marks it as an iMessage voice memo.
|
|
||||||
*/
|
*/
|
||||||
export async function sendBlueBubblesAttachment(params: {
|
export async function sendBlueBubblesAttachment(params: {
|
||||||
to: string;
|
to: string;
|
||||||
@@ -139,37 +113,12 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToMessageGuid?: string;
|
replyToMessageGuid?: string;
|
||||||
replyToPartIndex?: number;
|
replyToPartIndex?: number;
|
||||||
asVoice?: boolean;
|
|
||||||
opts?: BlueBubblesAttachmentOpts;
|
opts?: BlueBubblesAttachmentOpts;
|
||||||
}): Promise<SendBlueBubblesAttachmentResult> {
|
}): Promise<SendBlueBubblesAttachmentResult> {
|
||||||
const { to, caption, replyToMessageGuid, replyToPartIndex, asVoice, opts = {} } = params;
|
const { to, buffer, filename, contentType, caption, replyToMessageGuid, replyToPartIndex, opts = {} } =
|
||||||
let { buffer, filename, contentType } = params;
|
params;
|
||||||
const wantsVoice = asVoice === true;
|
|
||||||
const fallbackName = wantsVoice ? "Audio Message" : "attachment";
|
|
||||||
filename = sanitizeFilename(filename, fallbackName);
|
|
||||||
contentType = contentType?.trim() || undefined;
|
|
||||||
const { baseUrl, password } = resolveAccount(opts);
|
const { baseUrl, password } = resolveAccount(opts);
|
||||||
|
|
||||||
// Validate voice memo format when requested (BlueBubbles converts MP3 -> CAF when isAudioMessage).
|
|
||||||
const isAudioMessage = wantsVoice;
|
|
||||||
if (isAudioMessage) {
|
|
||||||
const voiceInfo = resolveVoiceInfo(filename, contentType);
|
|
||||||
if (!voiceInfo.isAudio) {
|
|
||||||
throw new Error("BlueBubbles voice messages require audio media (mp3 or caf).");
|
|
||||||
}
|
|
||||||
if (voiceInfo.isMp3) {
|
|
||||||
filename = ensureExtension(filename, ".mp3", fallbackName);
|
|
||||||
contentType = contentType ?? "audio/mpeg";
|
|
||||||
} else if (voiceInfo.isCaf) {
|
|
||||||
filename = ensureExtension(filename, ".caf", fallbackName);
|
|
||||||
contentType = contentType ?? "audio/x-caf";
|
|
||||||
} else {
|
|
||||||
throw new Error(
|
|
||||||
"BlueBubbles voice messages require mp3 or caf audio (convert before sending).",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = resolveSendTarget(to);
|
const target = resolveSendTarget(to);
|
||||||
const chatGuid = await resolveChatGuidForTarget({
|
const chatGuid = await resolveChatGuidForTarget({
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -221,11 +170,6 @@ export async function sendBlueBubblesAttachment(params: {
|
|||||||
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
addField("tempGuid", `temp-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`);
|
||||||
addField("method", "private-api");
|
addField("method", "private-api");
|
||||||
|
|
||||||
// Add isAudioMessage flag for voice memos
|
|
||||||
if (isAudioMessage) {
|
|
||||||
addField("isAudioMessage", "true");
|
|
||||||
}
|
|
||||||
|
|
||||||
const trimmedReplyTo = replyToMessageGuid?.trim();
|
const trimmedReplyTo = replyToMessageGuid?.trim();
|
||||||
if (trimmedReplyTo) {
|
if (trimmedReplyTo) {
|
||||||
addField("selectedMessageGuid", trimmedReplyTo);
|
addField("selectedMessageGuid", trimmedReplyTo);
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption?: string;
|
caption?: string;
|
||||||
replyToId?: string | null;
|
replyToId?: string | null;
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
asVoice?: boolean;
|
|
||||||
}) {
|
}) {
|
||||||
const {
|
const {
|
||||||
cfg,
|
cfg,
|
||||||
@@ -72,7 +71,6 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
caption,
|
caption,
|
||||||
replyToId,
|
replyToId,
|
||||||
accountId,
|
accountId,
|
||||||
asVoice,
|
|
||||||
} = params;
|
} = params;
|
||||||
const core = getBlueBubblesRuntime();
|
const core = getBlueBubblesRuntime();
|
||||||
const maxBytes = resolveChannelMediaMaxBytes({
|
const maxBytes = resolveChannelMediaMaxBytes({
|
||||||
@@ -148,7 +146,6 @@ export async function sendBlueBubblesMedia(params: {
|
|||||||
filename: resolvedFilename ?? "attachment",
|
filename: resolvedFilename ?? "attachment",
|
||||||
contentType: resolvedContentType ?? undefined,
|
contentType: resolvedContentType ?? undefined,
|
||||||
replyToMessageGuid,
|
replyToMessageGuid,
|
||||||
asVoice,
|
|
||||||
opts: {
|
opts: {
|
||||||
cfg,
|
cfg,
|
||||||
accountId,
|
accountId,
|
||||||
|
|||||||
@@ -159,7 +159,6 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
|||||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||||
pipeline: Type.Optional(Type.String()),
|
pipeline: Type.Optional(Type.String()),
|
||||||
argsJson: Type.Optional(Type.String()),
|
|
||||||
token: Type.Optional(Type.String()),
|
token: Type.Optional(Type.String()),
|
||||||
approve: Type.Optional(Type.Boolean()),
|
approve: Type.Optional(Type.Boolean()),
|
||||||
lobsterPath: Type.Optional(Type.String()),
|
lobsterPath: Type.Optional(Type.String()),
|
||||||
@@ -182,12 +181,7 @@ export function createLobsterTool(api: ClawdbotPluginApi) {
|
|||||||
if (action === "run") {
|
if (action === "run") {
|
||||||
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
const pipeline = typeof params.pipeline === "string" ? params.pipeline : "";
|
||||||
if (!pipeline.trim()) throw new Error("pipeline required");
|
if (!pipeline.trim()) throw new Error("pipeline required");
|
||||||
const argv = ["run", "--mode", "tool", pipeline];
|
return ["run", "--mode", "tool", pipeline];
|
||||||
const argsJson = typeof params.argsJson === "string" ? params.argsJson : "";
|
|
||||||
if (argsJson.trim()) {
|
|
||||||
argv.push("--args-json", argsJson);
|
|
||||||
}
|
|
||||||
return argv;
|
|
||||||
}
|
}
|
||||||
if (action === "resume") {
|
if (action === "resume") {
|
||||||
const token = typeof params.token === "string" ? params.token : "";
|
const token = typeof params.token === "string" ? params.token : "";
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
MIT License
|
|
||||||
|
|
||||||
Copyright (c) 2025 OpenProse
|
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
|
||||||
in the Software without restriction, including without limitation the rights
|
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
|
||||||
furnished to do so, subject to the following conditions:
|
|
||||||
|
|
||||||
The above copyright notice and this permission notice shall be included in all
|
|
||||||
copies or substantial portions of the Software.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
||||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
||||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
||||||
SOFTWARE.
|
|
||||||
@@ -115,11 +115,7 @@ if (!shouldBuild()) {
|
|||||||
runNode();
|
runNode();
|
||||||
} else {
|
} else {
|
||||||
logRunner("Building TypeScript (dist is stale).");
|
logRunner("Building TypeScript (dist is stale).");
|
||||||
const pnpmArgs = ["exec", compiler, ...projectArgs];
|
const build = spawn("pnpm", ["exec", compiler, ...projectArgs], {
|
||||||
const buildCmd = process.platform === "win32" ? "cmd.exe" : "pnpm";
|
|
||||||
const buildArgs =
|
|
||||||
process.platform === "win32" ? ["/d", "/s", "/c", "pnpm", ...pnpmArgs] : pnpmArgs;
|
|
||||||
const build = spawn(buildCmd, buildArgs, {
|
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
stdio: "inherit",
|
stdio: "inherit",
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
import fs from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
||||||
import {
|
|
||||||
type AuthProfileStore,
|
|
||||||
ensureAuthProfileStore,
|
|
||||||
resolveApiKeyForProfile,
|
|
||||||
} from "./auth-profiles.js";
|
|
||||||
|
|
||||||
vi.mock("@mariozechner/pi-ai", () => ({
|
|
||||||
getOAuthApiKey: vi.fn(() => {
|
|
||||||
throw new Error("refresh should not be called");
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("auth-profiles (github-copilot)", () => {
|
|
||||||
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
|
||||||
let tempDir: string | null = null;
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
vi.unstubAllGlobals();
|
|
||||||
if (tempDir) {
|
|
||||||
await fs.rm(tempDir, { recursive: true, force: true });
|
|
||||||
tempDir = null;
|
|
||||||
}
|
|
||||||
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
|
|
||||||
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
|
|
||||||
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
|
||||||
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
|
|
||||||
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
|
|
||||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = tempDir;
|
|
||||||
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
|
|
||||||
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
|
|
||||||
|
|
||||||
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
|
|
||||||
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
|
|
||||||
|
|
||||||
const store: AuthProfileStore = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"github-copilot:github": {
|
|
||||||
type: "oauth",
|
|
||||||
provider: "github-copilot",
|
|
||||||
refresh: "gh-token",
|
|
||||||
access: "gh-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "company.ghe.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
|
|
||||||
|
|
||||||
const loaded = ensureAuthProfileStore();
|
|
||||||
const resolved = await resolveApiKeyForProfile({
|
|
||||||
store: loaded,
|
|
||||||
profileId: "github-copilot:github",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(resolved?.apiKey).toBe("gh-token");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -39,15 +39,6 @@ async function refreshOAuthTokenWithLock(params: {
|
|||||||
const cred = store.profiles[params.profileId];
|
const cred = store.profiles[params.profileId];
|
||||||
if (!cred || cred.type !== "oauth") return null;
|
if (!cred || cred.type !== "oauth") return null;
|
||||||
|
|
||||||
if (
|
|
||||||
cred.provider === "github-copilot" &&
|
|
||||||
(!Number.isFinite(cred.expires) || cred.expires <= 0)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
newCredentials: cred,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
@@ -112,20 +103,6 @@ async function tryResolveOAuthProfile(params: {
|
|||||||
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
if (profileConfig && profileConfig.provider !== cred.provider) return null;
|
||||||
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
if (profileConfig && profileConfig.mode !== cred.type) return null;
|
||||||
|
|
||||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
provider: cred.provider,
|
|
||||||
email: cred.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
|
|
||||||
return {
|
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
|
||||||
provider: cred.provider,
|
|
||||||
email: cred.email,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (Date.now() < cred.expires) {
|
if (Date.now() < cred.expires) {
|
||||||
return {
|
return {
|
||||||
apiKey: buildOAuthApiKey(cred.provider, cred),
|
apiKey: buildOAuthApiKey(cred.provider, cred),
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ export type TokenCredential = {
|
|||||||
token: string;
|
token: string;
|
||||||
/** Optional expiry timestamp (ms since epoch). */
|
/** Optional expiry timestamp (ms since epoch). */
|
||||||
expires?: number;
|
expires?: number;
|
||||||
enterpriseUrl?: string;
|
|
||||||
email?: string;
|
email?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -52,6 +51,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
const agentDir = path.join(home, "agent-default-base-url");
|
const agentDir = path.join(home, "agent-default-base-url");
|
||||||
@@ -62,55 +71,48 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||||
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("uses enterprise URL from auth profiles to derive base URL", async () => {
|
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
|
const previousGh = process.env.GH_TOKEN;
|
||||||
|
const previousGithub = process.env.GITHUB_TOKEN;
|
||||||
|
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
|
||||||
|
process.env.GH_TOKEN = "gh-token";
|
||||||
|
process.env.GITHUB_TOKEN = "github-token";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
|
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
token: "copilot",
|
||||||
await fs.writeFile(
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
path.join(agentDir, "auth-profiles.json"),
|
source: "mock",
|
||||||
JSON.stringify(
|
baseUrl: "https://api.copilot.example",
|
||||||
{
|
});
|
||||||
version: 1,
|
|
||||||
profiles: {
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
"github-copilot:github": {
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
type: "oauth",
|
resolveCopilotApiToken,
|
||||||
provider: "github-copilot",
|
}));
|
||||||
refresh: "gh-token",
|
|
||||||
access: "gh-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "company.ghe.com",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } });
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "copilot-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
// no-op
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
process.env.GH_TOKEN = previousGh;
|
||||||
|
process.env.GITHUB_TOKEN = previousGithub;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -44,7 +43,7 @@ describe("models-config", () => {
|
|||||||
process.env.HOME = previousHome;
|
process.env.HOME = previousHome;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses default baseUrl when env token is present", async () => {
|
it("falls back to default baseUrl when token exchange fails", async () => {
|
||||||
await withTempHome(async () => {
|
await withTempHome(async () => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
|
||||||
@@ -52,6 +51,11 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
@@ -63,13 +67,13 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
|
||||||
} finally {
|
} finally {
|
||||||
process.env.COPILOT_GITHUB_TOKEN = previous;
|
process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
it("normalizes enterprise URL when deriving base URL", async () => {
|
it("uses agentDir override auth profiles for copilot injection", async () => {
|
||||||
await withTempHome(async (home) => {
|
await withTempHome(async (home) => {
|
||||||
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
const previous = process.env.COPILOT_GITHUB_TOKEN;
|
||||||
const previousGh = process.env.GH_TOKEN;
|
const previousGh = process.env.GH_TOKEN;
|
||||||
@@ -90,12 +94,9 @@ describe("models-config", () => {
|
|||||||
version: 1,
|
version: 1,
|
||||||
profiles: {
|
profiles: {
|
||||||
"github-copilot:github": {
|
"github-copilot:github": {
|
||||||
type: "oauth",
|
type: "token",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
refresh: "gh-profile-token",
|
token: "gh-profile-token",
|
||||||
access: "gh-profile-token",
|
|
||||||
expires: 0,
|
|
||||||
enterpriseUrl: "https://company.ghe.com/",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -104,6 +105,16 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
@@ -113,9 +124,7 @@ describe("models-config", () => {
|
|||||||
providers: Record<string, { baseUrl?: string }>;
|
providers: Record<string, { baseUrl?: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
|
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
|
||||||
"https://copilot-api.company.ghe.com",
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
normalizeGithubCopilotDomain,
|
DEFAULT_COPILOT_API_BASE_URL,
|
||||||
resolveGithubCopilotBaseUrl,
|
resolveCopilotApiToken,
|
||||||
} from "../providers/github-copilot-utils.js";
|
} from "../providers/github-copilot-token.js";
|
||||||
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
|
||||||
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
|
||||||
import {
|
import {
|
||||||
@@ -331,18 +331,29 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
|
|
||||||
if (!hasProfile && !githubToken) return null;
|
if (!hasProfile && !githubToken) return null;
|
||||||
|
|
||||||
let enterpriseDomain: string | null = null;
|
let selectedGithubToken = githubToken;
|
||||||
if (hasProfile) {
|
if (!selectedGithubToken && hasProfile) {
|
||||||
// Use the first available profile as a default for discovery (it will be
|
// Use the first available profile as a default for discovery (it will be
|
||||||
// re-resolved per-run by the embedded runner).
|
// re-resolved per-run by the embedded runner).
|
||||||
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
|
||||||
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
const profile = profileId ? authStore.profiles[profileId] : undefined;
|
||||||
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
|
if (profile && profile.type === "token") {
|
||||||
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
|
selectedGithubToken = profile.token;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
|
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||||
|
if (selectedGithubToken) {
|
||||||
|
try {
|
||||||
|
const token = await resolveCopilotApiToken({
|
||||||
|
githubToken: selectedGithubToken,
|
||||||
|
env,
|
||||||
|
});
|
||||||
|
baseUrl = token.baseUrl;
|
||||||
|
} catch {
|
||||||
|
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
// pi-coding-agent's ModelRegistry marks a model "available" only if its
|
||||||
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
|
||||||
@@ -353,7 +364,7 @@ export async function resolveImplicitCopilotProvider(params: {
|
|||||||
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
// GitHub token (not the exchanged Copilot token), and (3) matches existing
|
||||||
// patterns for OAuth-like providers in pi-coding-agent.
|
// patterns for OAuth-like providers in pi-coding-agent.
|
||||||
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
|
||||||
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
|
// Clawdbot uses its own auth store and exchanges tokens at runtime.
|
||||||
// `models list` uses Clawdbot's auth heuristics for availability.
|
// `models list` uses Clawdbot's auth heuristics for availability.
|
||||||
|
|
||||||
// We intentionally do NOT define custom models for Copilot in models.json.
|
// We intentionally do NOT define custom models for Copilot in models.json.
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import path from "node:path";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||||
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
|
||||||
@@ -81,16 +80,25 @@ describe("models-config", () => {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken,
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
|
|
||||||
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
|
||||||
|
|
||||||
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
|
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
|
||||||
const parsed = JSON.parse(raw) as {
|
expect.objectContaining({ githubToken: "alpha-token" }),
|
||||||
providers: Record<string, { baseUrl?: string }>;
|
);
|
||||||
};
|
|
||||||
|
|
||||||
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
|
|
||||||
} finally {
|
} finally {
|
||||||
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
|
||||||
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
else process.env.COPILOT_GITHUB_TOKEN = previous;
|
||||||
@@ -109,6 +117,16 @@ describe("models-config", () => {
|
|||||||
try {
|
try {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|
||||||
|
vi.doMock("../providers/github-copilot-token.js", () => ({
|
||||||
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
|
||||||
|
resolveCopilotApiToken: vi.fn().mockResolvedValue({
|
||||||
|
token: "copilot",
|
||||||
|
expiresAt: Date.now() + 60 * 60 * 1000,
|
||||||
|
source: "mock",
|
||||||
|
baseUrl: "https://api.copilot.example",
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
const { ensureClawdbotModelsJson } = await import("./models-config.js");
|
||||||
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
|
||||||
|
|
||||||
|
|||||||
+56
-145
@@ -1,8 +1,7 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { ensureClawdbotModelsJson } from "./models-config.js";
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
@@ -87,25 +86,10 @@ vi.mock("@mariozechner/pi-ai", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
let tempRoot: string | undefined;
|
|
||||||
let agentDir: string;
|
|
||||||
let workspaceDir: string;
|
|
||||||
let sessionCounter = 0;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeEach(async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-embedded-agent-"));
|
|
||||||
agentDir = path.join(tempRoot, "agent");
|
|
||||||
workspaceDir = path.join(tempRoot, "workspace");
|
|
||||||
await fs.mkdir(agentDir, { recursive: true });
|
|
||||||
await fs.mkdir(workspaceDir, { recursive: true });
|
|
||||||
}, 20_000);
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
if (!tempRoot) return;
|
|
||||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
|
||||||
tempRoot = undefined;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const makeOpenAiConfig = (modelIds: string[]) =>
|
const makeOpenAiConfig = (modelIds: string[]) =>
|
||||||
@@ -130,14 +114,10 @@ const makeOpenAiConfig = (modelIds: string[]) =>
|
|||||||
},
|
},
|
||||||
}) satisfies ClawdbotConfig;
|
}) satisfies ClawdbotConfig;
|
||||||
|
|
||||||
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
|
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||||
|
ensureClawdbotModelsJson(cfg, agentDir);
|
||||||
|
|
||||||
const nextSessionFile = () => {
|
const testSessionKey = "agent:test:embedded-ordering";
|
||||||
sessionCounter += 1;
|
|
||||||
return path.join(workspaceDir, `session-${sessionCounter}.jsonl`);
|
|
||||||
};
|
|
||||||
|
|
||||||
const testSessionKey = "agent:test:embedded";
|
|
||||||
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||||
|
|
||||||
const textFromContent = (content: unknown) => {
|
const textFromContent = (content: unknown) => {
|
||||||
@@ -165,114 +145,15 @@ const readSessionMessages = async (sessionFile: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("runEmbeddedPiAgent", () => {
|
describe("runEmbeddedPiAgent", () => {
|
||||||
it("writes models.json into the provided agentDir", async () => {
|
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
|
|
||||||
const cfg = {
|
|
||||||
models: {
|
|
||||||
providers: {
|
|
||||||
minimax: {
|
|
||||||
baseUrl: "https://api.minimax.io/anthropic",
|
|
||||||
api: "anthropic-messages",
|
|
||||||
apiKey: "sk-minimax-test",
|
|
||||||
models: [
|
|
||||||
{
|
|
||||||
id: "MiniMax-M2.1",
|
|
||||||
name: "MiniMax M2.1",
|
|
||||||
reasoning: false,
|
|
||||||
input: ["text"],
|
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
||||||
contextWindow: 200000,
|
|
||||||
maxTokens: 8192,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
} satisfies ClawdbotConfig;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "hi",
|
|
||||||
provider: "definitely-not-a-provider",
|
|
||||||
model: "definitely-not-a-model",
|
|
||||||
timeoutMs: 1,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
}),
|
|
||||||
).rejects.toThrow(/Unknown model:/);
|
|
||||||
|
|
||||||
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
|
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
|
||||||
await ensureModels(cfg);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
});
|
|
||||||
|
|
||||||
const messages = await readSessionMessages(sessionFile);
|
|
||||||
const firstUserIndex = messages.findIndex(
|
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
|
||||||
);
|
|
||||||
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
|
||||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
if (firstAssistantIndex !== -1) {
|
|
||||||
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists the user message when prompt fails before assistant output", async () => {
|
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
const cfg = makeOpenAiConfig(["mock-error"]);
|
|
||||||
await ensureModels(cfg);
|
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: testSessionKey,
|
|
||||||
sessionFile,
|
|
||||||
workspaceDir,
|
|
||||||
config: cfg,
|
|
||||||
prompt: "boom",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-error",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
agentDir,
|
|
||||||
enqueue: immediateEnqueue,
|
|
||||||
});
|
|
||||||
expect(result.payloads[0]?.isError).toBe(true);
|
|
||||||
|
|
||||||
const messages = await readSessionMessages(sessionFile);
|
|
||||||
const userIndex = messages.findIndex(
|
|
||||||
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
|
||||||
);
|
|
||||||
expect(userIndex).toBeGreaterThanOrEqual(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it(
|
it(
|
||||||
"appends new user + assistant after existing transcript entries",
|
"appends new user + assistant after existing transcript entries",
|
||||||
{ timeout: 90_000 },
|
{ timeout: 90_000 },
|
||||||
async () => {
|
async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
@@ -304,7 +185,7 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg);
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -340,11 +221,13 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
|
expect(newAssistantIndex).toBeGreaterThan(newUserIndex);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("persists multi-turn user/assistant ordering across runs", async () => {
|
it("persists multi-turn user/assistant ordering across runs", async () => {
|
||||||
const sessionFile = nextSessionFile();
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg);
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -382,33 +265,58 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
(message, index) => index > firstUserIndex && message?.role === "assistant",
|
(message, index) => index > firstUserIndex && message?.role === "assistant",
|
||||||
);
|
);
|
||||||
const secondUserIndex = messages.findIndex(
|
const secondUserIndex = messages.findIndex(
|
||||||
(message, index) =>
|
(message) => message?.role === "user" && textFromContent(message.content) === "second",
|
||||||
index > firstAssistantIndex &&
|
|
||||||
message?.role === "user" &&
|
|
||||||
textFromContent(message.content) === "second",
|
|
||||||
);
|
);
|
||||||
const secondAssistantIndex = messages.findIndex(
|
const secondAssistantIndex = messages.findIndex(
|
||||||
(message, index) => index > secondUserIndex && message?.role === "assistant",
|
(message, index) => index > secondUserIndex && message?.role === "assistant",
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||||
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
|
expect(firstAssistantIndex).toBeGreaterThan(firstUserIndex);
|
||||||
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
expect(secondUserIndex).toBeGreaterThan(firstAssistantIndex);
|
||||||
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
expect(secondAssistantIndex).toBeGreaterThan(secondUserIndex);
|
||||||
});
|
}, 90_000);
|
||||||
|
|
||||||
it("repairs orphaned user messages and continues", async () => {
|
it("repairs orphaned user messages and continues", async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "orphaned user" }],
|
content: [{ type: "text", text: "seed user 1" }],
|
||||||
|
});
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "text", text: "seed assistant" }],
|
||||||
|
stopReason: "stop",
|
||||||
|
api: "openai-responses",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
usage: {
|
||||||
|
input: 1,
|
||||||
|
output: 1,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 2,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
sessionManager.appendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "seed user 2" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg);
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
const result = await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -430,16 +338,19 @@ describe("runEmbeddedPiAgent", () => {
|
|||||||
|
|
||||||
it("repairs orphaned single-user sessions and continues", async () => {
|
it("repairs orphaned single-user sessions and continues", async () => {
|
||||||
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
const { SessionManager } = await import("@mariozechner/pi-coding-agent");
|
||||||
const sessionFile = nextSessionFile();
|
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
const sessionManager = SessionManager.open(sessionFile);
|
const sessionManager = SessionManager.open(sessionFile);
|
||||||
sessionManager.appendMessage({
|
sessionManager.appendMessage({
|
||||||
role: "user",
|
role: "user",
|
||||||
content: [{ type: "text", text: "solo user" }],
|
content: [{ type: "text", text: "seed user only" }],
|
||||||
});
|
});
|
||||||
|
|
||||||
const cfg = makeOpenAiConfig(["mock-1"]);
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
await ensureModels(cfg);
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
const result = await runEmbeddedPiAgent({
|
const result = await runEmbeddedPiAgent({
|
||||||
sessionId: "session:test",
|
sessionId: "session:test",
|
||||||
@@ -210,74 +210,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("honors user-pinned profiles even when in cooldown", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const now = Date.now();
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
|
||||||
const payload = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
|
||||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
|
||||||
},
|
|
||||||
usageStats: {
|
|
||||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
|
|
||||||
"openai:p2": { lastUsed: 2 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
|
||||||
|
|
||||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
|
||||||
makeAttempt({
|
|
||||||
assistantTexts: ["ok"],
|
|
||||||
lastAssistant: buildAssistant({
|
|
||||||
stopReason: "stop",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: "agent:test:user-cooldown",
|
|
||||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
|
||||||
workspaceDir,
|
|
||||||
agentDir,
|
|
||||||
config: makeConfig(),
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
authProfileId: "openai:p1",
|
|
||||||
authProfileIdSource: "user",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
runId: "run:user-cooldown",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const stored = JSON.parse(
|
|
||||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
|
||||||
) as {
|
|
||||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
|
||||||
};
|
|
||||||
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBeUndefined();
|
|
||||||
expect(stored.usageStats?.["openai:p1"]?.lastUsed).not.toBe(1);
|
|
||||||
expect(stored.usageStats?.["openai:p2"]?.lastUsed).toBe(2);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores user-locked profile when provider mismatches", async () => {
|
it("ignores user-locked profile when provider mismatches", async () => {
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
@@ -316,149 +248,4 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
|||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips profiles in cooldown during initial selection", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const now = Date.now();
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
|
||||||
const payload = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
|
||||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
|
||||||
},
|
|
||||||
usageStats: {
|
|
||||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 }, // p1 in cooldown for 1 hour
|
|
||||||
"openai:p2": { lastUsed: 2 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
|
||||||
|
|
||||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
|
||||||
makeAttempt({
|
|
||||||
assistantTexts: ["ok"],
|
|
||||||
lastAssistant: buildAssistant({
|
|
||||||
stopReason: "stop",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: "agent:test:skip-cooldown",
|
|
||||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
|
||||||
workspaceDir,
|
|
||||||
agentDir,
|
|
||||||
config: makeConfig(),
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
authProfileId: undefined,
|
|
||||||
authProfileIdSource: "auto",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
runId: "run:skip-cooldown",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
const stored = JSON.parse(
|
|
||||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
|
||||||
) as { usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }> };
|
|
||||||
expect(stored.usageStats?.["openai:p1"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
|
|
||||||
expect(typeof stored.usageStats?.["openai:p2"]?.lastUsed).toBe("number");
|
|
||||||
} finally {
|
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("skips profiles in cooldown when rotating after failure", async () => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
try {
|
|
||||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
|
||||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
|
||||||
const now = Date.now();
|
|
||||||
vi.setSystemTime(now);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
|
||||||
const payload = {
|
|
||||||
version: 1,
|
|
||||||
profiles: {
|
|
||||||
"openai:p1": { type: "api_key", provider: "openai", key: "sk-one" },
|
|
||||||
"openai:p2": { type: "api_key", provider: "openai", key: "sk-two" },
|
|
||||||
"openai:p3": { type: "api_key", provider: "openai", key: "sk-three" },
|
|
||||||
},
|
|
||||||
usageStats: {
|
|
||||||
"openai:p1": { lastUsed: 1 },
|
|
||||||
"openai:p2": { cooldownUntil: now + 60 * 60 * 1000 }, // p2 in cooldown
|
|
||||||
"openai:p3": { lastUsed: 3 },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
|
||||||
|
|
||||||
runEmbeddedAttemptMock
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeAttempt({
|
|
||||||
assistantTexts: [],
|
|
||||||
lastAssistant: buildAssistant({
|
|
||||||
stopReason: "error",
|
|
||||||
errorMessage: "rate limit",
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.mockResolvedValueOnce(
|
|
||||||
makeAttempt({
|
|
||||||
assistantTexts: ["ok"],
|
|
||||||
lastAssistant: buildAssistant({
|
|
||||||
stopReason: "stop",
|
|
||||||
content: [{ type: "text", text: "ok" }],
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await runEmbeddedPiAgent({
|
|
||||||
sessionId: "session:test",
|
|
||||||
sessionKey: "agent:test:rotate-skip-cooldown",
|
|
||||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
|
||||||
workspaceDir,
|
|
||||||
agentDir,
|
|
||||||
config: makeConfig(),
|
|
||||||
prompt: "hello",
|
|
||||||
provider: "openai",
|
|
||||||
model: "mock-1",
|
|
||||||
authProfileId: "openai:p1",
|
|
||||||
authProfileIdSource: "auto",
|
|
||||||
timeoutMs: 5_000,
|
|
||||||
runId: "run:rotate-skip-cooldown",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(runEmbeddedAttemptMock).toHaveBeenCalledTimes(2);
|
|
||||||
|
|
||||||
const stored = JSON.parse(
|
|
||||||
await fs.readFile(path.join(agentDir, "auth-profiles.json"), "utf-8"),
|
|
||||||
) as {
|
|
||||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
|
||||||
};
|
|
||||||
expect(typeof stored.usageStats?.["openai:p1"]?.lastUsed).toBe("number");
|
|
||||||
expect(typeof stored.usageStats?.["openai:p3"]?.lastUsed).toBe("number");
|
|
||||||
expect(stored.usageStats?.["openai:p2"]?.cooldownUntil).toBe(now + 60 * 60 * 1000);
|
|
||||||
} finally {
|
|
||||||
await fs.rm(agentDir, { recursive: true, force: true });
|
|
||||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
vi.useRealTimers();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
+258
@@ -0,0 +1,258 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
|
import { ensureClawdbotModelsJson } from "./models-config.js";
|
||||||
|
|
||||||
|
const buildAssistantMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: [{ type: "text" as const, text: "ok" }],
|
||||||
|
stopReason: "stop" as const,
|
||||||
|
api: model.api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 1,
|
||||||
|
output: 1,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 2,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildAssistantErrorMessage = (model: { api: string; provider: string; id: string }) => ({
|
||||||
|
role: "assistant" as const,
|
||||||
|
content: [] as const,
|
||||||
|
stopReason: "error" as const,
|
||||||
|
errorMessage: "boom",
|
||||||
|
api: model.api,
|
||||||
|
provider: model.provider,
|
||||||
|
model: model.id,
|
||||||
|
usage: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
totalTokens: 0,
|
||||||
|
cost: {
|
||||||
|
input: 0,
|
||||||
|
output: 0,
|
||||||
|
cacheRead: 0,
|
||||||
|
cacheWrite: 0,
|
||||||
|
total: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
timestamp: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockPiAi = () => {
|
||||||
|
vi.doMock("@mariozechner/pi-ai", async () => {
|
||||||
|
const actual =
|
||||||
|
await vi.importActual<typeof import("@mariozechner/pi-ai")>("@mariozechner/pi-ai");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
complete: async (model: { api: string; provider: string; id: string }) => {
|
||||||
|
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
||||||
|
return buildAssistantMessage(model);
|
||||||
|
},
|
||||||
|
completeSimple: async (model: { api: string; provider: string; id: string }) => {
|
||||||
|
if (model.id === "mock-error") return buildAssistantErrorMessage(model);
|
||||||
|
return buildAssistantMessage(model);
|
||||||
|
},
|
||||||
|
streamSimple: (model: { api: string; provider: string; id: string }) => {
|
||||||
|
const stream = new actual.AssistantMessageEventStream();
|
||||||
|
queueMicrotask(() => {
|
||||||
|
stream.push({
|
||||||
|
type: "done",
|
||||||
|
reason: "stop",
|
||||||
|
message:
|
||||||
|
model.id === "mock-error"
|
||||||
|
? buildAssistantErrorMessage(model)
|
||||||
|
: buildAssistantMessage(model),
|
||||||
|
});
|
||||||
|
stream.end();
|
||||||
|
});
|
||||||
|
return stream;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
mockPiAi();
|
||||||
|
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
const makeOpenAiConfig = (modelIds: string[]) =>
|
||||||
|
({
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
openai: {
|
||||||
|
api: "openai-responses",
|
||||||
|
apiKey: "sk-test",
|
||||||
|
baseUrl: "https://example.com",
|
||||||
|
models: modelIds.map((id) => ({
|
||||||
|
id,
|
||||||
|
name: `Mock ${id}`,
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 16_000,
|
||||||
|
maxTokens: 2048,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}) satisfies ClawdbotConfig;
|
||||||
|
|
||||||
|
const ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
|
||||||
|
ensureClawdbotModelsJson(cfg, agentDir);
|
||||||
|
|
||||||
|
const testSessionKey = "agent:test:embedded-models";
|
||||||
|
const immediateEnqueue = async <T>(task: () => Promise<T>) => task();
|
||||||
|
|
||||||
|
const textFromContent = (content: unknown) => {
|
||||||
|
if (typeof content === "string") return content;
|
||||||
|
if (Array.isArray(content) && content[0]?.type === "text") {
|
||||||
|
return (content[0] as { text?: string }).text;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const readSessionMessages = async (sessionFile: string) => {
|
||||||
|
const raw = await fs.readFile(sessionFile, "utf-8");
|
||||||
|
return raw
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(
|
||||||
|
(line) =>
|
||||||
|
JSON.parse(line) as {
|
||||||
|
type?: string;
|
||||||
|
message?: { role?: string; content?: unknown };
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.filter((entry) => entry.type === "message")
|
||||||
|
.map((entry) => entry.message as { role?: string; content?: unknown });
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("runEmbeddedPiAgent", () => {
|
||||||
|
it("writes models.json into the provided agentDir", async () => {
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
|
const cfg = {
|
||||||
|
models: {
|
||||||
|
providers: {
|
||||||
|
minimax: {
|
||||||
|
baseUrl: "https://api.minimax.io/anthropic",
|
||||||
|
api: "anthropic-messages",
|
||||||
|
apiKey: "sk-minimax-test",
|
||||||
|
models: [
|
||||||
|
{
|
||||||
|
id: "MiniMax-M2.1",
|
||||||
|
name: "MiniMax M2.1",
|
||||||
|
reasoning: false,
|
||||||
|
input: ["text"],
|
||||||
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
|
contextWindow: 200000,
|
||||||
|
maxTokens: 8192,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} satisfies ClawdbotConfig;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "hi",
|
||||||
|
provider: "definitely-not-a-provider",
|
||||||
|
model: "definitely-not-a-model",
|
||||||
|
timeoutMs: 1,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
}),
|
||||||
|
).rejects.toThrow(/Unknown model:/);
|
||||||
|
|
||||||
|
await expect(fs.stat(path.join(agentDir, "models.json"))).resolves.toBeTruthy();
|
||||||
|
});
|
||||||
|
it("persists the first user message before assistant output", { timeout: 60_000 }, async () => {
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
|
const cfg = makeOpenAiConfig(["mock-1"]);
|
||||||
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
|
await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "hello",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-1",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
});
|
||||||
|
|
||||||
|
const messages = await readSessionMessages(sessionFile);
|
||||||
|
const firstUserIndex = messages.findIndex(
|
||||||
|
(message) => message?.role === "user" && textFromContent(message.content) === "hello",
|
||||||
|
);
|
||||||
|
const firstAssistantIndex = messages.findIndex((message) => message?.role === "assistant");
|
||||||
|
expect(firstUserIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
if (firstAssistantIndex !== -1) {
|
||||||
|
expect(firstUserIndex).toBeLessThan(firstAssistantIndex);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
it("persists the user message when prompt fails before assistant output", async () => {
|
||||||
|
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||||
|
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||||
|
const sessionFile = path.join(workspaceDir, "session.jsonl");
|
||||||
|
|
||||||
|
const cfg = makeOpenAiConfig(["mock-error"]);
|
||||||
|
await ensureModels(cfg, agentDir);
|
||||||
|
|
||||||
|
const result = await runEmbeddedPiAgent({
|
||||||
|
sessionId: "session:test",
|
||||||
|
sessionKey: testSessionKey,
|
||||||
|
sessionFile,
|
||||||
|
workspaceDir,
|
||||||
|
config: cfg,
|
||||||
|
prompt: "boom",
|
||||||
|
provider: "openai",
|
||||||
|
model: "mock-error",
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
agentDir,
|
||||||
|
enqueue: immediateEnqueue,
|
||||||
|
});
|
||||||
|
expect(result.payloads[0]?.isError).toBe(true);
|
||||||
|
|
||||||
|
const messages = await readSessionMessages(sessionFile);
|
||||||
|
const userIndex = messages.findIndex(
|
||||||
|
(message) => message?.role === "user" && textFromContent(message.content) === "boom",
|
||||||
|
);
|
||||||
|
expect(userIndex).toBeGreaterThanOrEqual(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -128,6 +128,13 @@ export async function compactEmbeddedPiSession(params: {
|
|||||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (model.provider === "github-copilot") {
|
||||||
|
const { resolveCopilotApiToken } =
|
||||||
|
await import("../../providers/github-copilot-token.js");
|
||||||
|
const copilotToken = await resolveCopilotApiToken({
|
||||||
|
githubToken: apiKeyInfo.apiKey,
|
||||||
|
});
|
||||||
|
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
|
||||||
} else {
|
} else {
|
||||||
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,20 +7,9 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
|||||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||||
import { normalizeModelCompat } from "../model-compat.js";
|
import { normalizeModelCompat } from "../model-compat.js";
|
||||||
import { normalizeProviderId } from "../model-selection.js";
|
import { normalizeProviderId } from "../model-selection.js";
|
||||||
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
|
|
||||||
|
|
||||||
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
|
||||||
|
|
||||||
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
|
|
||||||
if (model.provider === "github-copilot") {
|
|
||||||
const headers = model.headers
|
|
||||||
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
|
|
||||||
: { "User-Agent": resolveGithubCopilotUserAgent() };
|
|
||||||
return { ...model, headers };
|
|
||||||
}
|
|
||||||
return model;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildInlineProviderModels(
|
export function buildInlineProviderModels(
|
||||||
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
|
||||||
): InlineModelEntry[] {
|
): InlineModelEntry[] {
|
||||||
@@ -71,7 +60,7 @@ export function resolveModel(
|
|||||||
if (inlineMatch) {
|
if (inlineMatch) {
|
||||||
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
|
||||||
return {
|
return {
|
||||||
model: applyProviderModelOverrides(normalized),
|
model: normalized,
|
||||||
authStorage,
|
authStorage,
|
||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
@@ -89,7 +78,7 @@ export function resolveModel(
|
|||||||
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
|
||||||
} as Model<Api>);
|
} as Model<Api>);
|
||||||
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
|
return { model: fallbackModel, authStorage, modelRegistry };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
error: `Unknown model: ${provider}/${modelId}`,
|
error: `Unknown model: ${provider}/${modelId}`,
|
||||||
@@ -97,9 +86,5 @@ export function resolveModel(
|
|||||||
modelRegistry,
|
modelRegistry,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
|
||||||
model: applyProviderModelOverrides(normalizeModelCompat(model)),
|
|
||||||
authStorage,
|
|
||||||
modelRegistry,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
@@ -5,7 +5,6 @@ import { resolveUserPath } from "../../utils.js";
|
|||||||
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
import { isMarkdownCapableMessageChannel } from "../../utils/message-channel.js";
|
||||||
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
import { resolveClawdbotAgentDir } from "../agent-paths.js";
|
||||||
import {
|
import {
|
||||||
isProfileInCooldown,
|
|
||||||
markAuthProfileFailure,
|
markAuthProfileFailure,
|
||||||
markAuthProfileGood,
|
markAuthProfileGood,
|
||||||
markAuthProfileUsed,
|
markAuthProfileUsed,
|
||||||
@@ -149,11 +148,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
|
if (lockedProfileId && !profileOrder.includes(lockedProfileId)) {
|
||||||
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
throw new Error(`Auth profile "${lockedProfileId}" is not configured for ${provider}.`);
|
||||||
}
|
}
|
||||||
const profileCandidates = lockedProfileId
|
const profileCandidates = profileOrder.length > 0 ? profileOrder : [undefined];
|
||||||
? [lockedProfileId]
|
|
||||||
: profileOrder.length > 0
|
|
||||||
? profileOrder
|
|
||||||
: [undefined];
|
|
||||||
let profileIndex = 0;
|
let profileIndex = 0;
|
||||||
|
|
||||||
const initialThinkLevel = params.thinkLevel ?? "off";
|
const initialThinkLevel = params.thinkLevel ?? "off";
|
||||||
@@ -174,18 +169,26 @@ export async function runEmbeddedPiAgent(
|
|||||||
|
|
||||||
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
const applyApiKeyInfo = async (candidate?: string): Promise<void> => {
|
||||||
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
apiKeyInfo = await resolveApiKeyForCandidate(candidate);
|
||||||
const resolvedProfileId = apiKeyInfo.profileId ?? candidate;
|
|
||||||
if (!apiKeyInfo.apiKey) {
|
if (!apiKeyInfo.apiKey) {
|
||||||
if (apiKeyInfo.mode !== "aws-sdk") {
|
if (apiKeyInfo.mode !== "aws-sdk") {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
lastProfileId = resolvedProfileId;
|
lastProfileId = apiKeyInfo.profileId;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
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);
|
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
|
||||||
lastProfileId = resolvedProfileId;
|
}
|
||||||
|
lastProfileId = apiKeyInfo.profileId;
|
||||||
};
|
};
|
||||||
|
|
||||||
const advanceAuthProfile = async (): Promise<boolean> => {
|
const advanceAuthProfile = async (): Promise<boolean> => {
|
||||||
@@ -193,10 +196,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
let nextIndex = profileIndex + 1;
|
let nextIndex = profileIndex + 1;
|
||||||
while (nextIndex < profileCandidates.length) {
|
while (nextIndex < profileCandidates.length) {
|
||||||
const candidate = profileCandidates[nextIndex];
|
const candidate = profileCandidates[nextIndex];
|
||||||
if (candidate && isProfileInCooldown(authStore, candidate)) {
|
|
||||||
nextIndex += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
await applyApiKeyInfo(candidate);
|
await applyApiKeyInfo(candidate);
|
||||||
profileIndex = nextIndex;
|
profileIndex = nextIndex;
|
||||||
@@ -212,24 +211,7 @@ export async function runEmbeddedPiAgent(
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (profileIndex < profileCandidates.length) {
|
|
||||||
const candidate = profileCandidates[profileIndex];
|
|
||||||
if (
|
|
||||||
candidate &&
|
|
||||||
candidate !== lockedProfileId &&
|
|
||||||
isProfileInCooldown(authStore, candidate)
|
|
||||||
) {
|
|
||||||
profileIndex += 1;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
await applyApiKeyInfo(profileCandidates[profileIndex]);
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (profileIndex >= profileCandidates.length) {
|
|
||||||
throw new Error(
|
|
||||||
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||||
const advanced = await advanceAuthProfile();
|
const advanced = await advanceAuthProfile();
|
||||||
@@ -520,12 +502,10 @@ export async function runEmbeddedPiAgent(
|
|||||||
store: authStore,
|
store: authStore,
|
||||||
provider,
|
provider,
|
||||||
profileId: lastProfileId,
|
profileId: lastProfileId,
|
||||||
agentDir: params.agentDir,
|
|
||||||
});
|
});
|
||||||
await markAuthProfileUsed({
|
await markAuthProfileUsed({
|
||||||
store: authStore,
|
store: authStore,
|
||||||
profileId: lastProfileId,
|
profileId: lastProfileId,
|
||||||
agentDir: params.agentDir,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -357,20 +357,6 @@ describe("handleSlackAction", () => {
|
|||||||
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
expect(payload.messages[0].timestampUtc).toBe(new Date(expectedMs).toISOString());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("passes threadId through to readSlackMessages", async () => {
|
|
||||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
|
||||||
readSlackMessages.mockClear();
|
|
||||||
readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false });
|
|
||||||
|
|
||||||
await handleSlackAction(
|
|
||||||
{ action: "readMessages", channelId: "C1", threadId: "12345.6789" },
|
|
||||||
cfg,
|
|
||||||
);
|
|
||||||
|
|
||||||
const [, opts] = readSlackMessages.mock.calls[0] ?? [];
|
|
||||||
expect(opts?.threadId).toBe("12345.6789");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("adds normalized timestamps to pin payloads", async () => {
|
it("adds normalized timestamps to pin payloads", async () => {
|
||||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
||||||
listSlackPins.mockResolvedValueOnce([
|
listSlackPins.mockResolvedValueOnce([
|
||||||
|
|||||||
@@ -214,13 +214,11 @@ export async function handleSlackAction(
|
|||||||
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
typeof limitRaw === "number" && Number.isFinite(limitRaw) ? limitRaw : undefined;
|
||||||
const before = readStringParam(params, "before");
|
const before = readStringParam(params, "before");
|
||||||
const after = readStringParam(params, "after");
|
const after = readStringParam(params, "after");
|
||||||
const threadId = readStringParam(params, "threadId");
|
|
||||||
const result = await readSlackMessages(channelId, {
|
const result = await readSlackMessages(channelId, {
|
||||||
...readOpts,
|
...readOpts,
|
||||||
limit,
|
limit,
|
||||||
before: before ?? undefined,
|
before: before ?? undefined,
|
||||||
after: after ?? undefined,
|
after: after ?? undefined,
|
||||||
threadId: threadId ?? undefined,
|
|
||||||
});
|
});
|
||||||
const messages = result.messages.map((message) =>
|
const messages = result.messages.map((message) =>
|
||||||
withNormalizedTimestamp(
|
withNormalizedTimestamp(
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ describe("block streaming", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function waitForCalls(fn: () => number, calls: number) {
|
async function waitForCalls(fn: () => number, calls: number) {
|
||||||
const deadline = Date.now() + 5000;
|
const deadline = Date.now() + 1500;
|
||||||
while (fn() < calls) {
|
while (fn() < calls) {
|
||||||
if (Date.now() > deadline) {
|
if (Date.now() > deadline) {
|
||||||
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
|
||||||
import { createSlackActions } from "./slack.actions.js";
|
|
||||||
|
|
||||||
const handleSlackAction = vi.fn(async () => ({ details: { ok: true } }));
|
|
||||||
|
|
||||||
vi.mock("../../agents/tools/slack-actions.js", () => ({
|
|
||||||
handleSlackAction: (...args: unknown[]) => handleSlackAction(...args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe("slack actions adapter", () => {
|
|
||||||
it("forwards threadId for read", async () => {
|
|
||||||
const cfg = { channels: { slack: { botToken: "tok" } } } as ClawdbotConfig;
|
|
||||||
const actions = createSlackActions("slack");
|
|
||||||
|
|
||||||
await actions.handleAction?.({
|
|
||||||
channel: "slack",
|
|
||||||
action: "read",
|
|
||||||
cfg,
|
|
||||||
params: {
|
|
||||||
channelId: "C1",
|
|
||||||
threadId: "171234.567",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const [params] = handleSlackAction.mock.calls[0] ?? [];
|
|
||||||
expect(params).toMatchObject({
|
|
||||||
action: "readMessages",
|
|
||||||
channelId: "C1",
|
|
||||||
threadId: "171234.567",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -133,7 +133,6 @@ export function createSlackActions(providerId: string): ChannelMessageActionAdap
|
|||||||
limit,
|
limit,
|
||||||
before: readStringParam(params, "before"),
|
before: readStringParam(params, "before"),
|
||||||
after: readStringParam(params, "after"),
|
after: readStringParam(params, "after"),
|
||||||
threadId: readStringParam(params, "threadId"),
|
|
||||||
accountId: accountId ?? undefined,
|
accountId: accountId ?? undefined,
|
||||||
},
|
},
|
||||||
cfg,
|
cfg,
|
||||||
|
|||||||
+2
-6
@@ -94,13 +94,9 @@ export function buildParseArgv(params: {
|
|||||||
: baseArgv[0]?.endsWith("clawdbot")
|
: baseArgv[0]?.endsWith("clawdbot")
|
||||||
? baseArgv.slice(1)
|
? baseArgv.slice(1)
|
||||||
: baseArgv;
|
: baseArgv;
|
||||||
const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase();
|
const executable = normalizedArgv[0]?.split(/[/\\]/).pop() ?? "";
|
||||||
const looksLikeNode =
|
const looksLikeNode =
|
||||||
normalizedArgv.length >= 2 &&
|
normalizedArgv.length >= 2 && (executable === "node" || executable === "bun");
|
||||||
(executable === "node" ||
|
|
||||||
executable === "node.exe" ||
|
|
||||||
executable === "bun" ||
|
|
||||||
executable === "bun.exe");
|
|
||||||
if (looksLikeNode) return normalizedArgv;
|
if (looksLikeNode) return normalizedArgv;
|
||||||
return ["node", programName || "clawdbot", ...normalizedArgv];
|
return ["node", programName || "clawdbot", ...normalizedArgv];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { danger } from "../../globals.js";
|
|||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
import { resolveBrowserActionContext } from "./shared.js";
|
import { resolveBrowserActionContext } from "./shared.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
|
|
||||||
export function registerBrowserFilesAndDownloadsCommands(
|
export function registerBrowserFilesAndDownloadsCommands(
|
||||||
browser: Command,
|
browser: Command,
|
||||||
@@ -74,7 +73,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
|
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -106,7 +105,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`downloaded: ${shortenHomePath(result.download.path)}`);
|
defaultRuntime.log(`downloaded: ${result.download.path}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { danger } from "../globals.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
function runBrowserObserve(action: () => Promise<void>) {
|
function runBrowserObserve(action: () => Promise<void>) {
|
||||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||||
@@ -62,7 +61,7 @@ export function registerBrowserActionObserveCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`PDF: ${shortenHomePath(result.path)}`);
|
defaultRuntime.log(`PDF: ${result.path}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { danger } from "../globals.js";
|
|||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
function runBrowserDebug(action: () => Promise<void>) {
|
function runBrowserDebug(action: () => Promise<void>) {
|
||||||
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
return runCommandWithRuntime(defaultRuntime, action, (err) => {
|
||||||
@@ -165,7 +164,7 @@ export function registerBrowserDebugCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`TRACE:${shortenHomePath(result.path)}`);
|
defaultRuntime.log(`TRACE:${result.path}`);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { movePathToTrash } from "../browser/trash.js";
|
import { movePathToTrash } from "../browser/trash.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
|
|
||||||
function bundledExtensionRootDir() {
|
function bundledExtensionRootDir() {
|
||||||
@@ -78,8 +77,7 @@ export function registerBrowserExtensionCommands(
|
|||||||
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
|
defaultRuntime.log(JSON.stringify({ ok: true, path: installed.path }, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const displayPath = shortenHomePath(installed.path);
|
defaultRuntime.log(installed.path);
|
||||||
defaultRuntime.log(displayPath);
|
|
||||||
const copied = await copyToClipboard(installed.path).catch(() => false);
|
const copied = await copyToClipboard(installed.path).catch(() => false);
|
||||||
defaultRuntime.error(
|
defaultRuntime.error(
|
||||||
info(
|
info(
|
||||||
@@ -87,7 +85,7 @@ export function registerBrowserExtensionCommands(
|
|||||||
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
|
copied ? "Copied to clipboard." : "Copy to clipboard unavailable.",
|
||||||
"Next:",
|
"Next:",
|
||||||
`- Chrome → chrome://extensions → enable “Developer mode”`,
|
`- Chrome → chrome://extensions → enable “Developer mode”`,
|
||||||
`- “Load unpacked” → select: ${displayPath}`,
|
`- “Load unpacked” → select: ${installed.path}`,
|
||||||
`- Pin “Clawdbot Browser Relay”, then click it on the tab (badge shows ON)`,
|
`- Pin “Clawdbot Browser Relay”, then click it on the tab (badge shows ON)`,
|
||||||
"",
|
"",
|
||||||
`${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.clawd.bot/tools/chrome-extension")}`,
|
`${theme.muted("Docs:")} ${formatDocsLink("/tools/chrome-extension", "docs.clawd.bot/tools/chrome-extension")}`,
|
||||||
@@ -117,8 +115,7 @@ export function registerBrowserExtensionCommands(
|
|||||||
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
|
defaultRuntime.log(JSON.stringify({ path: dir }, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const displayPath = shortenHomePath(dir);
|
defaultRuntime.log(dir);
|
||||||
defaultRuntime.log(displayPath);
|
|
||||||
const copied = await copyToClipboard(dir).catch(() => false);
|
const copied = await copyToClipboard(dir).catch(() => false);
|
||||||
if (copied) defaultRuntime.error(info("Copied to clipboard."));
|
if (copied) defaultRuntime.error(info("Copied to clipboard."));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import { browserScreenshotAction } from "../browser/client-actions.js";
|
|||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
|
|
||||||
export function registerBrowserInspectCommands(
|
export function registerBrowserInspectCommands(
|
||||||
@@ -37,7 +36,7 @@ export function registerBrowserInspectCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.path)}`);
|
defaultRuntime.log(`MEDIA:${result.path}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
defaultRuntime.error(danger(String(err)));
|
defaultRuntime.error(danger(String(err)));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
@@ -107,9 +106,9 @@ export function registerBrowserInspectCommands(
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
defaultRuntime.log(shortenHomePath(opts.out));
|
defaultRuntime.log(opts.out);
|
||||||
if (result.format === "ai" && result.imagePath) {
|
if (result.format === "ai" && result.imagePath) {
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
defaultRuntime.log(`MEDIA:${result.imagePath}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -123,7 +122,7 @@ export function registerBrowserInspectCommands(
|
|||||||
if (result.format === "ai") {
|
if (result.format === "ai") {
|
||||||
defaultRuntime.log(result.snapshot);
|
defaultRuntime.log(result.snapshot);
|
||||||
if (result.imagePath) {
|
if (result.imagePath) {
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(result.imagePath)}`);
|
defaultRuntime.log(`MEDIA:${result.imagePath}`);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
import { browserAct } from "../browser/client-actions-core.js";
|
import { browserAct } from "../browser/client-actions-core.js";
|
||||||
import { danger, info } from "../globals.js";
|
import { danger, info } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
|
|
||||||
@@ -47,8 +46,6 @@ export function registerBrowserManageCommands(
|
|||||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detectedPath = status.detectedExecutablePath ?? status.executablePath;
|
|
||||||
const detectedDisplay = detectedPath ? shortenHomePath(detectedPath) : "auto";
|
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
[
|
[
|
||||||
`profile: ${status.profile ?? "clawd"}`,
|
`profile: ${status.profile ?? "clawd"}`,
|
||||||
@@ -59,7 +56,7 @@ export function registerBrowserManageCommands(
|
|||||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||||
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
|
`detectedBrowser: ${status.detectedBrowser ?? "unknown"}`,
|
||||||
`detectedPath: ${detectedDisplay}`,
|
`detectedPath: ${status.detectedExecutablePath ?? status.executablePath ?? "auto"}`,
|
||||||
`profileColor: ${status.color}`,
|
`profileColor: ${status.color}`,
|
||||||
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
|
...(status.detectError ? [`detectError: ${status.detectError}`] : []),
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
type PathSegment = string;
|
type PathSegment = string;
|
||||||
|
|
||||||
@@ -169,7 +168,7 @@ function unsetAtPath(root: Record<string, unknown>, path: PathSegment[]): boolea
|
|||||||
async function loadValidConfig() {
|
async function loadValidConfig() {
|
||||||
const snapshot = await readConfigFileSnapshot();
|
const snapshot = await readConfigFileSnapshot();
|
||||||
if (snapshot.valid) return snapshot;
|
if (snapshot.valid) return snapshot;
|
||||||
defaultRuntime.error(`Config invalid at ${shortenHomePath(snapshot.path)}.`);
|
defaultRuntime.error(`Config invalid at ${snapshot.path}.`);
|
||||||
for (const issue of snapshot.issues) {
|
for (const issue of snapshot.issues) {
|
||||||
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
defaultRuntime.error(`- ${issue.path || "<root>"}: ${issue.message}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { isWSLEnv } from "../../infra/wsl.js";
|
|||||||
import { getResolvedLoggerSettings } from "../../logging.js";
|
import { getResolvedLoggerSettings } from "../../logging.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
import { formatCliCommand } from "../command-format.js";
|
import { formatCliCommand } from "../command-format.js";
|
||||||
import {
|
import {
|
||||||
filterDaemonEnv,
|
filterDaemonEnv,
|
||||||
@@ -67,7 +66,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
|
defaultRuntime.log(`${label("Service:")} ${accent(service.label)} (${serviceStatus})`);
|
||||||
try {
|
try {
|
||||||
const logFile = getResolvedLoggerSettings().file;
|
const logFile = getResolvedLoggerSettings().file;
|
||||||
defaultRuntime.log(`${label("File logs:")} ${infoText(shortenHomePath(logFile))}`);
|
defaultRuntime.log(`${label("File logs:")} ${infoText(logFile)}`);
|
||||||
} catch {
|
} catch {
|
||||||
// ignore missing config/log resolution
|
// ignore missing config/log resolution
|
||||||
}
|
}
|
||||||
@@ -77,14 +76,10 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (service.command?.sourcePath) {
|
if (service.command?.sourcePath) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(`${label("Service file:")} ${infoText(service.command.sourcePath)}`);
|
||||||
`${label("Service file:")} ${infoText(shortenHomePath(service.command.sourcePath))}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (service.command?.workingDirectory) {
|
if (service.command?.workingDirectory) {
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(`${label("Working dir:")} ${infoText(service.command.workingDirectory)}`);
|
||||||
`${label("Working dir:")} ${infoText(shortenHomePath(service.command.workingDirectory))}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
|
const daemonEnvLines = safeDaemonEnv(service.command?.environment);
|
||||||
if (daemonEnvLines.length > 0) {
|
if (daemonEnvLines.length > 0) {
|
||||||
@@ -106,7 +101,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (status.config) {
|
if (status.config) {
|
||||||
const cliCfg = `${shortenHomePath(status.config.cli.path)}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
const cliCfg = `${status.config.cli.path}${status.config.cli.exists ? "" : " (missing)"}${status.config.cli.valid ? "" : " (invalid)"}`;
|
||||||
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
|
defaultRuntime.log(`${label("Config (cli):")} ${infoText(cliCfg)}`);
|
||||||
if (!status.config.cli.valid && status.config.cli.issues?.length) {
|
if (!status.config.cli.valid && status.config.cli.issues?.length) {
|
||||||
for (const issue of status.config.cli.issues.slice(0, 5)) {
|
for (const issue of status.config.cli.issues.slice(0, 5)) {
|
||||||
@@ -116,7 +111,7 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (status.config.daemon) {
|
if (status.config.daemon) {
|
||||||
const daemonCfg = `${shortenHomePath(status.config.daemon.path)}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
|
const daemonCfg = `${status.config.daemon.path}${status.config.daemon.exists ? "" : " (missing)"}${status.config.daemon.valid ? "" : " (invalid)"}`;
|
||||||
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
|
defaultRuntime.log(`${label("Config (service):")} ${infoText(daemonCfg)}`);
|
||||||
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
|
if (!status.config.daemon.valid && status.config.daemon.issues?.length) {
|
||||||
for (const issue of status.config.daemon.issues.slice(0, 5)) {
|
for (const issue of status.config.daemon.issues.slice(0, 5)) {
|
||||||
@@ -281,8 +276,8 @@ export function printDaemonStatus(status: DaemonStatus, opts: { json: boolean })
|
|||||||
const logs = resolveGatewayLogPaths(
|
const logs = resolveGatewayLogPaths(
|
||||||
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
|
(service.command?.environment ?? process.env) as NodeJS.ProcessEnv,
|
||||||
);
|
);
|
||||||
defaultRuntime.error(`${errorText("Logs:")} ${shortenHomePath(logs.stdoutPath)}`);
|
defaultRuntime.error(`${errorText("Logs:")} ${logs.stdoutPath}`);
|
||||||
defaultRuntime.error(`${errorText("Errors:")} ${shortenHomePath(logs.stderrPath)}`);
|
defaultRuntime.error(`${errorText("Errors:")} ${logs.stderrPath}`);
|
||||||
}
|
}
|
||||||
spacer();
|
spacer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
|||||||
import { handleReset } from "../../commands/onboard-helpers.js";
|
import { handleReset } from "../../commands/onboard-helpers.js";
|
||||||
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { resolveUserPath, shortenHomePath } from "../../utils.js";
|
import { resolveUserPath } from "../../utils.js";
|
||||||
|
|
||||||
const DEV_IDENTITY_NAME = "C3-PO";
|
const DEV_IDENTITY_NAME = "C3-PO";
|
||||||
const DEV_IDENTITY_THEME = "protocol droid";
|
const DEV_IDENTITY_THEME = "protocol droid";
|
||||||
@@ -117,6 +117,6 @@ export async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
await ensureDevWorkspace(workspace);
|
await ensureDevWorkspace(workspace);
|
||||||
defaultRuntime.log(`Dev config ready: ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}`);
|
defaultRuntime.log(`Dev config ready: ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
defaultRuntime.log(`Dev workspace ready: ${shortenHomePath(resolveUserPath(workspace))}`);
|
defaultRuntime.log(`Dev workspace ready: ${resolveUserPath(workspace)}`);
|
||||||
}
|
}
|
||||||
@@ -1,66 +1,66 @@
|
|||||||
import { spawn } from "node:child_process";
|
import { spawn } from "node:child_process";
|
||||||
import fs from "node:fs";
|
import fs from "node:fs";
|
||||||
|
import net from "node:net";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { pathToFileURL } from "node:url";
|
import { pathToFileURL } from "node:url";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const waitForReady = async (
|
const waitForPortOpen = async (
|
||||||
proc: ReturnType<typeof spawn>,
|
proc: ReturnType<typeof spawn>,
|
||||||
chunksOut: string[],
|
chunksOut: string[],
|
||||||
chunksErr: string[],
|
chunksErr: string[],
|
||||||
|
port: number,
|
||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
) => {
|
) => {
|
||||||
|
const startedAt = Date.now();
|
||||||
|
while (Date.now() - startedAt < timeoutMs) {
|
||||||
|
if (proc.exitCode !== null) {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const timer = setTimeout(() => {
|
const socket = net.connect({ host: "127.0.0.1", port });
|
||||||
const stdout = chunksOut.join("");
|
socket.once("connect", () => {
|
||||||
const stderr = chunksErr.join("");
|
socket.destroy();
|
||||||
cleanup();
|
|
||||||
reject(
|
|
||||||
new Error(
|
|
||||||
`timeout waiting for gateway to start\n` +
|
|
||||||
`--- stdout ---\n${stdout}\n--- stderr ---\n${stderr}`,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}, timeoutMs);
|
|
||||||
|
|
||||||
const cleanup = () => {
|
|
||||||
clearTimeout(timer);
|
|
||||||
proc.off("exit", onExit);
|
|
||||||
proc.off("message", onMessage);
|
|
||||||
proc.stdout?.off("data", onStdout);
|
|
||||||
};
|
|
||||||
|
|
||||||
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 onMessage = (msg: unknown) => {
|
|
||||||
if (msg && typeof msg === "object" && "ready" in msg) {
|
|
||||||
cleanup();
|
|
||||||
resolve();
|
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);
|
|
||||||
});
|
});
|
||||||
|
socket.once("error", (err) => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// keep polling
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe("gateway SIGTERM", () => {
|
describe("gateway SIGTERM", () => {
|
||||||
@@ -77,50 +77,67 @@ describe("gateway SIGTERM", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
it("exits 0 on SIGTERM", { timeout: 180_000 }, async () => {
|
||||||
|
const port = await getFreePort();
|
||||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
|
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-gateway-test-"));
|
||||||
|
const configPath = path.join(stateDir, "clawdbot.json");
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify({ gateway: { mode: "local", port } }, null, 2),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
const out: string[] = [];
|
const out: string[] = [];
|
||||||
const err: string[] = [];
|
const err: string[] = [];
|
||||||
|
|
||||||
const nodeBin = process.execPath;
|
const nodeBin = process.execPath;
|
||||||
|
const entryArgs = [
|
||||||
|
"gateway",
|
||||||
|
"--port",
|
||||||
|
String(port),
|
||||||
|
"--bind",
|
||||||
|
"loopback",
|
||||||
|
"--allow-unconfigured",
|
||||||
|
];
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
CLAWDBOT_NO_RESPAWN: "1",
|
CLAWDBOT_NO_RESPAWN: "1",
|
||||||
CLAWDBOT_STATE_DIR: stateDir,
|
CLAWDBOT_STATE_DIR: stateDir,
|
||||||
|
CLAWDBOT_CONFIG_PATH: configPath,
|
||||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||||
CLAWDBOT_SKIP_GMAIL_WATCHER: "1",
|
|
||||||
CLAWDBOT_SKIP_CRON: "1",
|
|
||||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||||
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
CLAWDBOT_SKIP_CANVAS_HOST: "1",
|
||||||
|
// Avoid port collisions with other test processes that may also start a gateway server.
|
||||||
|
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
|
||||||
|
CLAWDBOT_BRIDGE_PORT: "0",
|
||||||
};
|
};
|
||||||
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
|
const bootstrapPath = path.join(stateDir, "clawdbot-entry-bootstrap.mjs");
|
||||||
const runLoopPath = path.resolve("src/cli/gateway-cli/run-loop.ts");
|
const runMainPath = path.resolve("src/cli/run-main.ts");
|
||||||
const runtimePath = path.resolve("src/runtime.ts");
|
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
bootstrapPath,
|
bootstrapPath,
|
||||||
[
|
[
|
||||||
'import { pathToFileURL } from "node:url";',
|
'import { pathToFileURL } from "node:url";',
|
||||||
`const runLoopUrl = ${JSON.stringify(pathToFileURL(runLoopPath).href)};`,
|
'const rawArgs = process.env.CLAWDBOT_ENTRY_ARGS ?? "[]";',
|
||||||
`const runtimeUrl = ${JSON.stringify(pathToFileURL(runtimePath).href)};`,
|
"let entryArgs = [];",
|
||||||
"const { runGatewayLoop } = await import(runLoopUrl);",
|
"try {",
|
||||||
"const { defaultRuntime } = await import(runtimeUrl);",
|
" entryArgs = JSON.parse(rawArgs);",
|
||||||
"await runGatewayLoop({",
|
"} catch (err) {",
|
||||||
" start: async () => {",
|
' console.error("Failed to parse CLAWDBOT_ENTRY_ARGS", err);',
|
||||||
' process.stdout.write("READY\\\\n");',
|
" process.exit(1);",
|
||||||
" if (process.send) process.send({ ready: true });",
|
"}",
|
||||||
" const keepAlive = setInterval(() => {}, 1000);",
|
"if (!Array.isArray(entryArgs)) entryArgs = [];",
|
||||||
" return { close: async () => clearInterval(keepAlive) };",
|
'entryArgs = entryArgs.filter((arg) => typeof arg === "string" && !arg.toLowerCase().includes("node.exe"));',
|
||||||
" },",
|
`const runMainUrl = ${JSON.stringify(pathToFileURL(runMainPath).href)};`,
|
||||||
" runtime: defaultRuntime,",
|
"const { runCli } = await import(runMainUrl);",
|
||||||
"});",
|
'await runCli(["node", "clawdbot", ...entryArgs]);',
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
const childArgs = ["--import", "tsx", bootstrapPath];
|
const childArgs = ["--import", "tsx", bootstrapPath];
|
||||||
|
env.CLAWDBOT_ENTRY_ARGS = JSON.stringify(entryArgs);
|
||||||
|
|
||||||
child = spawn(nodeBin, childArgs, {
|
child = spawn(nodeBin, childArgs, {
|
||||||
cwd: process.cwd(),
|
cwd: process.cwd(),
|
||||||
env,
|
env,
|
||||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const proc = child;
|
const proc = child;
|
||||||
@@ -131,7 +148,7 @@ describe("gateway SIGTERM", () => {
|
|||||||
child.stdout?.on("data", (d) => out.push(String(d)));
|
child.stdout?.on("data", (d) => out.push(String(d)));
|
||||||
child.stderr?.on("data", (d) => err.push(String(d)));
|
child.stderr?.on("data", (d) => err.push(String(d)));
|
||||||
|
|
||||||
await waitForReady(proc, out, err, 150_000);
|
await waitForPortOpen(proc, out, err, port, 150_000);
|
||||||
|
|
||||||
proc.kill("SIGTERM");
|
proc.kill("SIGTERM");
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import { formatDocsLink } from "../terminal/links.js";
|
|||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
export type HooksListOptions = {
|
export type HooksListOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -224,8 +224,8 @@ export function formatHookInfo(
|
|||||||
} else {
|
} else {
|
||||||
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
|
lines.push(`${theme.muted(" Source:")} ${hook.source}`);
|
||||||
}
|
}
|
||||||
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(hook.filePath)}`);
|
lines.push(`${theme.muted(" Path:")} ${hook.filePath}`);
|
||||||
lines.push(`${theme.muted(" Handler:")} ${shortenHomePath(hook.handlerPath)}`);
|
lines.push(`${theme.muted(" Handler:")} ${hook.handlerPath}`);
|
||||||
if (hook.homepage) {
|
if (hook.homepage) {
|
||||||
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
|
lines.push(`${theme.muted(" Homepage:")} ${hook.homepage}`);
|
||||||
}
|
}
|
||||||
@@ -577,7 +577,7 @@ export function registerHooksCli(program: Command): void {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
defaultRuntime.log(`Linked hook path: ${shortenHomePath(resolved)}`);
|
defaultRuntime.log(`Linked hook path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
defaultRuntime.log(`Restart the gateway to load hooks.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-27
@@ -17,7 +17,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||||
import { resolveStateDir } from "../config/paths.js";
|
import { resolveStateDir } from "../config/paths.js";
|
||||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
type MemoryCommandOptions = {
|
type MemoryCommandOptions = {
|
||||||
agent?: string;
|
agent?: string;
|
||||||
@@ -45,15 +44,11 @@ type MemorySourceScan = {
|
|||||||
|
|
||||||
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
function formatSourceLabel(source: string, workspaceDir: string, agentId: string): string {
|
||||||
if (source === "memory") {
|
if (source === "memory") {
|
||||||
return shortenHomeInString(
|
return `memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`;
|
||||||
`memory (MEMORY.md + ${path.join(workspaceDir, "memory")}${path.sep}*.md)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (source === "sessions") {
|
if (source === "sessions") {
|
||||||
const stateDir = resolveStateDir(process.env, os.homedir);
|
const stateDir = resolveStateDir(process.env, os.homedir);
|
||||||
return shortenHomeInString(
|
return `sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`;
|
||||||
`sessions (${path.join(stateDir, "agents", agentId, "sessions")}${path.sep}*.jsonl)`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
@@ -81,10 +76,7 @@ async function checkReadableFile(pathname: string): Promise<{ exists: boolean; i
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
if (code === "ENOENT") return { exists: false };
|
if (code === "ENOENT") return { exists: false };
|
||||||
return {
|
return { exists: true, issue: `${pathname} not readable (${code ?? "error"})` };
|
||||||
exists: true,
|
|
||||||
issue: `${shortenHomePath(pathname)} not readable (${code ?? "error"})`,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +92,10 @@ async function scanSessionFiles(agentId: string): Promise<SourceScan> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
if (code === "ENOENT") {
|
if (code === "ENOENT") {
|
||||||
issues.push(`sessions directory missing (${shortenHomePath(sessionsDir)})`);
|
issues.push(`sessions directory missing (${sessionsDir})`);
|
||||||
return { source: "sessions", totalFiles: 0, issues };
|
return { source: "sessions", totalFiles: 0, issues };
|
||||||
}
|
}
|
||||||
issues.push(
|
issues.push(`sessions directory not accessible (${sessionsDir}): ${code ?? "error"}`);
|
||||||
`sessions directory not accessible (${shortenHomePath(sessionsDir)}): ${code ?? "error"}`,
|
|
||||||
);
|
|
||||||
return { source: "sessions", totalFiles: null, issues };
|
return { source: "sessions", totalFiles: null, issues };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,12 +118,10 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
if (code === "ENOENT") {
|
if (code === "ENOENT") {
|
||||||
issues.push(`memory directory missing (${shortenHomePath(memoryDir)})`);
|
issues.push(`memory directory missing (${memoryDir})`);
|
||||||
dirReadable = false;
|
dirReadable = false;
|
||||||
} else {
|
} else {
|
||||||
issues.push(
|
issues.push(`memory directory not accessible (${memoryDir}): ${code ?? "error"}`);
|
||||||
`memory directory not accessible (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
|
||||||
);
|
|
||||||
dirReadable = null;
|
dirReadable = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -146,9 +134,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
const code = (err as NodeJS.ErrnoException).code;
|
const code = (err as NodeJS.ErrnoException).code;
|
||||||
if (dirReadable !== null) {
|
if (dirReadable !== null) {
|
||||||
issues.push(
|
issues.push(`memory directory scan failed (${memoryDir}): ${code ?? "error"}`);
|
||||||
`memory directory scan failed (${shortenHomePath(memoryDir)}): ${code ?? "error"}`,
|
|
||||||
);
|
|
||||||
dirReadable = null;
|
dirReadable = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,7 +152,7 @@ async function scanMemoryFiles(workspaceDir: string): Promise<SourceScan> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
|
if ((totalFiles ?? 0) === 0 && issues.length === 0) {
|
||||||
issues.push(`no memory files found in ${shortenHomePath(workspaceDir)}`);
|
issues.push(`no memory files found in ${workspaceDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { source: "memory", totalFiles, issues };
|
return { source: "memory", totalFiles, issues };
|
||||||
@@ -308,8 +294,8 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
status.sources?.length ? `${label("Sources")} ${info(status.sources.join(", "))}` : null,
|
||||||
`${label("Indexed")} ${success(indexedLabel)}`,
|
`${label("Indexed")} ${success(indexedLabel)}`,
|
||||||
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
`${label("Dirty")} ${status.dirty ? warn("yes") : muted("no")}`,
|
||||||
`${label("Store")} ${info(shortenHomePath(status.dbPath))}`,
|
`${label("Store")} ${info(status.dbPath)}`,
|
||||||
`${label("Workspace")} ${info(shortenHomePath(status.workspaceDir))}`,
|
`${label("Workspace")} ${info(status.workspaceDir)}`,
|
||||||
].filter(Boolean) as string[];
|
].filter(Boolean) as string[];
|
||||||
if (embeddingProbe) {
|
if (embeddingProbe) {
|
||||||
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
const state = embeddingProbe.ok ? "ready" : "unavailable";
|
||||||
@@ -354,7 +340,7 @@ export async function runMemoryStatus(opts: MemoryCommandOptions) {
|
|||||||
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
lines.push(`${label("Vector dims")} ${info(String(status.vector.dims))}`);
|
||||||
}
|
}
|
||||||
if (status.vector.extensionPath) {
|
if (status.vector.extensionPath) {
|
||||||
lines.push(`${label("Vector path")} ${info(shortenHomePath(status.vector.extensionPath))}`);
|
lines.push(`${label("Vector path")} ${info(status.vector.extensionPath)}`);
|
||||||
}
|
}
|
||||||
if (status.vector.loadError) {
|
if (status.vector.loadError) {
|
||||||
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
lines.push(`${label("Vector error")} ${warn(status.vector.loadError)}`);
|
||||||
@@ -608,7 +594,7 @@ export function registerMemoryCli(program: Command) {
|
|||||||
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
|
`${colorize(rich, theme.success, result.score.toFixed(3))} ${colorize(
|
||||||
rich,
|
rich,
|
||||||
theme.accent,
|
theme.accent,
|
||||||
`${shortenHomePath(result.path)}:${result.startLine}-${result.endLine}`,
|
`${result.path}:${result.startLine}-${result.endLine}`,
|
||||||
)}`,
|
)}`,
|
||||||
);
|
);
|
||||||
lines.push(colorize(rich, theme.muted, result.snippet));
|
lines.push(colorize(rich, theme.muted, result.snippet));
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
|||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
import { renderTable } from "../../terminal/table.js";
|
import { renderTable } from "../../terminal/table.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
|
|
||||||
const parseFacing = (value: string): CameraFacing => {
|
const parseFacing = (value: string): CameraFacing => {
|
||||||
const v = String(value ?? "")
|
const v = String(value ?? "")
|
||||||
@@ -166,7 +165,7 @@ export function registerNodesCameraCommands(nodes: Command) {
|
|||||||
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
|
defaultRuntime.log(JSON.stringify({ files: results }, null, 2));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(results.map((r) => `MEDIA:${shortenHomePath(r.path)}`).join("\n"));
|
defaultRuntime.log(results.map((r) => `MEDIA:${r.path}`).join("\n"));
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{ timeoutMs: 60_000 },
|
{ timeoutMs: 60_000 },
|
||||||
@@ -240,7 +239,7 @@ export function registerNodesCameraCommands(nodes: Command) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{ timeoutMs: 90_000 },
|
{ timeoutMs: 90_000 },
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { buildA2UITextJsonl, validateA2UIJsonl } from "./a2ui-jsonl.js";
|
|||||||
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
import { getNodesTheme, runNodesCommand } from "./cli-utils.js";
|
||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
|
|
||||||
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
|
async function invokeCanvas(opts: NodesRpcOpts, command: string, params?: Record<string, unknown>) {
|
||||||
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
const nodeId = await resolveNodeId(opts, String(opts.node ?? ""));
|
||||||
@@ -86,7 +85,7 @@ export function registerNodesCanvasCommands(nodes: Command) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(filePath)}`);
|
defaultRuntime.log(`MEDIA:${filePath}`);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{ timeoutMs: 60_000 },
|
{ timeoutMs: 60_000 },
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { parseDurationMs } from "../parse-duration.js";
|
|||||||
import { runNodesCommand } from "./cli-utils.js";
|
import { runNodesCommand } from "./cli-utils.js";
|
||||||
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
|
|
||||||
export function registerNodesScreenCommands(nodes: Command) {
|
export function registerNodesScreenCommands(nodes: Command) {
|
||||||
const screen = nodes
|
const screen = nodes
|
||||||
@@ -78,7 +77,7 @@ export function registerNodesScreenCommands(nodes: Command) {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
defaultRuntime.log(`MEDIA:${shortenHomePath(written.path)}`);
|
defaultRuntime.log(`MEDIA:${written.path}`);
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
{ timeoutMs: 180_000 },
|
{ timeoutMs: 180_000 },
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { callGatewayCli, nodesCallOpts, resolveNodeId } from "./rpc.js";
|
|||||||
import type { NodesRpcOpts } from "./types.js";
|
import type { NodesRpcOpts } from "./types.js";
|
||||||
import { renderTable } from "../../terminal/table.js";
|
import { renderTable } from "../../terminal/table.js";
|
||||||
import { parseDurationMs } from "../parse-duration.js";
|
import { parseDurationMs } from "../parse-duration.js";
|
||||||
import { shortenHomeInString } from "../../utils.js";
|
|
||||||
|
|
||||||
function formatVersionLabel(raw: string) {
|
function formatVersionLabel(raw: string) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
@@ -50,9 +49,8 @@ function formatPathEnv(raw?: string): string | null {
|
|||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
const parts = trimmed.split(":").filter(Boolean);
|
const parts = trimmed.split(":").filter(Boolean);
|
||||||
const display =
|
if (parts.length <= 3) return trimmed;
|
||||||
parts.length <= 3 ? trimmed : `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
|
return `${parts.slice(0, 2).join(":")}:…:${parts.slice(-1)[0]}`;
|
||||||
return shortenHomeInString(display);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
function parseSinceMs(raw: unknown, label: string): number | undefined {
|
||||||
|
|||||||
+12
-15
@@ -15,7 +15,7 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { resolveUserPath, shortenHomeInString, shortenHomePath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
export type PluginsListOptions = {
|
export type PluginsListOptions = {
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
@@ -55,7 +55,7 @@ function formatPluginLine(plugin: PluginRecord, verbose = false): string {
|
|||||||
|
|
||||||
const parts = [
|
const parts = [
|
||||||
`${name}${idSuffix} ${status}`,
|
`${name}${idSuffix} ${status}`,
|
||||||
` source: ${theme.muted(shortenHomeInString(plugin.source))}`,
|
` source: ${theme.muted(plugin.source)}`,
|
||||||
` origin: ${plugin.origin}`,
|
` origin: ${plugin.origin}`,
|
||||||
];
|
];
|
||||||
if (plugin.version) parts.push(` version: ${plugin.version}`);
|
if (plugin.version) parts.push(` version: ${plugin.version}`);
|
||||||
@@ -135,10 +135,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
|
|
||||||
if (!opts.verbose) {
|
if (!opts.verbose) {
|
||||||
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
const tableWidth = Math.max(60, (process.stdout.columns ?? 120) - 1);
|
||||||
const rows = list.map((plugin) => {
|
const rows = list.map((plugin) => ({
|
||||||
const desc = plugin.description ? theme.muted(plugin.description) : "";
|
|
||||||
const sourceLine = desc ? `${plugin.source}\n${desc}` : plugin.source;
|
|
||||||
return {
|
|
||||||
Name: plugin.name || plugin.id,
|
Name: plugin.name || plugin.id,
|
||||||
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
|
ID: plugin.name && plugin.name !== plugin.id ? plugin.id : "",
|
||||||
Status:
|
Status:
|
||||||
@@ -147,10 +144,10 @@ export function registerPluginsCli(program: Command) {
|
|||||||
: plugin.status === "disabled"
|
: plugin.status === "disabled"
|
||||||
? theme.warn("disabled")
|
? theme.warn("disabled")
|
||||||
: theme.error("error"),
|
: theme.error("error"),
|
||||||
Source: sourceLine,
|
Source: plugin.source,
|
||||||
Version: plugin.version ?? "",
|
Version: plugin.version ?? "",
|
||||||
};
|
Description: plugin.description ?? "",
|
||||||
});
|
}));
|
||||||
defaultRuntime.log(
|
defaultRuntime.log(
|
||||||
renderTable({
|
renderTable({
|
||||||
width: tableWidth,
|
width: tableWidth,
|
||||||
@@ -158,8 +155,9 @@ export function registerPluginsCli(program: Command) {
|
|||||||
{ key: "Name", header: "Name", minWidth: 14, flex: true },
|
{ key: "Name", header: "Name", minWidth: 14, flex: true },
|
||||||
{ key: "ID", header: "ID", minWidth: 10, flex: true },
|
{ key: "ID", header: "ID", minWidth: 10, flex: true },
|
||||||
{ key: "Status", header: "Status", minWidth: 10 },
|
{ key: "Status", header: "Status", minWidth: 10 },
|
||||||
{ key: "Source", header: "Source", minWidth: 26, flex: true },
|
{ key: "Source", header: "Source", minWidth: 10 },
|
||||||
{ key: "Version", header: "Version", minWidth: 8 },
|
{ key: "Version", header: "Version", minWidth: 8 },
|
||||||
|
{ key: "Description", header: "Description", minWidth: 18, flex: true },
|
||||||
],
|
],
|
||||||
rows,
|
rows,
|
||||||
}).trimEnd(),
|
}).trimEnd(),
|
||||||
@@ -203,7 +201,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
if (plugin.description) lines.push(plugin.description);
|
if (plugin.description) lines.push(plugin.description);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
|
lines.push(`${theme.muted("Status:")} ${plugin.status}`);
|
||||||
lines.push(`${theme.muted("Source:")} ${shortenHomeInString(plugin.source)}`);
|
lines.push(`${theme.muted("Source:")} ${plugin.source}`);
|
||||||
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
|
lines.push(`${theme.muted("Origin:")} ${plugin.origin}`);
|
||||||
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
|
if (plugin.version) lines.push(`${theme.muted("Version:")} ${plugin.version}`);
|
||||||
if (plugin.toolNames.length > 0) {
|
if (plugin.toolNames.length > 0) {
|
||||||
@@ -229,10 +227,9 @@ export function registerPluginsCli(program: Command) {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(`${theme.muted("Install:")} ${install.source}`);
|
lines.push(`${theme.muted("Install:")} ${install.source}`);
|
||||||
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
|
if (install.spec) lines.push(`${theme.muted("Spec:")} ${install.spec}`);
|
||||||
if (install.sourcePath)
|
if (install.sourcePath) lines.push(`${theme.muted("Source path:")} ${install.sourcePath}`);
|
||||||
lines.push(`${theme.muted("Source path:")} ${shortenHomePath(install.sourcePath)}`);
|
|
||||||
if (install.installPath)
|
if (install.installPath)
|
||||||
lines.push(`${theme.muted("Install path:")} ${shortenHomePath(install.installPath)}`);
|
lines.push(`${theme.muted("Install path:")} ${install.installPath}`);
|
||||||
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
|
if (install.version) lines.push(`${theme.muted("Recorded version:")} ${install.version}`);
|
||||||
if (install.installedAt)
|
if (install.installedAt)
|
||||||
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
|
lines.push(`${theme.muted("Installed at:")} ${install.installedAt}`);
|
||||||
@@ -336,7 +333,7 @@ export function registerPluginsCli(program: Command) {
|
|||||||
next = slotResult.config;
|
next = slotResult.config;
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
logSlotWarnings(slotResult.warnings);
|
logSlotWarnings(slotResult.warnings);
|
||||||
defaultRuntime.log(`Linked plugin path: ${shortenHomePath(resolved)}`);
|
defaultRuntime.log(`Linked plugin path: ${resolved}`);
|
||||||
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
defaultRuntime.log(`Restart the gateway to load plugins.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { loadAndMaybeMigrateDoctorConfig } from "../../commands/doctor-config-fl
|
|||||||
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
import { colorize, isRich, theme } from "../../terminal/theme.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { formatCliCommand } from "../command-format.js";
|
import { formatCliCommand } from "../command-format.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
|
||||||
|
|
||||||
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]);
|
const ALLOWED_INVALID_COMMANDS = new Set(["doctor", "logs", "health", "help", "status"]);
|
||||||
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
|
const ALLOWED_INVALID_GATEWAY_SUBCOMMANDS = new Set([
|
||||||
@@ -61,7 +60,7 @@ export async function ensureConfigReady(params: {
|
|||||||
const commandText = (value: string) => colorize(rich, theme.command, value);
|
const commandText = (value: string) => colorize(rich, theme.command, value);
|
||||||
|
|
||||||
params.runtime.error(heading("Config invalid"));
|
params.runtime.error(heading("Config invalid"));
|
||||||
params.runtime.error(`${muted("File:")} ${muted(shortenHomePath(snapshot.path))}`);
|
params.runtime.error(`${muted("File:")} ${muted(snapshot.path)}`);
|
||||||
if (issues.length > 0) {
|
if (issues.length > 0) {
|
||||||
params.runtime.error(muted("Problem:"));
|
params.runtime.error(muted("Problem:"));
|
||||||
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));
|
params.runtime.error(issues.map((issue) => ` ${error(issue)}`).join("\n"));
|
||||||
|
|||||||
+5
-12
@@ -6,7 +6,6 @@ import { runSecurityAudit } from "../security/audit.js";
|
|||||||
import { fixSecurityFootguns } from "../security/fix.js";
|
import { fixSecurityFootguns } from "../security/fix.js";
|
||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { isRich, theme } from "../terminal/theme.js";
|
import { isRich, theme } from "../terminal/theme.js";
|
||||||
import { shortenHomeInString, shortenHomePath } from "../utils.js";
|
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
|
|
||||||
type SecurityAuditOptions = {
|
type SecurityAuditOptions = {
|
||||||
@@ -84,24 +83,18 @@ export function registerSecurityCli(program: Command) {
|
|||||||
lines.push("");
|
lines.push("");
|
||||||
lines.push(heading("FIX"));
|
lines.push(heading("FIX"));
|
||||||
for (const change of fixResult.changes) {
|
for (const change of fixResult.changes) {
|
||||||
lines.push(muted(` ${shortenHomeInString(change)}`));
|
lines.push(muted(` ${change}`));
|
||||||
}
|
}
|
||||||
for (const action of fixResult.actions) {
|
for (const action of fixResult.actions) {
|
||||||
const mode = action.mode.toString(8).padStart(3, "0");
|
const mode = action.mode.toString(8).padStart(3, "0");
|
||||||
if (action.ok) lines.push(muted(` chmod ${mode} ${shortenHomePath(action.path)}`));
|
if (action.ok) lines.push(muted(` chmod ${mode} ${action.path}`));
|
||||||
else if (action.skipped)
|
else if (action.skipped)
|
||||||
lines.push(
|
lines.push(muted(` skip chmod ${mode} ${action.path} (${action.skipped})`));
|
||||||
muted(` skip chmod ${mode} ${shortenHomePath(action.path)} (${action.skipped})`),
|
|
||||||
);
|
|
||||||
else if (action.error)
|
else if (action.error)
|
||||||
lines.push(
|
lines.push(muted(` chmod ${mode} ${action.path} failed: ${action.error}`));
|
||||||
muted(` chmod ${mode} ${shortenHomePath(action.path)} failed: ${action.error}`),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (fixResult.errors.length > 0) {
|
if (fixResult.errors.length > 0) {
|
||||||
for (const err of fixResult.errors) {
|
for (const err of fixResult.errors) lines.push(muted(` error: ${err}`));
|
||||||
lines.push(muted(` error: ${shortenHomeInString(err)}`));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { defaultRuntime } from "../runtime.js";
|
|||||||
import { formatDocsLink } from "../terminal/links.js";
|
import { formatDocsLink } from "../terminal/links.js";
|
||||||
import { renderTable } from "../terminal/table.js";
|
import { renderTable } from "../terminal/table.js";
|
||||||
import { theme } from "../terminal/theme.js";
|
import { theme } from "../terminal/theme.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import { formatCliCommand } from "./command-format.js";
|
import { formatCliCommand } from "./command-format.js";
|
||||||
|
|
||||||
export type SkillsListOptions = {
|
export type SkillsListOptions = {
|
||||||
@@ -177,7 +176,7 @@ export function formatSkillInfo(
|
|||||||
// Details
|
// Details
|
||||||
lines.push(theme.heading("Details:"));
|
lines.push(theme.heading("Details:"));
|
||||||
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
|
lines.push(`${theme.muted(" Source:")} ${skill.source}`);
|
||||||
lines.push(`${theme.muted(" Path:")} ${shortenHomePath(skill.filePath)}`);
|
lines.push(`${theme.muted(" Path:")} ${skill.filePath}`);
|
||||||
if (skill.homepage) {
|
if (skill.homepage) {
|
||||||
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
|
lines.push(`${theme.muted(" Homepage:")} ${skill.homepage}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,11 @@ import {
|
|||||||
} from "../agents/agent-scope.js";
|
} from "../agents/agent-scope.js";
|
||||||
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
import { ensureAuthProfileStore } from "../agents/auth-profiles.js";
|
||||||
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
import { resolveAuthStorePath } from "../agents/auth-profiles/paths.js";
|
||||||
import { writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
import { logConfigUpdated } from "../config/logging.js";
|
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
import { createClackPrompter } from "../wizard/clack-prompter.js";
|
||||||
import { WizardCancelledError } from "../wizard/prompts.js";
|
import { WizardCancelledError } from "../wizard/prompts.js";
|
||||||
import {
|
import {
|
||||||
@@ -127,7 +126,7 @@ export async function agentsAddCommand(
|
|||||||
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
|
: { config: nextConfig, added: [], skipped: [], conflicts: [] };
|
||||||
|
|
||||||
await writeConfigFile(bindingResult.config);
|
await writeConfigFile(bindingResult.config);
|
||||||
if (!opts.json) logConfigUpdated(runtime);
|
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||||
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
|
await ensureWorkspaceAndSessions(workspaceDir, quietRuntime, {
|
||||||
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
|
skipBootstrap: Boolean(bindingResult.config.agents?.defaults?.skipBootstrap),
|
||||||
@@ -152,8 +151,8 @@ export async function agentsAddCommand(
|
|||||||
runtime.log(JSON.stringify(payload, null, 2));
|
runtime.log(JSON.stringify(payload, null, 2));
|
||||||
} else {
|
} else {
|
||||||
runtime.log(`Agent: ${agentId}`);
|
runtime.log(`Agent: ${agentId}`);
|
||||||
runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
|
runtime.log(`Workspace: ${workspaceDir}`);
|
||||||
runtime.log(`Agent dir: ${shortenHomePath(agentDir)}`);
|
runtime.log(`Agent dir: ${agentDir}`);
|
||||||
if (model) runtime.log(`Model: ${model}`);
|
if (model) runtime.log(`Model: ${model}`);
|
||||||
if (bindingResult.conflicts.length > 0) {
|
if (bindingResult.conflicts.length > 0) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
@@ -335,7 +334,7 @@ export async function agentsAddCommand(
|
|||||||
}
|
}
|
||||||
|
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||||
agentId,
|
agentId,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
import { resolveAgentDir, resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
|
||||||
import { writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
import { logConfigUpdated } from "../config/logging.js";
|
|
||||||
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
import { resolveSessionTranscriptsDirForAgent } from "../config/sessions.js";
|
||||||
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
import { DEFAULT_AGENT_ID, normalizeAgentId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
@@ -70,7 +69,7 @@ export async function agentsDeleteCommand(
|
|||||||
|
|
||||||
const result = pruneAgentConfig(cfg, agentId);
|
const result = pruneAgentConfig(cfg, agentId);
|
||||||
await writeConfigFile(result.config);
|
await writeConfigFile(result.config);
|
||||||
if (!opts.json) logConfigUpdated(runtime);
|
if (!opts.json) runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
|
||||||
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
const quietRuntime = opts.json ? createQuietRuntime(runtime) : runtime;
|
||||||
await moveToTrash(workspaceDir, quietRuntime);
|
await moveToTrash(workspaceDir, quietRuntime);
|
||||||
|
|||||||
@@ -4,13 +4,12 @@ import path from "node:path";
|
|||||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||||
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
|
import { identityHasValues, parseIdentityMarkdown } from "../agents/identity-file.js";
|
||||||
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
import { DEFAULT_IDENTITY_FILENAME } from "../agents/workspace.js";
|
||||||
import { writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
import { logConfigUpdated } from "../config/logging.js";
|
|
||||||
import type { IdentityConfig } from "../config/types.js";
|
import type { IdentityConfig } from "../config/types.js";
|
||||||
import { normalizeAgentId } from "../routing/session-key.js";
|
import { normalizeAgentId } from "../routing/session-key.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { resolveUserPath, shortenHomePath } from "../utils.js";
|
import { resolveUserPath } from "../utils.js";
|
||||||
import { requireValidConfig } from "./agents.command-shared.js";
|
import { requireValidConfig } from "./agents.command-shared.js";
|
||||||
import {
|
import {
|
||||||
type AgentIdentity,
|
type AgentIdentity,
|
||||||
@@ -106,14 +105,14 @@ export async function agentsSetIdentityCommand(
|
|||||||
const matches = resolveAgentIdByWorkspace(cfg, workspaceDir);
|
const matches = resolveAgentIdByWorkspace(cfg, workspaceDir);
|
||||||
if (matches.length === 0) {
|
if (matches.length === 0) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`No agent workspace matches ${shortenHomePath(workspaceDir)}. Pass --agent to target a specific agent.`,
|
`No agent workspace matches ${workspaceDir}. Pass --agent to target a specific agent.`,
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (matches.length > 1) {
|
if (matches.length > 1) {
|
||||||
runtime.error(
|
runtime.error(
|
||||||
`Multiple agents match ${shortenHomePath(workspaceDir)}: ${matches.join(", ")}. Pass --agent to choose one.`,
|
`Multiple agents match ${workspaceDir}: ${matches.join(", ")}. Pass --agent to choose one.`,
|
||||||
);
|
);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
@@ -132,7 +131,7 @@ export async function agentsSetIdentityCommand(
|
|||||||
const targetPath =
|
const targetPath =
|
||||||
identityFilePath ??
|
identityFilePath ??
|
||||||
(workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md");
|
(workspaceDir ? path.join(workspaceDir, DEFAULT_IDENTITY_FILENAME) : "IDENTITY.md");
|
||||||
runtime.error(`No identity data found in ${shortenHomePath(targetPath)}.`);
|
runtime.error(`No identity data found in ${targetPath}.`);
|
||||||
runtime.exit(1);
|
runtime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -212,11 +211,11 @@ export async function agentsSetIdentityCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Agent: ${agentId}`);
|
runtime.log(`Agent: ${agentId}`);
|
||||||
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
if (nextIdentity.name) runtime.log(`Name: ${nextIdentity.name}`);
|
||||||
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
if (nextIdentity.theme) runtime.log(`Theme: ${nextIdentity.theme}`);
|
||||||
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
if (nextIdentity.emoji) runtime.log(`Emoji: ${nextIdentity.emoji}`);
|
||||||
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
if (nextIdentity.avatar) runtime.log(`Avatar: ${nextIdentity.avatar}`);
|
||||||
if (workspaceDir) runtime.log(`Workspace: ${shortenHomePath(workspaceDir)}`);
|
if (workspaceDir) runtime.log(`Workspace: ${workspaceDir}`);
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,6 @@ import { normalizeAgentId } from "../routing/session-key.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import { describeBinding } from "./agents.bindings.js";
|
import { describeBinding } from "./agents.bindings.js";
|
||||||
import { requireValidConfig } from "./agents.command-shared.js";
|
import { requireValidConfig } from "./agents.command-shared.js";
|
||||||
import type { AgentSummary } from "./agents.config.js";
|
import type { AgentSummary } from "./agents.config.js";
|
||||||
@@ -41,8 +40,8 @@ function formatSummary(summary: AgentSummary) {
|
|||||||
if (identityLine) {
|
if (identityLine) {
|
||||||
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
|
lines.push(` Identity: ${identityLine}${identitySource ? ` (${identitySource})` : ""}`);
|
||||||
}
|
}
|
||||||
lines.push(` Workspace: ${shortenHomePath(summary.workspace)}`);
|
lines.push(` Workspace: ${summary.workspace}`);
|
||||||
lines.push(` Agent dir: ${shortenHomePath(summary.agentDir)}`);
|
lines.push(` Agent dir: ${summary.agentDir}`);
|
||||||
if (summary.model) lines.push(` Model: ${summary.model}`);
|
if (summary.model) lines.push(` Model: ${summary.model}`);
|
||||||
lines.push(` Routing rules: ${summary.bindings}`);
|
lines.push(` Routing rules: ${summary.bindings}`);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export async function applyAuthChoiceGitHubCopilot(
|
|||||||
nextConfig = applyAuthProfileConfig(nextConfig, {
|
nextConfig = applyAuthProfileConfig(nextConfig, {
|
||||||
profileId: "github-copilot:github",
|
profileId: "github-copilot:github",
|
||||||
provider: "github-copilot",
|
provider: "github-copilot",
|
||||||
mode: "oauth",
|
mode: "token",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (params.setDefaultModel) {
|
if (params.setDefaultModel) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import path from "node:path";
|
|||||||
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { resolveHomeDir, resolveUserPath, shortenHomeInString } from "../utils.js";
|
import { resolveHomeDir, resolveUserPath } from "../utils.js";
|
||||||
|
|
||||||
export type RemovalResult = {
|
export type RemovalResult = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
@@ -53,21 +53,20 @@ export async function removePath(
|
|||||||
if (!target?.trim()) return { ok: false, skipped: true };
|
if (!target?.trim()) return { ok: false, skipped: true };
|
||||||
const resolved = path.resolve(target);
|
const resolved = path.resolve(target);
|
||||||
const label = opts?.label ?? resolved;
|
const label = opts?.label ?? resolved;
|
||||||
const displayLabel = shortenHomeInString(label);
|
|
||||||
if (isUnsafeRemovalTarget(resolved)) {
|
if (isUnsafeRemovalTarget(resolved)) {
|
||||||
runtime.error(`Refusing to remove unsafe path: ${displayLabel}`);
|
runtime.error(`Refusing to remove unsafe path: ${label}`);
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
if (opts?.dryRun) {
|
if (opts?.dryRun) {
|
||||||
runtime.log(`[dry-run] remove ${displayLabel}`);
|
runtime.log(`[dry-run] remove ${label}`);
|
||||||
return { ok: true, skipped: true };
|
return { ok: true, skipped: true };
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await fs.rm(resolved, { recursive: true, force: true });
|
await fs.rm(resolved, { recursive: true, force: true });
|
||||||
runtime.log(`Removed ${displayLabel}`);
|
runtime.log(`Removed ${label}`);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
runtime.error(`Failed to remove ${displayLabel}: ${String(err)}`);
|
runtime.error(`Failed to remove ${label}: ${String(err)}`);
|
||||||
return { ok: false };
|
return { ok: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type { ClawdbotConfig } from "../config/config.js";
|
|||||||
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import { confirm, select } from "./configure.shared.js";
|
import { confirm, select } from "./configure.shared.js";
|
||||||
import { guardCancel } from "./onboard-helpers.js";
|
import { guardCancel } from "./onboard-helpers.js";
|
||||||
|
|
||||||
@@ -52,7 +51,7 @@ export async function removeChannelConfigWizard(
|
|||||||
const label = getChannelPlugin(channel)?.meta.label ?? channel;
|
const label = getChannelPlugin(channel)?.meta.label ?? channel;
|
||||||
const confirmed = guardCancel(
|
const confirmed = guardCancel(
|
||||||
await confirm({
|
await confirm({
|
||||||
message: `Delete ${label} configuration from ${shortenHomePath(CONFIG_PATH_CLAWDBOT)}?`,
|
message: `Delete ${label} configuration from ${CONFIG_PATH_CLAWDBOT}?`,
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
}),
|
}),
|
||||||
runtime,
|
runtime,
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { readConfigFileSnapshot, resolveGatewayPort, writeConfigFile } from "../config/config.js";
|
import {
|
||||||
import { logConfigUpdated } from "../config/logging.js";
|
CONFIG_PATH_CLAWDBOT,
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
resolveGatewayPort,
|
||||||
|
writeConfigFile,
|
||||||
|
} from "../config/config.js";
|
||||||
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
import { ensureControlUiAssetsBuilt } from "../infra/control-ui-assets.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
@@ -249,7 +253,7 @@ export async function runConfigureWizard(
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
await writeConfigFile(remoteConfig);
|
await writeConfigFile(remoteConfig);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
outro("Remote gateway configured.");
|
outro("Remote gateway configured.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -282,7 +286,7 @@ export async function runConfigureWizard(
|
|||||||
mode,
|
mode,
|
||||||
});
|
});
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
};
|
};
|
||||||
|
|
||||||
if (opts.sections) {
|
if (opts.sections) {
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { promisify } from "node:util";
|
|||||||
|
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
const execFileAsync = promisify(execFile);
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
@@ -20,11 +19,10 @@ export async function noteMacLaunchAgentOverrides() {
|
|||||||
const hasMarker = fs.existsSync(markerPath);
|
const hasMarker = fs.existsSync(markerPath);
|
||||||
if (!hasMarker) return;
|
if (!hasMarker) return;
|
||||||
|
|
||||||
const displayMarkerPath = shortenHomePath(markerPath);
|
|
||||||
const lines = [
|
const lines = [
|
||||||
`- LaunchAgent writes are disabled via ${displayMarkerPath}.`,
|
`- LaunchAgent writes are disabled via ${markerPath}.`,
|
||||||
"- To restore default behavior:",
|
"- To restore default behavior:",
|
||||||
` rm ${displayMarkerPath}`,
|
` rm ${markerPath}`,
|
||||||
].filter((line): line is string => Boolean(line));
|
].filter((line): line is string => Boolean(line));
|
||||||
note(lines.join("\n"), "Gateway (macOS)");
|
note(lines.join("\n"), "Gateway (macOS)");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
resolveStorePath,
|
resolveStorePath,
|
||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
type DoctorPrompterLike = {
|
type DoctorPrompterLike = {
|
||||||
confirmSkipInNonInteractive: (params: {
|
confirmSkipInNonInteractive: (params: {
|
||||||
@@ -132,16 +131,11 @@ export async function noteStateIntegrity(
|
|||||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, homedir);
|
||||||
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
const storePath = resolveStorePath(cfg.session?.store, { agentId });
|
||||||
const storeDir = path.dirname(storePath);
|
const storeDir = path.dirname(storePath);
|
||||||
const displayStateDir = shortenHomePath(stateDir);
|
|
||||||
const displayOauthDir = shortenHomePath(oauthDir);
|
|
||||||
const displaySessionsDir = shortenHomePath(sessionsDir);
|
|
||||||
const displayStoreDir = shortenHomePath(storeDir);
|
|
||||||
const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined;
|
|
||||||
|
|
||||||
let stateDirExists = existsDir(stateDir);
|
let stateDirExists = existsDir(stateDir);
|
||||||
if (!stateDirExists) {
|
if (!stateDirExists) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- CRITICAL: state directory missing (${displayStateDir}). Sessions, credentials, logs, and config are stored there.`,
|
`- CRITICAL: state directory missing (${stateDir}). Sessions, credentials, logs, and config are stored there.`,
|
||||||
);
|
);
|
||||||
if (cfg.gateway?.mode === "remote") {
|
if (cfg.gateway?.mode === "remote") {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
@@ -149,26 +143,26 @@ export async function noteStateIntegrity(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const create = await prompter.confirmSkipInNonInteractive({
|
const create = await prompter.confirmSkipInNonInteractive({
|
||||||
message: `Create ${displayStateDir} now?`,
|
message: `Create ${stateDir} now?`,
|
||||||
initialValue: false,
|
initialValue: false,
|
||||||
});
|
});
|
||||||
if (create) {
|
if (create) {
|
||||||
const created = ensureDir(stateDir);
|
const created = ensureDir(stateDir);
|
||||||
if (created.ok) {
|
if (created.ok) {
|
||||||
changes.push(`- Created ${displayStateDir}`);
|
changes.push(`- Created ${stateDir}`);
|
||||||
stateDirExists = true;
|
stateDirExists = true;
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`- Failed to create ${displayStateDir}: ${created.error}`);
|
warnings.push(`- Failed to create ${stateDir}: ${created.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stateDirExists && !canWriteDir(stateDir)) {
|
if (stateDirExists && !canWriteDir(stateDir)) {
|
||||||
warnings.push(`- State directory not writable (${displayStateDir}).`);
|
warnings.push(`- State directory not writable (${stateDir}).`);
|
||||||
const hint = dirPermissionHint(stateDir);
|
const hint = dirPermissionHint(stateDir);
|
||||||
if (hint) warnings.push(` ${hint}`);
|
if (hint) warnings.push(` ${hint}`);
|
||||||
const repair = await prompter.confirmSkipInNonInteractive({
|
const repair = await prompter.confirmSkipInNonInteractive({
|
||||||
message: `Repair permissions on ${displayStateDir}?`,
|
message: `Repair permissions on ${stateDir}?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (repair) {
|
if (repair) {
|
||||||
@@ -176,9 +170,9 @@ export async function noteStateIntegrity(
|
|||||||
const stat = fs.statSync(stateDir);
|
const stat = fs.statSync(stateDir);
|
||||||
const target = addUserRwx(stat.mode);
|
const target = addUserRwx(stat.mode);
|
||||||
fs.chmodSync(stateDir, target);
|
fs.chmodSync(stateDir, target);
|
||||||
changes.push(`- Repaired permissions on ${displayStateDir}`);
|
changes.push(`- Repaired permissions on ${stateDir}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(`- Failed to repair ${displayStateDir}: ${String(err)}`);
|
warnings.push(`- Failed to repair ${stateDir}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,19 +181,19 @@ export async function noteStateIntegrity(
|
|||||||
const stat = fs.statSync(stateDir);
|
const stat = fs.statSync(stateDir);
|
||||||
if ((stat.mode & 0o077) !== 0) {
|
if ((stat.mode & 0o077) !== 0) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- State directory permissions are too open (${displayStateDir}). Recommend chmod 700.`,
|
`- State directory permissions are too open (${stateDir}). Recommend chmod 700.`,
|
||||||
);
|
);
|
||||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||||
message: `Tighten permissions on ${displayStateDir} to 700?`,
|
message: `Tighten permissions on ${stateDir} to 700?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (tighten) {
|
if (tighten) {
|
||||||
fs.chmodSync(stateDir, 0o700);
|
fs.chmodSync(stateDir, 0o700);
|
||||||
changes.push(`- Tightened permissions on ${displayStateDir} to 700`);
|
changes.push(`- Tightened permissions on ${stateDir} to 700`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(`- Failed to read ${displayStateDir} permissions: ${String(err)}`);
|
warnings.push(`- Failed to read ${stateDir} permissions: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,21 +202,19 @@ export async function noteStateIntegrity(
|
|||||||
const stat = fs.statSync(configPath);
|
const stat = fs.statSync(configPath);
|
||||||
if ((stat.mode & 0o077) !== 0) {
|
if ((stat.mode & 0o077) !== 0) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- Config file is group/world readable (${displayConfigPath ?? configPath}). Recommend chmod 600.`,
|
`- Config file is group/world readable (${configPath}). Recommend chmod 600.`,
|
||||||
);
|
);
|
||||||
const tighten = await prompter.confirmSkipInNonInteractive({
|
const tighten = await prompter.confirmSkipInNonInteractive({
|
||||||
message: `Tighten permissions on ${displayConfigPath ?? configPath} to 600?`,
|
message: `Tighten permissions on ${configPath} to 600?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (tighten) {
|
if (tighten) {
|
||||||
fs.chmodSync(configPath, 0o600);
|
fs.chmodSync(configPath, 0o600);
|
||||||
changes.push(`- Tightened permissions on ${displayConfigPath ?? configPath} to 600`);
|
changes.push(`- Tightened permissions on ${configPath} to 600`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(
|
warnings.push(`- Failed to read config permissions (${configPath}): ${String(err)}`);
|
||||||
`- Failed to read config permissions (${displayConfigPath ?? configPath}): ${String(err)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,33 +223,26 @@ export async function noteStateIntegrity(
|
|||||||
dirCandidates.set(sessionsDir, "Sessions dir");
|
dirCandidates.set(sessionsDir, "Sessions dir");
|
||||||
dirCandidates.set(storeDir, "Session store dir");
|
dirCandidates.set(storeDir, "Session store dir");
|
||||||
dirCandidates.set(oauthDir, "OAuth dir");
|
dirCandidates.set(oauthDir, "OAuth dir");
|
||||||
const displayDirFor = (dir: string) => {
|
|
||||||
if (dir === sessionsDir) return displaySessionsDir;
|
|
||||||
if (dir === storeDir) return displayStoreDir;
|
|
||||||
if (dir === oauthDir) return displayOauthDir;
|
|
||||||
return shortenHomePath(dir);
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const [dir, label] of dirCandidates) {
|
for (const [dir, label] of dirCandidates) {
|
||||||
const displayDir = displayDirFor(dir);
|
|
||||||
if (!existsDir(dir)) {
|
if (!existsDir(dir)) {
|
||||||
warnings.push(`- CRITICAL: ${label} missing (${displayDir}).`);
|
warnings.push(`- CRITICAL: ${label} missing (${dir}).`);
|
||||||
const create = await prompter.confirmSkipInNonInteractive({
|
const create = await prompter.confirmSkipInNonInteractive({
|
||||||
message: `Create ${label} at ${displayDir}?`,
|
message: `Create ${label} at ${dir}?`,
|
||||||
initialValue: true,
|
initialValue: true,
|
||||||
});
|
});
|
||||||
if (create) {
|
if (create) {
|
||||||
const created = ensureDir(dir);
|
const created = ensureDir(dir);
|
||||||
if (created.ok) {
|
if (created.ok) {
|
||||||
changes.push(`- Created ${label}: ${displayDir}`);
|
changes.push(`- Created ${label}: ${dir}`);
|
||||||
} else {
|
} else {
|
||||||
warnings.push(`- Failed to create ${displayDir}: ${created.error}`);
|
warnings.push(`- Failed to create ${dir}: ${created.error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!canWriteDir(dir)) {
|
if (!canWriteDir(dir)) {
|
||||||
warnings.push(`- ${label} not writable (${displayDir}).`);
|
warnings.push(`- ${label} not writable (${dir}).`);
|
||||||
const hint = dirPermissionHint(dir);
|
const hint = dirPermissionHint(dir);
|
||||||
if (hint) warnings.push(` ${hint}`);
|
if (hint) warnings.push(` ${hint}`);
|
||||||
const repair = await prompter.confirmSkipInNonInteractive({
|
const repair = await prompter.confirmSkipInNonInteractive({
|
||||||
@@ -269,9 +254,9 @@ export async function noteStateIntegrity(
|
|||||||
const stat = fs.statSync(dir);
|
const stat = fs.statSync(dir);
|
||||||
const target = addUserRwx(stat.mode);
|
const target = addUserRwx(stat.mode);
|
||||||
fs.chmodSync(dir, target);
|
fs.chmodSync(dir, target);
|
||||||
changes.push(`- Repaired permissions on ${label}: ${displayDir}`);
|
changes.push(`- Repaired permissions on ${label}: ${dir}`);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
warnings.push(`- Failed to repair ${displayDir}: ${String(err)}`);
|
warnings.push(`- Failed to repair ${dir}: ${String(err)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -289,8 +274,8 @@ export async function noteStateIntegrity(
|
|||||||
warnings.push(
|
warnings.push(
|
||||||
[
|
[
|
||||||
"- Multiple state directories detected. This can split session history.",
|
"- Multiple state directories detected. This can split session history.",
|
||||||
...Array.from(extraStateDirs).map((dir) => ` - ${shortenHomePath(dir)}`),
|
...Array.from(extraStateDirs).map((dir) => ` - ${dir}`),
|
||||||
` Active state dir: ${displayStateDir}`,
|
` Active state dir: ${stateDir}`,
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -326,7 +311,7 @@ export async function noteStateIntegrity(
|
|||||||
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
|
const transcriptPath = resolveSessionFilePath(mainEntry.sessionId, mainEntry, { agentId });
|
||||||
if (!existsFile(transcriptPath)) {
|
if (!existsFile(transcriptPath)) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`- Main session transcript missing (${shortenHomePath(transcriptPath)}). History will appear to reset.`,
|
`- Main session transcript missing (${transcriptPath}). History will appear to reset.`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
const lineCount = countJsonlLines(transcriptPath);
|
const lineCount = countJsonlLines(transcriptPath);
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
DEFAULT_SOUL_FILENAME,
|
DEFAULT_SOUL_FILENAME,
|
||||||
DEFAULT_USER_FILENAME,
|
DEFAULT_USER_FILENAME,
|
||||||
} from "../agents/workspace.js";
|
} from "../agents/workspace.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
export const MEMORY_SYSTEM_PROMPT = [
|
export const MEMORY_SYSTEM_PROMPT = [
|
||||||
"Memory system not found in workspace.",
|
"Memory system not found in workspace.",
|
||||||
@@ -81,8 +80,8 @@ export function detectLegacyWorkspaceDirs(params: {
|
|||||||
export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string {
|
export function formatLegacyWorkspaceWarning(detection: LegacyWorkspaceDetection): string {
|
||||||
return [
|
return [
|
||||||
"Extra workspace directories detected (may contain old agent files):",
|
"Extra workspace directories detected (may contain old agent files):",
|
||||||
...detection.legacyDirs.map((dir) => `- ${shortenHomePath(dir)}`),
|
...detection.legacyDirs.map((dir) => `- ${dir}`),
|
||||||
`Active workspace: ${shortenHomePath(detection.activeWorkspace)}`,
|
`Active workspace: ${detection.activeWorkspace}`,
|
||||||
"If unused, archive or move to Trash (e.g. trash ~/clawdbot).",
|
"If unused, archive or move to Trash (e.g. trash ~/clawdbot).",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
}
|
}
|
||||||
+6
-10
@@ -12,16 +12,13 @@ import {
|
|||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import type { ClawdbotConfig } from "../config/config.js";
|
import type { ClawdbotConfig } from "../config/config.js";
|
||||||
import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, readConfigFileSnapshot, writeConfigFile } from "../config/config.js";
|
||||||
import { logConfigUpdated } from "../config/logging.js";
|
|
||||||
import { resolveGatewayService } from "../daemon/service.js";
|
import { resolveGatewayService } from "../daemon/service.js";
|
||||||
import { resolveGatewayAuth } from "../gateway/auth.js";
|
|
||||||
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
import { buildGatewayConnectionDetails } from "../gateway/call.js";
|
||||||
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
import { resolveClawdbotPackageRoot } from "../infra/clawdbot-root.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { note } from "../terminal/note.js";
|
import { note } from "../terminal/note.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
|
import { maybeRepairAnthropicOAuthProfileId, noteAuthProfileHealth } from "./doctor-auth.js";
|
||||||
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
|
||||||
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
|
import { maybeRepairGatewayDaemon } from "./doctor-gateway-daemon-flow.js";
|
||||||
@@ -114,11 +111,10 @@ export async function doctorCommand(
|
|||||||
note(gatewayDetails.remoteFallbackNote, "Gateway");
|
note(gatewayDetails.remoteFallbackNote, "Gateway");
|
||||||
}
|
}
|
||||||
if (resolveMode(cfg) === "local") {
|
if (resolveMode(cfg) === "local") {
|
||||||
const auth = resolveGatewayAuth({
|
const authMode = cfg.gateway?.auth?.mode;
|
||||||
authConfig: cfg.gateway?.auth,
|
const token =
|
||||||
tailscaleMode: cfg.gateway?.tailscale?.mode ?? "off",
|
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
|
||||||
});
|
const needsToken = authMode !== "password" && (authMode !== "token" || !token);
|
||||||
const needsToken = auth.mode !== "password" && (auth.mode !== "token" || !auth.token);
|
|
||||||
if (needsToken) {
|
if (needsToken) {
|
||||||
note(
|
note(
|
||||||
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
|
"Gateway auth is off or missing a token. Token auth is now the recommended default (including loopback).",
|
||||||
@@ -271,10 +267,10 @@ export async function doctorCommand(
|
|||||||
if (shouldWriteConfig) {
|
if (shouldWriteConfig) {
|
||||||
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
cfg = applyWizardMetadata(cfg, { command: "doctor", mode: resolveMode(cfg) });
|
||||||
await writeConfigFile(cfg);
|
await writeConfigFile(cfg);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`;
|
const backupPath = `${CONFIG_PATH_CLAWDBOT}.bak`;
|
||||||
if (fs.existsSync(backupPath)) {
|
if (fs.existsSync(backupPath)) {
|
||||||
runtime.log(`Backup: ${shortenHomePath(backupPath)}`);
|
runtime.log(`Backup: ${backupPath}`);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
runtime.log(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply changes.`);
|
runtime.log(`Run "${formatCliCommand("clawdbot doctor --fix")}" to apply changes.`);
|
||||||
|
|||||||
@@ -389,39 +389,4 @@ describe("doctor command", () => {
|
|||||||
);
|
);
|
||||||
expect(warned).toBe(true);
|
expect(warned).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips gateway auth warning when CLAWDBOT_GATEWAY_TOKEN is set", async () => {
|
|
||||||
readConfigFileSnapshot.mockResolvedValue({
|
|
||||||
path: "/tmp/clawdbot.json",
|
|
||||||
exists: true,
|
|
||||||
raw: "{}",
|
|
||||||
parsed: {},
|
|
||||||
valid: true,
|
|
||||||
config: {
|
|
||||||
gateway: { mode: "local" },
|
|
||||||
},
|
|
||||||
issues: [],
|
|
||||||
legacyIssues: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
const prevToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token-1234567890";
|
|
||||||
note.mockClear();
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { doctorCommand } = await import("./doctor.js");
|
|
||||||
await doctorCommand(
|
|
||||||
{ log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
|
||||||
{ nonInteractive: true, workspaceSuggestions: false },
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (prevToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
|
||||||
else process.env.CLAWDBOT_GATEWAY_TOKEN = prevToken;
|
|
||||||
}
|
|
||||||
|
|
||||||
const warned = note.mock.calls.some(([message]) =>
|
|
||||||
String(message).includes("Gateway auth is off or missing a token"),
|
|
||||||
);
|
|
||||||
expect(warned).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { loadConfig } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
ensureFlagCompatibility,
|
ensureFlagCompatibility,
|
||||||
@@ -75,7 +74,7 @@ export async function modelsAliasesAddCommand(
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
|
runtime.log(`Alias ${alias} -> ${resolved.provider}/${resolved.model}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +105,7 @@ export async function modelsAliasesRemoveCommand(aliasRaw: string, runtime: Runt
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
if (
|
if (
|
||||||
!updated.agents?.defaults?.models ||
|
!updated.agents?.defaults?.models ||
|
||||||
Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim())
|
Object.values(updated.agents.defaults.models).every((entry) => !entry?.alias?.trim())
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import {
|
|||||||
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js";
|
||||||
import { parseDurationMs } from "../../cli/parse-duration.js";
|
import { parseDurationMs } from "../../cli/parse-duration.js";
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js";
|
||||||
import { readConfigFileSnapshot, type ClawdbotConfig } from "../../config/config.js";
|
import {
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
CONFIG_PATH_CLAWDBOT,
|
||||||
|
readConfigFileSnapshot,
|
||||||
|
type ClawdbotConfig,
|
||||||
|
} from "../../config/config.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
|
import { stylePromptHint, stylePromptMessage } from "../../terminal/prompt-style.js";
|
||||||
import { applyAuthProfileConfig } from "../onboard-auth.js";
|
import { applyAuthProfileConfig } from "../onboard-auth.js";
|
||||||
@@ -114,7 +117,7 @@ export async function modelsAuthSetupTokenCommand(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
|
runtime.log(`Auth profile: ${CLAUDE_CLI_PROFILE_ID} (anthropic/oauth)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +159,7 @@ export async function modelsAuthPasteTokenCommand(
|
|||||||
|
|
||||||
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
|
await updateConfig((cfg) => applyAuthProfileConfig(cfg, { profileId, provider, mode: "token" }));
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
|
runtime.log(`Auth profile: ${profileId} (${provider}/token)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +425,7 @@ export async function modelsAuthLoginCommand(opts: LoginOptions, runtime: Runtim
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
for (const profile of result.profiles) {
|
for (const profile of result.profiles) {
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
|
`Auth profile: ${profile.profileId} (${profile.credential.provider}/${credentialMode(profile.credential)})`,
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
|
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROVIDER,
|
DEFAULT_PROVIDER,
|
||||||
@@ -79,7 +78,7 @@ export async function modelsFallbacksAddCommand(modelRaw: string, runtime: Runti
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
|
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +124,7 @@ export async function modelsFallbacksRemoveCommand(modelRaw: string, runtime: Ru
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
|
runtime.log(`Fallbacks: ${(updated.agents?.defaults?.model?.fallbacks ?? []).join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +148,6 @@ export async function modelsFallbacksClearCommand(runtime: RuntimeEnv) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log("Fallback list cleared.");
|
runtime.log("Fallback list cleared.");
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
|
import { buildModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PROVIDER,
|
DEFAULT_PROVIDER,
|
||||||
@@ -79,7 +78,7 @@ export async function modelsImageFallbacksAddCommand(modelRaw: string, runtime:
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
|
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
|
||||||
);
|
);
|
||||||
@@ -127,7 +126,7 @@ export async function modelsImageFallbacksRemoveCommand(modelRaw: string, runtim
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
|
`Image fallbacks: ${(updated.agents?.defaults?.imageModel?.fallbacks ?? []).join(", ")}`,
|
||||||
);
|
);
|
||||||
@@ -153,6 +152,6 @@ export async function modelsImageFallbacksClearCommand(runtime: RuntimeEnv) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log("Image fallback list cleared.");
|
runtime.log("Image fallback list cleared.");
|
||||||
}
|
}
|
||||||
@@ -250,7 +250,7 @@ export async function modelsStatusCommand(
|
|||||||
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
|
rawModel && rawModel !== resolvedLabel ? `${resolvedLabel} (from ${rawModel})` : resolvedLabel;
|
||||||
|
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, shortenHomePath(CONFIG_PATH_CLAWDBOT))}`,
|
`${label("Config")}${colorize(rich, theme.muted, ":")} ${colorize(rich, theme.info, CONFIG_PATH_CLAWDBOT)}`,
|
||||||
);
|
);
|
||||||
runtime.log(
|
runtime.log(
|
||||||
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
|
`${label("Agent dir")}${colorize(rich, theme.muted, ":")} ${colorize(
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import { cancel, multiselect as clackMultiselect, isCancel } from "@clack/prompt
|
|||||||
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
import { resolveApiKeyForProvider } from "../../agents/model-auth.js";
|
||||||
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
|
import { type ModelScanResult, scanOpenRouterModels } from "../../agents/model-scan.js";
|
||||||
import { withProgressTotals } from "../../cli/progress.js";
|
import { withProgressTotals } from "../../cli/progress.js";
|
||||||
import { loadConfig } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import {
|
import {
|
||||||
stylePromptHint,
|
stylePromptHint,
|
||||||
@@ -344,7 +343,7 @@ export async function modelsScanCommand(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Fallbacks: ${selected.join(", ")}`);
|
runtime.log(`Fallbacks: ${selected.join(", ")}`);
|
||||||
if (selectedImages.length > 0) {
|
if (selectedImages.length > 0) {
|
||||||
runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`);
|
runtime.log(`Image fallbacks: ${selectedImages.join(", ")}`);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logConfigUpdated } from "../../config/logging.js";
|
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { resolveModelTarget, updateConfig } from "./shared.js";
|
import { resolveModelTarget, updateConfig } from "./shared.js";
|
||||||
|
|
||||||
@@ -27,6 +27,6 @@ export async function modelsSetImageCommand(modelRaw: string, runtime: RuntimeEn
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`);
|
runtime.log(`Image model: ${updated.agents?.defaults?.imageModel?.primary ?? modelRaw}`);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { logConfigUpdated } from "../../config/logging.js";
|
import { CONFIG_PATH_CLAWDBOT } from "../../config/config.js";
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { resolveModelTarget, updateConfig } from "./shared.js";
|
import { resolveModelTarget, updateConfig } from "./shared.js";
|
||||||
|
|
||||||
@@ -27,6 +27,6 @@ export async function modelsSetCommand(modelRaw: string, runtime: RuntimeEnv) {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`);
|
runtime.log(`Default model: ${updated.agents?.defaults?.model?.primary ?? modelRaw}`);
|
||||||
}
|
}
|
||||||
@@ -18,13 +18,7 @@ import { runCommandWithTimeout } from "../process/exec.js";
|
|||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
import { stylePromptTitle } from "../terminal/prompt-style.js";
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import {
|
import { CONFIG_DIR, resolveUserPath, sleep } from "../utils.js";
|
||||||
CONFIG_DIR,
|
|
||||||
resolveUserPath,
|
|
||||||
shortenHomeInString,
|
|
||||||
shortenHomePath,
|
|
||||||
sleep,
|
|
||||||
} from "../utils.js";
|
|
||||||
import { VERSION } from "../version.js";
|
import { VERSION } from "../version.js";
|
||||||
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
|
import type { NodeManagerChoice, OnboardMode, ResetScope } from "./onboard-types.js";
|
||||||
|
|
||||||
@@ -39,21 +33,21 @@ export function guardCancel<T>(value: T | symbol, runtime: RuntimeEnv): T {
|
|||||||
export function summarizeExistingConfig(config: ClawdbotConfig): string {
|
export function summarizeExistingConfig(config: ClawdbotConfig): string {
|
||||||
const rows: string[] = [];
|
const rows: string[] = [];
|
||||||
const defaults = config.agents?.defaults;
|
const defaults = config.agents?.defaults;
|
||||||
if (defaults?.workspace) rows.push(shortenHomeInString(`workspace: ${defaults.workspace}`));
|
if (defaults?.workspace) rows.push(`workspace: ${defaults.workspace}`);
|
||||||
if (defaults?.model) {
|
if (defaults?.model) {
|
||||||
const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary;
|
const model = typeof defaults.model === "string" ? defaults.model : defaults.model.primary;
|
||||||
if (model) rows.push(shortenHomeInString(`model: ${model}`));
|
if (model) rows.push(`model: ${model}`);
|
||||||
}
|
}
|
||||||
if (config.gateway?.mode) rows.push(shortenHomeInString(`gateway.mode: ${config.gateway.mode}`));
|
if (config.gateway?.mode) rows.push(`gateway.mode: ${config.gateway.mode}`);
|
||||||
if (typeof config.gateway?.port === "number") {
|
if (typeof config.gateway?.port === "number") {
|
||||||
rows.push(shortenHomeInString(`gateway.port: ${config.gateway.port}`));
|
rows.push(`gateway.port: ${config.gateway.port}`);
|
||||||
}
|
}
|
||||||
if (config.gateway?.bind) rows.push(shortenHomeInString(`gateway.bind: ${config.gateway.bind}`));
|
if (config.gateway?.bind) rows.push(`gateway.bind: ${config.gateway.bind}`);
|
||||||
if (config.gateway?.remote?.url) {
|
if (config.gateway?.remote?.url) {
|
||||||
rows.push(shortenHomeInString(`gateway.remote.url: ${config.gateway.remote.url}`));
|
rows.push(`gateway.remote.url: ${config.gateway.remote.url}`);
|
||||||
}
|
}
|
||||||
if (config.skills?.install?.nodeManager) {
|
if (config.skills?.install?.nodeManager) {
|
||||||
rows.push(shortenHomeInString(`skills.nodeManager: ${config.skills.install.nodeManager}`));
|
rows.push(`skills.nodeManager: ${config.skills.install.nodeManager}`);
|
||||||
}
|
}
|
||||||
return rows.length ? rows.join("\n") : "No key settings detected.";
|
return rows.length ? rows.join("\n") : "No key settings detected.";
|
||||||
}
|
}
|
||||||
@@ -217,19 +211,6 @@ export async function openUrl(url: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openUrlInBackground(url: string): Promise<boolean> {
|
|
||||||
if (process.platform !== "darwin") return false;
|
|
||||||
const resolved = await resolveBrowserOpenCommand();
|
|
||||||
if (!resolved.argv || resolved.command !== "open") return false;
|
|
||||||
const command = ["open", "-g", url];
|
|
||||||
try {
|
|
||||||
await runCommandWithTimeout(command, { timeoutMs: 5_000 });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function ensureWorkspaceAndSessions(
|
export async function ensureWorkspaceAndSessions(
|
||||||
workspaceDir: string,
|
workspaceDir: string,
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
@@ -239,10 +220,10 @@ export async function ensureWorkspaceAndSessions(
|
|||||||
dir: workspaceDir,
|
dir: workspaceDir,
|
||||||
ensureBootstrapFiles: !options?.skipBootstrap,
|
ensureBootstrapFiles: !options?.skipBootstrap,
|
||||||
});
|
});
|
||||||
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
|
runtime.log(`Workspace OK: ${ws.dir}`);
|
||||||
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
|
const sessionsDir = resolveSessionTranscriptsDirForAgent(options?.agentId);
|
||||||
await fs.mkdir(sessionsDir, { recursive: true });
|
await fs.mkdir(sessionsDir, { recursive: true });
|
||||||
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
|
runtime.log(`Sessions OK: ${sessionsDir}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveNodeManagerOptions(): Array<{
|
export function resolveNodeManagerOptions(): Array<{
|
||||||
@@ -265,9 +246,9 @@ export async function moveToTrash(pathname: string, runtime: RuntimeEnv): Promis
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
|
await runCommandWithTimeout(["trash", pathname], { timeoutMs: 5000 });
|
||||||
runtime.log(`Moved to Trash: ${shortenHomePath(pathname)}`);
|
runtime.log(`Moved to Trash: ${pathname}`);
|
||||||
} catch {
|
} catch {
|
||||||
runtime.log(`Failed to move to Trash (manual delete): ${shortenHomePath(pathname)}`);
|
runtime.log(`Failed to move to Trash (manual delete): ${pathname}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
import {
|
||||||
|
loadOrCreateDeviceIdentity,
|
||||||
|
publicKeyRawBase64UrlFromPem,
|
||||||
|
signDevicePayload,
|
||||||
|
} from "../infra/device-identity.js";
|
||||||
|
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
||||||
|
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||||
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const srv = createServer();
|
||||||
|
srv.on("error", reject);
|
||||||
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
srv.close();
|
||||||
|
reject(new Error("failed to acquire free port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = addr.port;
|
||||||
|
srv.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onceMessage<T = unknown>(
|
||||||
|
ws: WebSocket,
|
||||||
|
filter: (obj: unknown) => boolean,
|
||||||
|
timeoutMs = 5000,
|
||||||
|
): Promise<T> {
|
||||||
|
return await new Promise<T>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => reject(new Error("timeout")), timeoutMs);
|
||||||
|
const closeHandler = (code: number, reason: Buffer) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.off("message", handler);
|
||||||
|
reject(new Error(`closed ${code}: ${rawDataToString(reason)}`));
|
||||||
|
};
|
||||||
|
const handler = (data: WebSocket.RawData) => {
|
||||||
|
const obj = JSON.parse(rawDataToString(data));
|
||||||
|
if (!filter(obj)) return;
|
||||||
|
clearTimeout(timer);
|
||||||
|
ws.off("message", handler);
|
||||||
|
ws.off("close", closeHandler);
|
||||||
|
resolve(obj as T);
|
||||||
|
};
|
||||||
|
ws.on("message", handler);
|
||||||
|
ws.once("close", closeHandler);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connectReq(params: { url: string; token?: string }) {
|
||||||
|
const ws = new WebSocket(params.url);
|
||||||
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
const identity = loadOrCreateDeviceIdentity();
|
||||||
|
const signedAtMs = Date.now();
|
||||||
|
const payload = buildDeviceAuthPayload({
|
||||||
|
deviceId: identity.deviceId,
|
||||||
|
clientId: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
clientMode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
role: "operator",
|
||||||
|
scopes: [],
|
||||||
|
signedAtMs,
|
||||||
|
token: params.token ?? null,
|
||||||
|
});
|
||||||
|
const device = {
|
||||||
|
id: identity.deviceId,
|
||||||
|
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
|
||||||
|
signature: signDevicePayload(identity.privateKeyPem, payload),
|
||||||
|
signedAt: signedAtMs,
|
||||||
|
};
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: "c1",
|
||||||
|
method: "connect",
|
||||||
|
params: {
|
||||||
|
minProtocol: PROTOCOL_VERSION,
|
||||||
|
maxProtocol: PROTOCOL_VERSION,
|
||||||
|
client: {
|
||||||
|
id: GATEWAY_CLIENT_NAMES.TEST,
|
||||||
|
displayName: "vitest",
|
||||||
|
version: "dev",
|
||||||
|
platform: process.platform,
|
||||||
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
|
},
|
||||||
|
caps: [],
|
||||||
|
auth: params.token ? { token: params.token } : undefined,
|
||||||
|
device,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const res = await onceMessage<{
|
||||||
|
type: "res";
|
||||||
|
id: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: { message?: string };
|
||||||
|
}>(ws, (o) => {
|
||||||
|
const obj = o as { type?: unknown; id?: unknown } | undefined;
|
||||||
|
return obj?.type === "res" && obj?.id === "c1";
|
||||||
|
});
|
||||||
|
ws.close();
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("onboard (non-interactive): gateway auth", () => {
|
||||||
|
it("writes gateway token auth into config and gateway enforces it", async () => {
|
||||||
|
const prev = {
|
||||||
|
home: process.env.HOME,
|
||||||
|
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
||||||
|
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||||
|
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
||||||
|
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||||
|
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||||
|
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||||
|
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
|
||||||
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-noninteractive-"));
|
||||||
|
process.env.HOME = tempHome;
|
||||||
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
const token = "tok_test_123";
|
||||||
|
const workspace = path.join(tempHome, "clawd");
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: () => {},
|
||||||
|
error: (msg: string) => {
|
||||||
|
throw new Error(msg);
|
||||||
|
},
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||||
|
await runNonInteractiveOnboarding(
|
||||||
|
{
|
||||||
|
nonInteractive: true,
|
||||||
|
mode: "local",
|
||||||
|
workspace,
|
||||||
|
authChoice: "skip",
|
||||||
|
skipSkills: true,
|
||||||
|
skipHealth: true,
|
||||||
|
installDaemon: false,
|
||||||
|
gatewayBind: "loopback",
|
||||||
|
gatewayAuth: "token",
|
||||||
|
gatewayToken: token,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { CONFIG_PATH_CLAWDBOT } = await import("../config/config.js");
|
||||||
|
const cfg = JSON.parse(await fs.readFile(CONFIG_PATH_CLAWDBOT, "utf8")) as {
|
||||||
|
gateway?: { auth?: { mode?: string; token?: string } };
|
||||||
|
agents?: { defaults?: { workspace?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cfg?.agents?.defaults?.workspace).toBe(workspace);
|
||||||
|
expect(cfg?.gateway?.auth?.mode).toBe("token");
|
||||||
|
expect(cfg?.gateway?.auth?.token).toBe(token);
|
||||||
|
|
||||||
|
const { startGatewayServer } = await import("../gateway/server.js");
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startGatewayServer(port, {
|
||||||
|
bind: "loopback",
|
||||||
|
controlUiEnabled: false,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const resNoToken = await connectReq({ url: `ws://127.0.0.1:${port}` });
|
||||||
|
expect(resNoToken.ok).toBe(false);
|
||||||
|
expect(resNoToken.error?.message ?? "").toContain("unauthorized");
|
||||||
|
|
||||||
|
const resToken = await connectReq({
|
||||||
|
url: `ws://127.0.0.1:${port}`,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
expect(resToken.ok).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "non-interactive onboard auth test" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.rm(tempHome, { recursive: true, force: true });
|
||||||
|
process.env.HOME = prev.home;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
||||||
|
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
+47
-171
@@ -3,7 +3,7 @@ import { createServer } from "node:net";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -13,32 +13,33 @@ import {
|
|||||||
} from "../infra/device-identity.js";
|
} from "../infra/device-identity.js";
|
||||||
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
import { buildDeviceAuthPayload } from "../gateway/device-auth.js";
|
||||||
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
import { PROTOCOL_VERSION } from "../gateway/protocol/index.js";
|
||||||
|
import { getFreePort as getFreeTestPort } from "../gateway/test-helpers.js";
|
||||||
import { rawDataToString } from "../infra/ws.js";
|
import { rawDataToString } from "../infra/ws.js";
|
||||||
import { getDeterministicFreePortBlock } from "../test-utils/ports.js";
|
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
async function getFreePort(): Promise<number> {
|
async function isPortFree(port: number): Promise<boolean> {
|
||||||
return await new Promise((resolve, reject) => {
|
if (!Number.isFinite(port) || port <= 0 || port > 65535) return false;
|
||||||
|
return await new Promise((resolve) => {
|
||||||
const srv = createServer();
|
const srv = createServer();
|
||||||
srv.on("error", reject);
|
srv.once("error", () => resolve(false));
|
||||||
srv.listen(0, "127.0.0.1", () => {
|
srv.listen(port, "127.0.0.1", () => {
|
||||||
const addr = srv.address();
|
srv.close(() => resolve(true));
|
||||||
if (!addr || typeof addr === "string") {
|
|
||||||
srv.close();
|
|
||||||
reject(new Error("failed to acquire free port"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const port = addr.port;
|
|
||||||
srv.close((err) => {
|
|
||||||
if (err) reject(err);
|
|
||||||
else resolve(port);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFreeGatewayPort(): Promise<number> {
|
async function getFreeGatewayPort(): Promise<number> {
|
||||||
return await getDeterministicFreePortBlock({ offsets: [0, 1, 2, 4] });
|
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
|
||||||
|
// ensuring the common derived offsets are free too.
|
||||||
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||||
|
const port = await getFreeTestPort();
|
||||||
|
const candidates = [port, port + 1, port + 2, port + 4];
|
||||||
|
const ok = (await Promise.all(candidates.map((candidate) => isPortFree(candidate)))).every(
|
||||||
|
Boolean,
|
||||||
|
);
|
||||||
|
if (ok) return port;
|
||||||
|
}
|
||||||
|
throw new Error("failed to acquire a free gateway port block");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function onceMessage<T = unknown>(
|
async function onceMessage<T = unknown>(
|
||||||
@@ -120,17 +121,12 @@ async function connectReq(params: { url: string; token?: string }) {
|
|||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
const runtime = {
|
describe("onboard (non-interactive): lan bind auto-token", () => {
|
||||||
log: () => {},
|
it("auto-enables token auth when binding LAN and persists the token", async () => {
|
||||||
error: (msg: string) => {
|
if (process.platform === "win32") {
|
||||||
throw new Error(msg);
|
// Windows runner occasionally drops the temp config write in this flow; skip to keep CI green.
|
||||||
},
|
return;
|
||||||
exit: (code: number) => {
|
}
|
||||||
throw new Error(`exit:${code}`);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("onboard (non-interactive): gateway and remote auth", () => {
|
|
||||||
const prev = {
|
const prev = {
|
||||||
home: process.env.HOME,
|
home: process.env.HOME,
|
||||||
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
||||||
@@ -139,161 +135,33 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
|||||||
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||||
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||||
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||||
skipBrowser: process.env.CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER,
|
|
||||||
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
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_CHANNELS = "1";
|
||||||
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||||
process.env.CLAWDBOT_SKIP_CRON = "1";
|
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||||
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "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_TOKEN;
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
|
||||||
|
|
||||||
tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-"));
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-lan-"));
|
||||||
process.env.HOME = tempHome;
|
process.env.HOME = tempHome;
|
||||||
});
|
const stateDir = path.join(tempHome, ".clawdbot");
|
||||||
|
|
||||||
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 stateDir = await initStateDir("state-lan-");
|
|
||||||
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
process.env.CLAWDBOT_STATE_DIR = stateDir;
|
||||||
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
|
process.env.CLAWDBOT_CONFIG_PATH = path.join(stateDir, "clawdbot.json");
|
||||||
|
|
||||||
const port = await getFreeGatewayPort();
|
const port = await getFreeGatewayPort();
|
||||||
const workspace = path.join(stateDir, "clawd");
|
const workspace = path.join(tempHome, "clawd");
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: () => {},
|
||||||
|
error: (msg: string) => {
|
||||||
|
throw new Error(msg);
|
||||||
|
},
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Other test files mock ../config/config.js. This onboarding flow needs the real
|
// Other test files mock ../config/config.js. This onboarding flow needs the real
|
||||||
// implementation so it can persist the config and then read it back (Windows CI
|
// implementation so it can persist the config and then read it back (Windows CI
|
||||||
@@ -358,6 +226,14 @@ describe("onboard (non-interactive): gateway and remote auth", () => {
|
|||||||
await server.close({ reason: "lan auto-token test complete" });
|
await server.close({ reason: "lan auto-token test complete" });
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.rm(stateDir, { recursive: true, force: true });
|
await fs.rm(tempHome, { recursive: true, force: true });
|
||||||
|
process.env.HOME = prev.home;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
||||||
|
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import fs from "node:fs/promises";
|
||||||
|
import { createServer } from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
async function getFreePort(): Promise<number> {
|
||||||
|
return await new Promise((resolve, reject) => {
|
||||||
|
const srv = createServer();
|
||||||
|
srv.on("error", reject);
|
||||||
|
srv.listen(0, "127.0.0.1", () => {
|
||||||
|
const addr = srv.address();
|
||||||
|
if (!addr || typeof addr === "string") {
|
||||||
|
srv.close();
|
||||||
|
reject(new Error("failed to acquire free port"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const port = addr.port;
|
||||||
|
srv.close((err) => {
|
||||||
|
if (err) reject(err);
|
||||||
|
else resolve(port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("onboard (non-interactive): remote gateway config", () => {
|
||||||
|
it("writes gateway.remote url/token and callGateway uses them", async () => {
|
||||||
|
const prev = {
|
||||||
|
home: process.env.HOME,
|
||||||
|
stateDir: process.env.CLAWDBOT_STATE_DIR,
|
||||||
|
configPath: process.env.CLAWDBOT_CONFIG_PATH,
|
||||||
|
skipChannels: process.env.CLAWDBOT_SKIP_CHANNELS,
|
||||||
|
skipGmail: process.env.CLAWDBOT_SKIP_GMAIL_WATCHER,
|
||||||
|
skipCron: process.env.CLAWDBOT_SKIP_CRON,
|
||||||
|
skipCanvas: process.env.CLAWDBOT_SKIP_CANVAS_HOST,
|
||||||
|
token: process.env.CLAWDBOT_GATEWAY_TOKEN,
|
||||||
|
password: process.env.CLAWDBOT_GATEWAY_PASSWORD,
|
||||||
|
};
|
||||||
|
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = "1";
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = "1";
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||||
|
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||||
|
|
||||||
|
const tempHome = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-onboard-remote-"));
|
||||||
|
process.env.HOME = tempHome;
|
||||||
|
delete process.env.CLAWDBOT_STATE_DIR;
|
||||||
|
delete process.env.CLAWDBOT_CONFIG_PATH;
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const token = "tok_remote_123";
|
||||||
|
const { startGatewayServer } = await import("../gateway/server.js");
|
||||||
|
const server = await startGatewayServer(port, {
|
||||||
|
bind: "loopback",
|
||||||
|
auth: { mode: "token", token },
|
||||||
|
controlUiEnabled: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const runtime = {
|
||||||
|
log: () => {},
|
||||||
|
error: (msg: string) => {
|
||||||
|
throw new Error(msg);
|
||||||
|
},
|
||||||
|
exit: (code: number) => {
|
||||||
|
throw new Error(`exit:${code}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { runNonInteractiveOnboarding } = await import("./onboard-non-interactive.js");
|
||||||
|
await runNonInteractiveOnboarding(
|
||||||
|
{
|
||||||
|
nonInteractive: true,
|
||||||
|
mode: "remote",
|
||||||
|
remoteUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
remoteToken: token,
|
||||||
|
authChoice: "skip",
|
||||||
|
json: true,
|
||||||
|
},
|
||||||
|
runtime,
|
||||||
|
);
|
||||||
|
|
||||||
|
const { resolveConfigPath } = await import("../config/config.js");
|
||||||
|
const cfg = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")) as {
|
||||||
|
gateway?: { mode?: string; remote?: { url?: string; token?: string } };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(cfg.gateway?.mode).toBe("remote");
|
||||||
|
expect(cfg.gateway?.remote?.url).toBe(`ws://127.0.0.1:${port}`);
|
||||||
|
expect(cfg.gateway?.remote?.token).toBe(token);
|
||||||
|
|
||||||
|
const { callGateway } = await import("../gateway/call.js");
|
||||||
|
const health = await callGateway<{ ok?: boolean }>({ method: "health" });
|
||||||
|
expect(health?.ok).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "non-interactive remote test complete" });
|
||||||
|
await fs.rm(tempHome, { recursive: true, force: true });
|
||||||
|
process.env.HOME = prev.home;
|
||||||
|
process.env.CLAWDBOT_STATE_DIR = prev.stateDir;
|
||||||
|
process.env.CLAWDBOT_CONFIG_PATH = prev.configPath;
|
||||||
|
process.env.CLAWDBOT_SKIP_CHANNELS = prev.skipChannels;
|
||||||
|
process.env.CLAWDBOT_SKIP_GMAIL_WATCHER = prev.skipGmail;
|
||||||
|
process.env.CLAWDBOT_SKIP_CRON = prev.skipCron;
|
||||||
|
process.env.CLAWDBOT_SKIP_CANVAS_HOST = prev.skipCanvas;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_TOKEN = prev.token;
|
||||||
|
process.env.CLAWDBOT_GATEWAY_PASSWORD = prev.password;
|
||||||
|
}
|
||||||
|
}, 60_000);
|
||||||
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { resolveGatewayPort, writeConfigFile } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, resolveGatewayPort, writeConfigFile } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js";
|
||||||
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js";
|
import { DEFAULT_GATEWAY_DAEMON_RUNTIME } from "../daemon-runtime.js";
|
||||||
@@ -75,7 +74,7 @@ export async function runNonInteractiveOnboardingLocal(params: {
|
|||||||
|
|
||||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
|
||||||
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
await ensureWorkspaceAndSessions(workspaceDir, runtime, {
|
||||||
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
skipBootstrap: Boolean(nextConfig.agents?.defaults?.skipBootstrap),
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
import type { AuthChoice, OnboardOptions } from "../../onboard-types.js";
|
||||||
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
|
import { applyOpenAICodexModelDefault } from "../../openai-codex-model-default.js";
|
||||||
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
import { resolveNonInteractiveApiKey } from "../api-keys.js";
|
||||||
import { shortenHomePath } from "../../../utils.js";
|
|
||||||
|
|
||||||
export async function applyNonInteractiveAuthChoice(params: {
|
export async function applyNonInteractiveAuthChoice(params: {
|
||||||
nextConfig: ClawdbotConfig;
|
nextConfig: ClawdbotConfig;
|
||||||
@@ -173,7 +172,7 @@ export async function applyNonInteractiveAuthChoice(params: {
|
|||||||
const key = resolved.key;
|
const key = resolved.key;
|
||||||
const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: key });
|
const result = upsertSharedEnvVar({ key: "OPENAI_API_KEY", value: key });
|
||||||
process.env.OPENAI_API_KEY = key;
|
process.env.OPENAI_API_KEY = key;
|
||||||
runtime.log(`Saved OPENAI_API_KEY to ${shortenHomePath(result.path)}`);
|
runtime.log(`Saved OPENAI_API_KEY to ${result.path}`);
|
||||||
return nextConfig;
|
return nextConfig;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
import { writeConfigFile } from "../../config/config.js";
|
import { CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../../config/config.js";
|
||||||
import { logConfigUpdated } from "../../config/logging.js";
|
|
||||||
import type { RuntimeEnv } from "../../runtime.js";
|
import type { RuntimeEnv } from "../../runtime.js";
|
||||||
import { formatCliCommand } from "../../cli/command-format.js";
|
import { formatCliCommand } from "../../cli/command-format.js";
|
||||||
import { applyWizardMetadata } from "../onboard-helpers.js";
|
import { applyWizardMetadata } from "../onboard-helpers.js";
|
||||||
@@ -34,7 +33,7 @@ export async function runNonInteractiveOnboardingRemote(params: {
|
|||||||
};
|
};
|
||||||
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
nextConfig = applyWizardMetadata(nextConfig, { command: "onboard", mode });
|
||||||
await writeConfigFile(nextConfig);
|
await writeConfigFile(nextConfig);
|
||||||
logConfigUpdated(runtime);
|
runtime.log(`Updated ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
mode,
|
mode,
|
||||||
|
|||||||
+8
-10
@@ -4,11 +4,9 @@ import JSON5 from "json5";
|
|||||||
|
|
||||||
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
import { DEFAULT_AGENT_WORKSPACE_DIR, ensureAgentWorkspace } from "../agents/workspace.js";
|
||||||
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
import { type ClawdbotConfig, CONFIG_PATH_CLAWDBOT, writeConfigFile } from "../config/config.js";
|
||||||
import { formatConfigPath, logConfigUpdated } from "../config/logging.js";
|
|
||||||
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
import { resolveSessionTranscriptsDir } from "../config/sessions.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
|
||||||
|
|
||||||
async function readConfigFileRaw(): Promise<{
|
async function readConfigFileRaw(): Promise<{
|
||||||
exists: boolean;
|
exists: boolean;
|
||||||
@@ -54,22 +52,22 @@ export async function setupCommand(
|
|||||||
|
|
||||||
if (!existingRaw.exists || defaults.workspace !== workspace) {
|
if (!existingRaw.exists || defaults.workspace !== workspace) {
|
||||||
await writeConfigFile(next);
|
await writeConfigFile(next);
|
||||||
if (!existingRaw.exists) {
|
runtime.log(
|
||||||
runtime.log(`Wrote ${formatConfigPath()}`);
|
!existingRaw.exists
|
||||||
|
? `Wrote ${CONFIG_PATH_CLAWDBOT}`
|
||||||
|
: `Updated ${CONFIG_PATH_CLAWDBOT} (set agents.defaults.workspace)`,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
logConfigUpdated(runtime, { suffix: "(set agents.defaults.workspace)" });
|
runtime.log(`Config OK: ${CONFIG_PATH_CLAWDBOT}`);
|
||||||
}
|
|
||||||
} else {
|
|
||||||
runtime.log(`Config OK: ${formatConfigPath()}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const ws = await ensureAgentWorkspace({
|
const ws = await ensureAgentWorkspace({
|
||||||
dir: workspace,
|
dir: workspace,
|
||||||
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
|
ensureBootstrapFiles: !next.agents?.defaults?.skipBootstrap,
|
||||||
});
|
});
|
||||||
runtime.log(`Workspace OK: ${shortenHomePath(ws.dir)}`);
|
runtime.log(`Workspace OK: ${ws.dir}`);
|
||||||
|
|
||||||
const sessionsDir = resolveSessionTranscriptsDir();
|
const sessionsDir = resolveSessionTranscriptsDir();
|
||||||
await fs.mkdir(sessionsDir, { recursive: true });
|
await fs.mkdir(sessionsDir, { recursive: true });
|
||||||
runtime.log(`Sessions OK: ${shortenHomePath(sessionsDir)}`);
|
runtime.log(`Sessions OK: ${sessionsDir}`);
|
||||||
}
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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}`);
|
|
||||||
}
|
|
||||||
@@ -1,275 +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 {
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
@@ -49,8 +49,7 @@ function parseSseDataLines(text: string): string[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
describe("OpenAI-compatible HTTP API (e2e)", () => {
|
||||||
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
|
||||||
{
|
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServerWithDefaultConfig(port);
|
const server = await startServerWithDefaultConfig(port);
|
||||||
try {
|
try {
|
||||||
@@ -62,9 +61,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
{
|
it("can be disabled via config (404)", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port, {
|
const server = await startServer(port, {
|
||||||
openAiChatCompletionsEnabled: false,
|
openAiChatCompletionsEnabled: false,
|
||||||
@@ -78,39 +77,45 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles request validation and routing", async () => {
|
it("rejects non-POST", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
const mockAgentOnce = (payloads: Array<{ text: string }>) => {
|
|
||||||
agentCommand.mockReset();
|
|
||||||
agentCommand.mockResolvedValueOnce({ payloads } as never);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
{
|
|
||||||
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: "Bearer secret" },
|
headers: { authorization: "Bearer secret" },
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(405);
|
expect(res.status).toBe(405);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
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`, {
|
const res = await fetch(`http://127.0.0.1:${port}/v1/chat/completions`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
body: JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("routes to a specific agent via header", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(
|
const res = await postChatCompletions(
|
||||||
port,
|
port,
|
||||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||||
@@ -123,11 +128,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("routes to a specific agent via model (no custom headers)", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot:beta",
|
model: "clawdbot:beta",
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
@@ -139,11 +152,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("prefers explicit header agent over model agent", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(
|
const res = await postChatCompletions(
|
||||||
port,
|
port,
|
||||||
{
|
{
|
||||||
@@ -159,11 +180,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
/^agent:alpha:/,
|
/^agent:alpha:/,
|
||||||
);
|
);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("honors x-clawdbot-session-key override", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(
|
const res = await postChatCompletions(
|
||||||
port,
|
port,
|
||||||
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
{ model: "clawdbot", messages: [{ role: "user", content: "hi" }] },
|
||||||
@@ -178,11 +207,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey).toBe(
|
||||||
"agent:beta:openai:custom",
|
"agent:beta:openai:custom",
|
||||||
);
|
);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("uses OpenAI user for a stable session key", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
user: "alice",
|
user: "alice",
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
@@ -194,11 +231,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
||||||
"openai-user:alice",
|
"openai-user:alice",
|
||||||
);
|
);
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("extracts user message text from array content", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -215,11 +260,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
|
|
||||||
const [opts] = agentCommand.mock.calls[0] ?? [];
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
|
expect((opts as { message?: string } | undefined)?.message).toBe("hello\nworld");
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("includes conversation history when multiple messages are provided", async () => {
|
||||||
mockAgentOnce([{ text: "I am Claude" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "I am Claude" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -238,11 +291,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect(message).toContain("Assistant: I am Claude.");
|
expect(message).toContain("Assistant: I am Claude.");
|
||||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
expect(message).toContain("User: What did I just ask you?");
|
expect(message).toContain("User: What did I just ask you?");
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("does not include history markers for single message", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -257,11 +318,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
expect(message).not.toContain(HISTORY_CONTEXT_MARKER);
|
||||||
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
expect(message).not.toContain(CURRENT_MESSAGE_MARKER);
|
||||||
expect(message).toBe("Hello");
|
expect(message).toBe("Hello");
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("treats developer role same as system role", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -275,11 +344,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
const extraSystemPrompt =
|
const extraSystemPrompt =
|
||||||
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||||
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
expect(extraSystemPrompt).toBe("You are a helpful assistant.");
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("includes tool output when it is the latest message", async () => {
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [
|
messages: [
|
||||||
@@ -298,11 +375,19 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
expect(message).toContain("Assistant: Checking the weather.");
|
expect(message).toContain("Assistant: Checking the weather.");
|
||||||
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
expect(message).toContain(CURRENT_MESSAGE_MARKER);
|
||||||
expect(message).toContain("Tool: Sunny, 70F.");
|
expect(message).toContain("Tool: Sunny, 70F.");
|
||||||
await res.text();
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("returns a non-streaming OpenAI chat.completion response", async () => {
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
stream: false,
|
stream: false,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
@@ -316,30 +401,30 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
const msg = (choice0.message as Record<string, unknown> | undefined) ?? {};
|
||||||
expect(msg.role).toBe("assistant");
|
expect(msg.role).toBe("assistant");
|
||||||
expect(msg.content).toBe("hello");
|
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 {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams SSE chunks when stream=true", async () => {
|
it("requires a user message", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
try {
|
try {
|
||||||
{
|
const res = await postChatCompletions(port, {
|
||||||
agentCommand.mockReset();
|
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) => {
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
||||||
@@ -347,6 +432,9 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
return { payloads: [{ text: "hello" }] } as never;
|
return { payloads: [{ text: "hello" }] } as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
const res = await postChatCompletions(port, {
|
const res = await postChatCompletions(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
@@ -369,10 +457,12 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
.filter((v): v is string => typeof v === "string")
|
.filter((v): v is string => typeof v === "string")
|
||||||
.join("");
|
.join("");
|
||||||
expect(allContent).toBe("hello");
|
expect(allContent).toBe("hello");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("preserves repeated identical deltas when streaming SSE", async () => {
|
||||||
agentCommand.mockReset();
|
|
||||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "hi" } });
|
||||||
@@ -380,41 +470,48 @@ describe("OpenAI-compatible HTTP API (e2e)", () => {
|
|||||||
return { payloads: [{ text: "hihi" }] } as never;
|
return { payloads: [{ text: "hihi" }] } as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
const repeatedRes = await postChatCompletions(port, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postChatCompletions(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
});
|
});
|
||||||
expect(repeatedRes.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const repeatedText = await repeatedRes.text();
|
const text = await res.text();
|
||||||
const repeatedData = parseSseDataLines(repeatedText);
|
const data = parseSseDataLines(text);
|
||||||
const repeatedChunks = repeatedData
|
const jsonChunks = data
|
||||||
.filter((d) => d !== "[DONE]")
|
.filter((d) => d !== "[DONE]")
|
||||||
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
.map((d) => JSON.parse(d) as Record<string, unknown>);
|
||||||
const repeatedContent = repeatedChunks
|
const allContent = jsonChunks
|
||||||
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
.flatMap((c) => (c.choices as Array<Record<string, unknown>> | undefined) ?? [])
|
||||||
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
.map((choice) => (choice.delta as Record<string, unknown> | undefined)?.content)
|
||||||
.filter((v): v is string => typeof v === "string")
|
.filter((v): v is string => typeof v === "string")
|
||||||
.join("");
|
.join("");
|
||||||
expect(repeatedContent).toBe("hihi");
|
expect(allContent).toBe("hihi");
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
{
|
it("streams SSE chunks when stream=true (fallback when no deltas)", async () => {
|
||||||
agentCommand.mockReset();
|
|
||||||
agentCommand.mockResolvedValueOnce({
|
agentCommand.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const fallbackRes = await postChatCompletions(port, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postChatCompletions(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
messages: [{ role: "user", content: "hi" }],
|
messages: [{ role: "user", content: "hi" }],
|
||||||
});
|
});
|
||||||
expect(fallbackRes.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const fallbackText = await fallbackRes.text();
|
const text = await res.text();
|
||||||
expect(fallbackText).toContain("[DONE]");
|
expect(text).toContain("[DONE]");
|
||||||
expect(fallbackText).toContain("hello");
|
expect(text).toContain("hello");
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ async function ensureResponseConsumed(res: Response) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("OpenResponses HTTP API (e2e)", () => {
|
describe("OpenResponses HTTP API (e2e)", () => {
|
||||||
it("rejects when disabled (default + config)", { timeout: 120_000 }, async () => {
|
it("is disabled by default (requires config)", { timeout: 120_000 }, async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServerWithDefaultConfig(port);
|
const server = await startServerWithDefaultConfig(port);
|
||||||
try {
|
try {
|
||||||
@@ -83,112 +83,201 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const disabledPort = await getFreePort();
|
it("can be disabled via config (404)", async () => {
|
||||||
const disabledServer = await startServer(disabledPort, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port, {
|
||||||
openResponsesEnabled: false,
|
openResponsesEnabled: false,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
const res = await postResponses(disabledPort, {
|
const res = await postResponses(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(res.status).toBe(404);
|
expect(res.status).toBe(404);
|
||||||
await ensureResponseConsumed(res);
|
await ensureResponseConsumed(res);
|
||||||
} finally {
|
} finally {
|
||||||
await disabledServer.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("handles OpenResponses request parsing and validation", async () => {
|
it("rejects non-POST", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
const mockAgentOnce = (payloads: Array<{ text: string }>, meta?: unknown) => {
|
|
||||||
agentCommand.mockReset();
|
|
||||||
agentCommand.mockResolvedValueOnce({ payloads, meta } as never);
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const resNonPost = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
const res = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: { authorization: "Bearer secret" },
|
headers: { authorization: "Bearer secret" },
|
||||||
});
|
});
|
||||||
expect(resNonPost.status).toBe(405);
|
expect(res.status).toBe(405);
|
||||||
await ensureResponseConsumed(resNonPost);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const resMissingAuth = await fetch(`http://127.0.0.1:${port}/v1/responses`, {
|
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`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
|
body: JSON.stringify({ model: "clawdbot", input: "hi" }),
|
||||||
});
|
});
|
||||||
expect(resMissingAuth.status).toBe(401);
|
expect(res.status).toBe(401);
|
||||||
await ensureResponseConsumed(resMissingAuth);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const resMissingModel = await postResponses(port, { input: "hi" });
|
it("rejects invalid request body (missing model)", async () => {
|
||||||
expect(resMissingModel.status).toBe(400);
|
const port = await getFreePort();
|
||||||
const missingModelJson = (await resMissingModel.json()) as Record<string, unknown>;
|
const server = await startServer(port);
|
||||||
expect((missingModelJson.error as Record<string, unknown> | undefined)?.type).toBe(
|
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(
|
||||||
"invalid_request_error",
|
"invalid_request_error",
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(resMissingModel);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("routes to a specific agent via header", async () => {
|
||||||
const resHeader = await postResponses(
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(
|
||||||
port,
|
port,
|
||||||
{ model: "clawdbot", input: "hi" },
|
{ model: "clawdbot", input: "hi" },
|
||||||
{ "x-clawdbot-agent-id": "beta" },
|
{ "x-clawdbot-agent-id": "beta" },
|
||||||
);
|
);
|
||||||
expect(resHeader.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsHeader] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect((optsHeader as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
expect(agentCommand).toHaveBeenCalledTimes(1);
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(resHeader);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("routes to a specific agent via model (no custom headers)", async () => {
|
||||||
const resModel = await postResponses(port, { model: "clawdbot:beta", input: "hi" });
|
agentCommand.mockResolvedValueOnce({
|
||||||
expect(resModel.status).toBe(200);
|
payloads: [{ text: "hello" }],
|
||||||
const [optsModel] = agentCommand.mock.calls[0] ?? [];
|
} as never);
|
||||||
expect((optsModel as { sessionKey?: string } | undefined)?.sessionKey ?? "").toMatch(
|
|
||||||
|
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(
|
||||||
/^agent:beta:/,
|
/^agent:beta:/,
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(resModel);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("uses OpenResponses user for a stable session key", async () => {
|
||||||
const resUser = await postResponses(port, {
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
user: "alice",
|
user: "alice",
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resUser.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsUser] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect((optsUser as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect((opts as { sessionKey?: string } | undefined)?.sessionKey ?? "").toContain(
|
||||||
"openresponses-user:alice",
|
"openresponses-user:alice",
|
||||||
);
|
);
|
||||||
await ensureResponseConsumed(resUser);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("accepts string input", async () => {
|
||||||
const resString = await postResponses(port, {
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hello world",
|
input: "hello world",
|
||||||
});
|
});
|
||||||
expect(resString.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsString] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect((optsString as { message?: string } | undefined)?.message).toBe("hello world");
|
|
||||||
await ensureResponseConsumed(resString);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resArray = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [{ type: "message", role: "user", content: "hello there" }],
|
input: [{ type: "message", role: "user", content: "hello there" }],
|
||||||
});
|
});
|
||||||
expect(resArray.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsArray] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect((optsArray as { message?: string } | undefined)?.message).toBe("hello there");
|
|
||||||
await ensureResponseConsumed(resArray);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resSystemDeveloper = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
||||||
@@ -196,30 +285,53 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
{ type: "message", role: "user", content: "Hello" },
|
{ type: "message", role: "user", content: "Hello" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(resSystemDeveloper.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsSystemDeveloper] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const extraSystemPrompt =
|
const extraSystemPrompt =
|
||||||
(optsSystemDeveloper as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ??
|
(opts as { extraSystemPrompt?: string } | undefined)?.extraSystemPrompt ?? "";
|
||||||
"";
|
|
||||||
expect(extraSystemPrompt).toContain("You are a helpful assistant.");
|
expect(extraSystemPrompt).toContain("You are a helpful assistant.");
|
||||||
expect(extraSystemPrompt).toContain("Be concise.");
|
expect(extraSystemPrompt).toContain("Be concise.");
|
||||||
await ensureResponseConsumed(resSystemDeveloper);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("includes instructions in extraSystemPrompt", async () => {
|
||||||
const resInstructions = await postResponses(port, {
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
instructions: "Always respond in French.",
|
instructions: "Always respond in French.",
|
||||||
});
|
});
|
||||||
expect(resInstructions.status).toBe(200);
|
expect(res.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);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "I am Claude" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resHistory = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
{ type: "message", role: "system", content: "You are a helpful assistant." },
|
||||||
@@ -228,33 +340,56 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
{ type: "message", role: "user", content: "What did I just ask you?" },
|
{ type: "message", role: "user", content: "What did I just ask you?" },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(resHistory.status).toBe(200);
|
expect(res.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);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resFunctionOutput = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{ type: "message", role: "user", content: "What's the weather?" },
|
{ type: "message", role: "user", content: "What's the weather?" },
|
||||||
{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." },
|
{ type: "function_call_output", call_id: "call_1", output: "Sunny, 70F." },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(resFunctionOutput.status).toBe(200);
|
expect(res.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);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resInputFile = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: [
|
input: [
|
||||||
{
|
{
|
||||||
@@ -275,17 +410,29 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
expect(resInputFile.status).toBe(200);
|
expect(res.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);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resToolNone = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -296,15 +443,25 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: "none",
|
tool_choice: "none",
|
||||||
});
|
});
|
||||||
expect(resToolNone.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsToolNone] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect(
|
|
||||||
(optsToolNone as { clientTools?: unknown[] } | undefined)?.clientTools,
|
|
||||||
).toBeUndefined();
|
|
||||||
await ensureResponseConsumed(resToolNone);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const resToolChoice = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -319,16 +476,24 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: { type: "function", function: { name: "get_time" } },
|
tool_choice: { type: "function", function: { name: "get_time" } },
|
||||||
});
|
});
|
||||||
expect(resToolChoice.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsToolChoice] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
const clientTools =
|
const clientTools =
|
||||||
(optsToolChoice as { clientTools?: Array<{ function?: { name?: string } }> })
|
(opts as { clientTools?: Array<{ function?: { name?: string } }> })?.clientTools ?? [];
|
||||||
?.clientTools ?? [];
|
|
||||||
expect(clientTools).toHaveLength(1);
|
expect(clientTools).toHaveLength(1);
|
||||||
expect(clientTools[0]?.function?.name).toBe("get_time");
|
expect(clientTools[0]?.function?.name).toBe("get_time");
|
||||||
await ensureResponseConsumed(resToolChoice);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const resUnknownTool = await postResponses(port, {
|
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, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
tools: [
|
tools: [
|
||||||
@@ -339,51 +504,85 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
],
|
],
|
||||||
tool_choice: { type: "function", function: { name: "unknown_tool" } },
|
tool_choice: { type: "function", function: { name: "unknown_tool" } },
|
||||||
});
|
});
|
||||||
expect(resUnknownTool.status).toBe(400);
|
expect(res.status).toBe(400);
|
||||||
await ensureResponseConsumed(resUnknownTool);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }]);
|
it("passes max_output_tokens through to the agent stream params", async () => {
|
||||||
const resMaxTokens = await postResponses(port, {
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
max_output_tokens: 123,
|
max_output_tokens: 123,
|
||||||
});
|
});
|
||||||
expect(resMaxTokens.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const [optsMaxTokens] = agentCommand.mock.calls[0] ?? [];
|
|
||||||
expect(
|
|
||||||
(optsMaxTokens as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams
|
|
||||||
?.maxTokens,
|
|
||||||
).toBe(123);
|
|
||||||
await ensureResponseConsumed(resMaxTokens);
|
|
||||||
|
|
||||||
mockAgentOnce([{ text: "ok" }], {
|
const [opts] = agentCommand.mock.calls[0] ?? [];
|
||||||
|
expect(
|
||||||
|
(opts as { streamParams?: { maxTokens?: number } } | undefined)?.streamParams?.maxTokens,
|
||||||
|
).toBe(123);
|
||||||
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns usage when available", async () => {
|
||||||
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "ok" }],
|
||||||
|
meta: {
|
||||||
agentMeta: {
|
agentMeta: {
|
||||||
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
|
usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 },
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
const resUsage = await postResponses(port, {
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
stream: false,
|
stream: false,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resUsage.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const usageJson = (await resUsage.json()) as Record<string, unknown>;
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
|
expect(json.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 });
|
||||||
await ensureResponseConsumed(resUsage);
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
mockAgentOnce([{ text: "hello" }]);
|
it("returns a non-streaming response with correct shape", async () => {
|
||||||
const resShape = await postResponses(port, {
|
agentCommand.mockResolvedValueOnce({
|
||||||
|
payloads: [{ text: "hello" }],
|
||||||
|
} as never);
|
||||||
|
|
||||||
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
stream: false,
|
stream: false,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resShape.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const shapeJson = (await resShape.json()) as Record<string, unknown>;
|
const json = (await res.json()) as Record<string, unknown>;
|
||||||
expect(shapeJson.object).toBe("response");
|
expect(json.object).toBe("response");
|
||||||
expect(shapeJson.status).toBe("completed");
|
expect(json.status).toBe("completed");
|
||||||
expect(Array.isArray(shapeJson.output)).toBe(true);
|
expect(Array.isArray(json.output)).toBe(true);
|
||||||
|
|
||||||
const output = shapeJson.output as Array<Record<string, unknown>>;
|
const output = json.output as Array<Record<string, unknown>>;
|
||||||
expect(output.length).toBe(1);
|
expect(output.length).toBe(1);
|
||||||
const item = output[0] ?? {};
|
const item = output[0] ?? {};
|
||||||
expect(item.type).toBe("message");
|
expect(item.type).toBe("message");
|
||||||
@@ -393,29 +592,32 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
expect(content.length).toBe(1);
|
expect(content.length).toBe(1);
|
||||||
expect(content[0]?.type).toBe("output_text");
|
expect(content[0]?.type).toBe("output_text");
|
||||||
expect(content[0]?.text).toBe("hello");
|
expect(content[0]?.text).toBe("hello");
|
||||||
await ensureResponseConsumed(resShape);
|
await ensureResponseConsumed(res);
|
||||||
|
|
||||||
const resNoUser = await postResponses(port, {
|
|
||||||
model: "clawdbot",
|
|
||||||
input: [{ type: "message", role: "system", content: "yo" }],
|
|
||||||
});
|
|
||||||
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(resNoUser);
|
|
||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streams OpenResponses SSE events", async () => {
|
it("requires a user message in input", async () => {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
const server = await startServer(port);
|
const server = await startServer(port);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
agentCommand.mockReset();
|
const res = 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(
|
||||||
|
"invalid_request_error",
|
||||||
|
);
|
||||||
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("streams SSE events when stream=true (delta events)", async () => {
|
||||||
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
agentCommand.mockImplementationOnce(async (opts: unknown) => {
|
||||||
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
const runId = (opts as { runId?: string } | undefined)?.runId ?? "";
|
||||||
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
emitAgentEvent({ runId, stream: "assistant", data: { delta: "he" } });
|
||||||
@@ -423,18 +625,22 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
return { payloads: [{ text: "hello" }] } as never;
|
return { payloads: [{ text: "hello" }] } as never;
|
||||||
});
|
});
|
||||||
|
|
||||||
const resDelta = await postResponses(port, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resDelta.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(resDelta.headers.get("content-type") ?? "").toContain("text/event-stream");
|
expect(res.headers.get("content-type") ?? "").toContain("text/event-stream");
|
||||||
|
|
||||||
const deltaText = await resDelta.text();
|
const text = await res.text();
|
||||||
const deltaEvents = parseSseEvents(deltaText);
|
const events = parseSseEvents(text);
|
||||||
|
|
||||||
const eventTypes = deltaEvents.map((e) => e.event).filter(Boolean);
|
// Check for required event types
|
||||||
|
const eventTypes = events.map((e) => e.event).filter(Boolean);
|
||||||
expect(eventTypes).toContain("response.created");
|
expect(eventTypes).toContain("response.created");
|
||||||
expect(eventTypes).toContain("response.output_item.added");
|
expect(eventTypes).toContain("response.output_item.added");
|
||||||
expect(eventTypes).toContain("response.in_progress");
|
expect(eventTypes).toContain("response.in_progress");
|
||||||
@@ -443,51 +649,72 @@ describe("OpenResponses HTTP API (e2e)", () => {
|
|||||||
expect(eventTypes).toContain("response.output_text.done");
|
expect(eventTypes).toContain("response.output_text.done");
|
||||||
expect(eventTypes).toContain("response.content_part.done");
|
expect(eventTypes).toContain("response.content_part.done");
|
||||||
expect(eventTypes).toContain("response.completed");
|
expect(eventTypes).toContain("response.completed");
|
||||||
expect(deltaEvents.some((e) => e.data === "[DONE]")).toBe(true);
|
|
||||||
|
|
||||||
const deltas = deltaEvents
|
// Check for [DONE] terminal event
|
||||||
.filter((e) => e.event === "response.output_text.delta")
|
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
|
||||||
.map((e) => {
|
.map((e) => {
|
||||||
const parsed = JSON.parse(e.data) as { delta?: string };
|
const parsed = JSON.parse(e.data) as { delta?: string };
|
||||||
return parsed.delta ?? "";
|
return parsed.delta ?? "";
|
||||||
})
|
})
|
||||||
.join("");
|
.join("");
|
||||||
expect(deltas).toBe("hello");
|
expect(allDeltas).toBe("hello");
|
||||||
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
agentCommand.mockReset();
|
it("streams SSE events when stream=true (fallback when no deltas)", async () => {
|
||||||
agentCommand.mockResolvedValueOnce({
|
agentCommand.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const resFallback = await postResponses(port, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resFallback.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
const fallbackText = await resFallback.text();
|
const text = await res.text();
|
||||||
expect(fallbackText).toContain("[DONE]");
|
expect(text).toContain("[DONE]");
|
||||||
expect(fallbackText).toContain("hello");
|
expect(text).toContain("hello");
|
||||||
|
await ensureResponseConsumed(res);
|
||||||
|
} finally {
|
||||||
|
await server.close({ reason: "test done" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
agentCommand.mockReset();
|
it("event type matches JSON type field", async () => {
|
||||||
agentCommand.mockResolvedValueOnce({
|
agentCommand.mockResolvedValueOnce({
|
||||||
payloads: [{ text: "hello" }],
|
payloads: [{ text: "hello" }],
|
||||||
} as never);
|
} as never);
|
||||||
|
|
||||||
const resTypeMatch = await postResponses(port, {
|
const port = await getFreePort();
|
||||||
|
const server = await startServer(port);
|
||||||
|
try {
|
||||||
|
const res = await postResponses(port, {
|
||||||
stream: true,
|
stream: true,
|
||||||
model: "clawdbot",
|
model: "clawdbot",
|
||||||
input: "hi",
|
input: "hi",
|
||||||
});
|
});
|
||||||
expect(resTypeMatch.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
|
|
||||||
const typeText = await resTypeMatch.text();
|
const text = await res.text();
|
||||||
const typeEvents = parseSseEvents(typeText);
|
const events = parseSseEvents(text);
|
||||||
for (const event of typeEvents) {
|
|
||||||
|
for (const event of events) {
|
||||||
if (event.data === "[DONE]") continue;
|
if (event.data === "[DONE]") continue;
|
||||||
const parsed = JSON.parse(event.data) as { type?: string };
|
const parsed = JSON.parse(event.data) as { type?: string };
|
||||||
expect(event.event).toBe(parsed.type);
|
expect(event.event).toBe(parsed.type);
|
||||||
}
|
}
|
||||||
|
await ensureResponseConsumed(res);
|
||||||
} finally {
|
} finally {
|
||||||
await server.close({ reason: "test done" });
|
await server.close({ reason: "test done" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,46 @@ function _expectChannels(call: Record<string, unknown>, channel: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server agent", () => {
|
describe("gateway server agent", () => {
|
||||||
test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => {
|
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 () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
await writeSessionStore({
|
await writeSessionStore({
|
||||||
@@ -48,33 +87,6 @@ describe("gateway server agent", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
|
registerAgentRunContext("run-tool-off", { sessionKey: "agent:main:main" });
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
@@ -98,9 +110,15 @@ describe("gateway server agent", () => {
|
|||||||
? (evt.payload as Record<string, unknown>)
|
? (evt.payload as Record<string, unknown>)
|
||||||
: {};
|
: {};
|
||||||
expect(payload.stream).toBe("assistant");
|
expect(payload.stream).toBe("assistant");
|
||||||
}
|
|
||||||
|
|
||||||
{
|
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", {
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
runId: "run-wait-1",
|
runId: "run-wait-1",
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -112,15 +130,21 @@ describe("gateway server agent", () => {
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end", startedAt: 200, endedAt: 210 },
|
data: { phase: "end", startedAt: 200, endedAt: 210 },
|
||||||
});
|
});
|
||||||
}, 5);
|
}, 10);
|
||||||
|
|
||||||
const res = await waitP;
|
const res = await waitP;
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.payload.status).toBe("ok");
|
expect(res.payload.status).toBe("ok");
|
||||||
expect(res.payload.startedAt).toBe(200);
|
expect(res.payload.startedAt).toBe(200);
|
||||||
}
|
|
||||||
|
|
||||||
{
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("agent.wait resolves when lifecycle ended before wait call", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
emitAgentEvent({
|
emitAgentEvent({
|
||||||
runId: "run-wait-early",
|
runId: "run-wait-early",
|
||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
@@ -134,18 +158,30 @@ describe("gateway server agent", () => {
|
|||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.payload.status).toBe("ok");
|
expect(res.payload.status).toBe("ok");
|
||||||
expect(res.payload.startedAt).toBe(50);
|
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", {
|
const res = await rpcReq(ws, "agent.wait", {
|
||||||
runId: "run-wait-3",
|
runId: "run-wait-3",
|
||||||
timeoutMs: 30,
|
timeoutMs: 20,
|
||||||
});
|
});
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.payload.status).toBe("timeout");
|
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", {
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
runId: "run-wait-err",
|
runId: "run-wait-err",
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -157,15 +193,21 @@ describe("gateway server agent", () => {
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "error", error: "boom" },
|
data: { phase: "error", error: "boom" },
|
||||||
});
|
});
|
||||||
}, 5);
|
}, 10);
|
||||||
|
|
||||||
const res = await waitP;
|
const res = await waitP;
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.payload.status).toBe("error");
|
expect(res.payload.status).toBe("error");
|
||||||
expect(res.payload.error).toBe("boom");
|
expect(res.payload.error).toBe("boom");
|
||||||
}
|
|
||||||
|
|
||||||
{
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
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", {
|
const waitP = rpcReq(ws, "agent.wait", {
|
||||||
runId: "run-wait-start",
|
runId: "run-wait-start",
|
||||||
timeoutMs: 1000,
|
timeoutMs: 1000,
|
||||||
@@ -183,18 +225,15 @@ describe("gateway server agent", () => {
|
|||||||
stream: "lifecycle",
|
stream: "lifecycle",
|
||||||
data: { phase: "end", endedAt: 456 },
|
data: { phase: "end", endedAt: 456 },
|
||||||
});
|
});
|
||||||
}, 5);
|
}, 10);
|
||||||
|
|
||||||
const res = await waitP;
|
const res = await waitP;
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(res.payload.status).toBe("ok");
|
expect(res.payload.status).toBe("ok");
|
||||||
expect(res.payload.startedAt).toBe(123);
|
expect(res.payload.startedAt).toBe(123);
|
||||||
expect(res.payload.endedAt).toBe(456);
|
expect(res.payload.endedAt).toBe(456);
|
||||||
}
|
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
await fs.rm(dir, { recursive: true, force: true });
|
|
||||||
testState.sessionStorePath = undefined;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -30,11 +30,11 @@ describe("gateway server auth/connect", () => {
|
|||||||
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
test("closes silent handshakes after timeout", { timeout: 60_000 }, async () => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
const prevHandshakeTimeout = process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS;
|
||||||
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "50";
|
process.env.CLAWDBOT_TEST_HANDSHAKE_TIMEOUT_MS = "250";
|
||||||
try {
|
try {
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
const handshakeTimeoutMs = getHandshakeTimeoutMs();
|
||||||
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 250);
|
const closed = await waitForWsClose(ws, handshakeTimeoutMs + 2_000);
|
||||||
expect(closed).toBe(true);
|
expect(closed).toBe(true);
|
||||||
await server.close();
|
await server.close();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
|
||||||
import {
|
import {
|
||||||
agentCommand,
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
@@ -14,7 +13,9 @@ import {
|
|||||||
testState,
|
testState,
|
||||||
writeSessionStore,
|
writeSessionStore,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||||
const deadline = Date.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
@@ -23,54 +24,23 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
|||||||
}
|
}
|
||||||
throw new Error("timeout waiting for condition");
|
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", () => {
|
describe("gateway server chat", () => {
|
||||||
test("handles history, abort, idempotency, and ordering flows", { timeout: 60_000 }, async () => {
|
test("chat.history caps payload bytes", { timeout: 60_000 }, async () => {
|
||||||
const tempDirs: string[] = [];
|
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();
|
const { server, ws } = await startServerWithClient();
|
||||||
const spy = vi.mocked(agentCommand);
|
|
||||||
const resetSpy = () => {
|
|
||||||
spy.mockReset();
|
|
||||||
spy.mockResolvedValue(undefined);
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
await withSessionStore(
|
|
||||||
tempDirs,
|
|
||||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
|
||||||
async (historyDir) => {
|
|
||||||
const bigText = "x".repeat(200_000);
|
const bigText = "x".repeat(200_000);
|
||||||
const largeLines: string[] = [];
|
const largeLines: string[] = [];
|
||||||
for (let i = 0; i < 40; i += 1) {
|
for (let i = 0; i < 40; i += 1) {
|
||||||
@@ -84,11 +54,8 @@ describe("gateway server chat", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await fs.writeFile(
|
await fs.writeFile(path.join(dir, "sess-main.jsonl"), largeLines.join("\n"), "utf-8");
|
||||||
path.join(historyDir, "sess-main.jsonl"),
|
|
||||||
largeLines.join("\n"),
|
|
||||||
"utf-8",
|
|
||||||
);
|
|
||||||
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
const cappedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
@@ -98,11 +65,16 @@ describe("gateway server chat", () => {
|
|||||||
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
const bytes = Buffer.byteLength(JSON.stringify(cappedMsgs), "utf8");
|
||||||
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
expect(bytes).toBeLessThanOrEqual(6 * 1024 * 1024);
|
||||||
expect(cappedMsgs.length).toBeLessThan(60);
|
expect(cappedMsgs.length).toBeLessThan(60);
|
||||||
},
|
|
||||||
);
|
ws.close();
|
||||||
await withSessionStore(
|
await server.close();
|
||||||
tempDirs,
|
});
|
||||||
{
|
|
||||||
|
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: {
|
main: {
|
||||||
sessionId: "sess-main",
|
sessionId: "sess-main",
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
@@ -110,27 +82,47 @@ describe("gateway server chat", () => {
|
|||||||
lastTo: "+1555",
|
lastTo: "+1555",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
async () => {
|
});
|
||||||
const routeRes = await rpcReq(ws, "chat.send", {
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "chat.send", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-route",
|
idempotencyKey: "idem-route",
|
||||||
});
|
});
|
||||||
expect(routeRes.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
const stored = JSON.parse(
|
|
||||||
await fs.readFile(testState.sessionStorePath as string, "utf-8"),
|
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
|
||||||
) as Record<string, { lastChannel?: string; lastTo?: string } | undefined>;
|
string,
|
||||||
|
{ lastChannel?: string; lastTo?: string } | undefined
|
||||||
|
>;
|
||||||
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
|
expect(stored["agent:main:main"]?.lastChannel).toBe("whatsapp");
|
||||||
expect(stored["agent:main:main"]?.lastTo).toBe("+1555");
|
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(),
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
await withSessionStore(
|
});
|
||||||
tempDirs,
|
|
||||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
const { server, ws } = await startServerWithClient();
|
||||||
async () => {
|
let inFlight: Promise<unknown> | undefined;
|
||||||
resetSpy();
|
|
||||||
let abortInFlight: Promise<unknown> | undefined;
|
|
||||||
try {
|
try {
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
const callsBefore = spy.mock.calls.length;
|
const callsBefore = spy.mock.calls.length;
|
||||||
spy.mockImplementationOnce(async (opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
@@ -140,57 +132,83 @@ describe("gateway server chat", () => {
|
|||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const sendResP = onceMessage(
|
|
||||||
ws,
|
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-1", 8000);
|
||||||
(o) => o.type === "res" && o.id === "send-abort-1",
|
|
||||||
8000,
|
|
||||||
);
|
|
||||||
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
|
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-1", 8000);
|
||||||
const abortedEventP = onceMessage(
|
const abortedEventP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
|
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
|
||||||
8000,
|
8000,
|
||||||
);
|
);
|
||||||
abortInFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
|
inFlight = Promise.allSettled([sendResP, abortResP, abortedEventP]);
|
||||||
sendReq(ws, "send-abort-1", "chat.send", {
|
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: "send-abort-1",
|
||||||
|
method: "chat.send",
|
||||||
|
params: {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-abort-1",
|
idempotencyKey: "idem-abort-1",
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const sendRes = await sendResP;
|
const sendRes = await sendResP;
|
||||||
expect(sendRes.ok).toBe(true);
|
expect(sendRes.ok).toBe(true);
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const deadline = Date.now() + 1000;
|
const deadline = Date.now() + 1000;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (spy.mock.calls.length > callsBefore) return resolve();
|
if (spy.mock.calls.length > callsBefore) return resolve();
|
||||||
if (Date.now() > deadline)
|
if (Date.now() > deadline) return reject(new Error("timeout waiting for agentCommand"));
|
||||||
return reject(new Error("timeout waiting for agentCommand"));
|
|
||||||
setTimeout(tick, 5);
|
setTimeout(tick, 5);
|
||||||
};
|
};
|
||||||
tick();
|
tick();
|
||||||
});
|
});
|
||||||
sendReq(ws, "abort-1", "chat.abort", {
|
|
||||||
sessionKey: "main",
|
ws.send(
|
||||||
runId: "idem-abort-1",
|
JSON.stringify({
|
||||||
});
|
type: "req",
|
||||||
|
id: "abort-1",
|
||||||
|
method: "chat.abort",
|
||||||
|
params: { sessionKey: "main", runId: "idem-abort-1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const abortRes = await abortResP;
|
const abortRes = await abortResP;
|
||||||
expect(abortRes.ok).toBe(true);
|
expect(abortRes.ok).toBe(true);
|
||||||
|
|
||||||
const evt = await abortedEventP;
|
const evt = await abortedEventP;
|
||||||
expect(evt.payload?.runId).toBe("idem-abort-1");
|
expect(evt.payload?.runId).toBe("idem-abort-1");
|
||||||
expect(evt.payload?.sessionKey).toBe("main");
|
expect(evt.payload?.sessionKey).toBe("main");
|
||||||
} finally {
|
} finally {
|
||||||
await abortInFlight;
|
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(),
|
||||||
},
|
},
|
||||||
);
|
},
|
||||||
await withSessionStore(
|
});
|
||||||
tempDirs,
|
|
||||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
|
||||||
async () => {
|
|
||||||
sessionStoreSaveDelayMs.value = 120;
|
sessionStoreSaveDelayMs.value = 120;
|
||||||
resetSpy();
|
|
||||||
try {
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const spy = vi.mocked(agentCommand);
|
||||||
spy.mockImplementationOnce(async (opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -199,43 +217,66 @@ describe("gateway server chat", () => {
|
|||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const abortedEventP = onceMessage(
|
const abortedEventP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
|
(o) => o.type === "event" && o.event === "chat" && o.payload?.state === "aborted",
|
||||||
);
|
);
|
||||||
const sendResP = onceMessage(
|
|
||||||
ws,
|
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-abort-save-1");
|
||||||
(o) => o.type === "res" && o.id === "send-abort-save-1",
|
|
||||||
);
|
ws.send(
|
||||||
sendReq(ws, "send-abort-save-1", "chat.send", {
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: "send-abort-save-1",
|
||||||
|
method: "chat.send",
|
||||||
|
params: {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-abort-save-1",
|
idempotencyKey: "idem-abort-save-1",
|
||||||
timeoutMs: 30_000,
|
timeoutMs: 30_000,
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
|
const abortResP = onceMessage(ws, (o) => o.type === "res" && o.id === "abort-save-1");
|
||||||
sendReq(ws, "abort-save-1", "chat.abort", {
|
ws.send(
|
||||||
sessionKey: "main",
|
JSON.stringify({
|
||||||
runId: "idem-abort-save-1",
|
type: "req",
|
||||||
});
|
id: "abort-save-1",
|
||||||
|
method: "chat.abort",
|
||||||
|
params: { sessionKey: "main", runId: "idem-abort-save-1" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const abortRes = await abortResP;
|
const abortRes = await abortResP;
|
||||||
expect(abortRes.ok).toBe(true);
|
expect(abortRes.ok).toBe(true);
|
||||||
|
|
||||||
const sendRes = await sendResP;
|
const sendRes = await sendResP;
|
||||||
expect(sendRes.ok).toBe(true);
|
expect(sendRes.ok).toBe(true);
|
||||||
|
|
||||||
const evt = await abortedEventP;
|
const evt = await abortedEventP;
|
||||||
expect(evt.payload?.runId).toBe("idem-abort-save-1");
|
expect(evt.payload?.runId).toBe("idem-abort-save-1");
|
||||||
expect(evt.payload?.sessionKey).toBe("main");
|
expect(evt.payload?.sessionKey).toBe("main");
|
||||||
} finally {
|
|
||||||
sessionStoreSaveDelayMs.value = 0;
|
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() },
|
||||||
},
|
},
|
||||||
);
|
});
|
||||||
await withSessionStore(
|
|
||||||
tempDirs,
|
const { server, ws } = await startServerWithClient();
|
||||||
{ main: { sessionId: "sess-main", updatedAt: Date.now() } },
|
await connectOk(ws);
|
||||||
async () => {
|
|
||||||
resetSpy();
|
const spy = vi.mocked(agentCommand);
|
||||||
const callsBeforeStop = spy.mock.calls.length;
|
const callsBefore = spy.mock.calls.length;
|
||||||
spy.mockImplementationOnce(async (opts) => {
|
spy.mockImplementationOnce(async (opts) => {
|
||||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||||
await new Promise<void>((resolve) => {
|
await new Promise<void>((resolve) => {
|
||||||
@@ -244,20 +285,26 @@ describe("gateway server chat", () => {
|
|||||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
const stopSendResP = onceMessage(
|
|
||||||
ws,
|
const sendResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-1", 8000);
|
||||||
(o) => o.type === "res" && o.id === "send-stop-1",
|
ws.send(
|
||||||
8000,
|
JSON.stringify({
|
||||||
);
|
type: "req",
|
||||||
sendReq(ws, "send-stop-1", "chat.send", {
|
id: "send-stop-1",
|
||||||
|
method: "chat.send",
|
||||||
|
params: {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-stop-run",
|
idempotencyKey: "idem-stop-run",
|
||||||
});
|
},
|
||||||
const stopSendRes = await stopSendResP;
|
}),
|
||||||
expect(stopSendRes.ok).toBe(true);
|
);
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeStop);
|
const sendRes = await sendResP;
|
||||||
const abortedStopEventP = onceMessage(
|
expect(sendRes.ok).toBe(true);
|
||||||
|
|
||||||
|
await waitFor(() => spy.mock.calls.length > callsBefore);
|
||||||
|
|
||||||
|
const abortedEventP = onceMessage(
|
||||||
ws,
|
ws,
|
||||||
(o) =>
|
(o) =>
|
||||||
o.type === "event" &&
|
o.type === "event" &&
|
||||||
@@ -266,20 +313,37 @@ describe("gateway server chat", () => {
|
|||||||
o.payload?.runId === "idem-stop-run",
|
o.payload?.runId === "idem-stop-run",
|
||||||
8000,
|
8000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
|
const stopResP = onceMessage(ws, (o) => o.type === "res" && o.id === "send-stop-2", 8000);
|
||||||
sendReq(ws, "send-stop-2", "chat.send", {
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: "send-stop-2",
|
||||||
|
method: "chat.send",
|
||||||
|
params: {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "/stop",
|
message: "/stop",
|
||||||
idempotencyKey: "idem-stop-req",
|
idempotencyKey: "idem-stop-req",
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
const stopRes = await stopResP;
|
const stopRes = await stopResP;
|
||||||
expect(stopRes.ok).toBe(true);
|
expect(stopRes.ok).toBe(true);
|
||||||
const stopEvt = await abortedStopEventP;
|
|
||||||
expect(stopEvt.payload?.sessionKey).toBe("main");
|
const evt = await abortedEventP;
|
||||||
expect(spy.mock.calls.length).toBe(callsBeforeStop + 1);
|
expect(evt.payload?.sessionKey).toBe("main");
|
||||||
},
|
|
||||||
);
|
expect(spy.mock.calls.length).toBe(callsBefore + 1);
|
||||||
resetSpy();
|
|
||||||
|
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;
|
let resolveRun: (() => void) | undefined;
|
||||||
const runDone = new Promise<void>((resolve) => {
|
const runDone = new Promise<void>((resolve) => {
|
||||||
resolveRun = resolve;
|
resolveRun = resolve;
|
||||||
@@ -287,6 +351,7 @@ describe("gateway server chat", () => {
|
|||||||
spy.mockImplementationOnce(async () => {
|
spy.mockImplementationOnce(async () => {
|
||||||
await runDone;
|
await runDone;
|
||||||
});
|
});
|
||||||
|
|
||||||
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
const started = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
@@ -294,14 +359,17 @@ describe("gateway server chat", () => {
|
|||||||
});
|
});
|
||||||
expect(started.ok).toBe(true);
|
expect(started.ok).toBe(true);
|
||||||
expect(started.payload?.status).toBe("started");
|
expect(started.payload?.status).toBe("started");
|
||||||
const inFlightRes = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
|
||||||
|
const inFlight = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-status-1",
|
idempotencyKey: "idem-status-1",
|
||||||
});
|
});
|
||||||
expect(inFlightRes.ok).toBe(true);
|
expect(inFlight.ok).toBe(true);
|
||||||
expect(inFlightRes.payload?.status).toBe("in_flight");
|
expect(inFlight.payload?.status).toBe("in_flight");
|
||||||
|
|
||||||
resolveRun?.();
|
resolveRun?.();
|
||||||
|
|
||||||
let completed = false;
|
let completed = false;
|
||||||
for (let i = 0; i < 50; i++) {
|
for (let i = 0; i < 50; i++) {
|
||||||
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
const again = await rpcReq<{ runId?: string; status?: string }>(ws, "chat.send", {
|
||||||
@@ -316,207 +384,8 @@ describe("gateway server chat", () => {
|
|||||||
await new Promise((r) => setTimeout(r, 10));
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
}
|
}
|
||||||
expect(completed).toBe(true);
|
expect(completed).toBe(true);
|
||||||
resetSpy();
|
|
||||||
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 startedAbortAll = await rpcReq(ws, "chat.send", {
|
|
||||||
sessionKey: "main",
|
|
||||||
message: "hello",
|
|
||||||
idempotencyKey: "idem-abort-all-1",
|
|
||||||
});
|
|
||||||
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();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,13 +2,13 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test, vi } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
|
||||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||||
import {
|
import {
|
||||||
agentCommand,
|
agentCommand,
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
|
piSdkMock,
|
||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
@@ -27,17 +27,9 @@ async function waitFor(condition: () => boolean, timeoutMs = 1500) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("gateway server chat", () => {
|
describe("gateway server chat", () => {
|
||||||
test("handles chat send and history flows", async () => {
|
test("webchat can chat.send without a mobile node", async () => {
|
||||||
const tempDirs: string[] = [];
|
const { server, ws } = await startServerWithClient();
|
||||||
const { server, ws, port } = await startServerWithClient();
|
await connectOk(ws, {
|
||||||
let webchatWs: WebSocket | undefined;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await connectOk(ws);
|
|
||||||
|
|
||||||
webchatWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
|
||||||
await new Promise<void>((resolve) => webchatWs?.once("open", resolve));
|
|
||||||
await connectOk(webchatWs, {
|
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
id: GATEWAY_CLIENT_NAMES.CONTROL_UI,
|
||||||
version: "dev",
|
version: "dev",
|
||||||
@@ -46,48 +38,63 @@ describe("gateway server chat", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const webchatRes = await rpcReq(webchatWs, "chat.send", {
|
const res = await rpcReq(ws, "chat.send", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-webchat-1",
|
idempotencyKey: "idem-webchat-1",
|
||||||
});
|
});
|
||||||
expect(webchatRes.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
webchatWs.close();
|
ws.close();
|
||||||
webchatWs = undefined;
|
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 spy = vi.mocked(agentCommand);
|
||||||
spy.mockClear();
|
const callsBefore = spy.mock.calls.length;
|
||||||
testState.agentConfig = { timeoutSeconds: 123 };
|
const res = await rpcReq(ws, "chat.send", {
|
||||||
const callsBeforeTimeout = spy.mock.calls.length;
|
|
||||||
const timeoutRes = await rpcReq(ws, "chat.send", {
|
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-timeout-1",
|
idempotencyKey: "idem-timeout-1",
|
||||||
});
|
});
|
||||||
expect(timeoutRes.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeTimeout);
|
await waitFor(() => spy.mock.calls.length > callsBefore);
|
||||||
const timeoutCall = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
|
const call = spy.mock.calls.at(-1)?.[0] as { timeout?: string } | undefined;
|
||||||
expect(timeoutCall?.timeout).toBe("123");
|
expect(call?.timeout).toBe("123");
|
||||||
testState.agentConfig = undefined;
|
|
||||||
|
|
||||||
spy.mockClear();
|
ws.close();
|
||||||
const callsBeforeSession = spy.mock.calls.length;
|
await server.close();
|
||||||
const sessionRes = await rpcReq(ws, "chat.send", {
|
});
|
||||||
|
|
||||||
|
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",
|
sessionKey: "agent:main:subagent:abc",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-session-key-1",
|
idempotencyKey: "idem-session-key-1",
|
||||||
});
|
});
|
||||||
expect(sessionRes.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeSession);
|
await waitFor(() => spy.mock.calls.length > callsBefore);
|
||||||
const sessionCall = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
|
const call = spy.mock.calls.at(-1)?.[0] as { sessionKey?: string } | undefined;
|
||||||
expect(sessionCall?.sessionKey).toBe("agent:main:subagent:abc");
|
expect(call?.sessionKey).toBe("agent:main:subagent:abc");
|
||||||
|
|
||||||
const sendPolicyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
ws.close();
|
||||||
tempDirs.push(sendPolicyDir);
|
await server.close();
|
||||||
testState.sessionStorePath = path.join(sendPolicyDir, "sessions.json");
|
});
|
||||||
|
|
||||||
|
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 = {
|
testState.sessionConfig = {
|
||||||
sendPolicy: {
|
sendPolicy: {
|
||||||
default: "allow",
|
default: "allow",
|
||||||
@@ -111,22 +118,24 @@ describe("gateway server chat", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const blockedRes = await rpcReq(ws, "chat.send", {
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "chat.send", {
|
||||||
sessionKey: "discord:group:dev",
|
sessionKey: "discord:group:dev",
|
||||||
message: "hello",
|
message: "hello",
|
||||||
idempotencyKey: "idem-1",
|
idempotencyKey: "idem-1",
|
||||||
});
|
});
|
||||||
expect(blockedRes.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect((blockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
|
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
|
||||||
/send blocked/i,
|
|
||||||
);
|
|
||||||
|
|
||||||
testState.sessionStorePath = undefined;
|
ws.close();
|
||||||
testState.sessionConfig = undefined;
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
const agentBlockedDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
test("agent blocked by send policy for sessionKey", async () => {
|
||||||
tempDirs.push(agentBlockedDir);
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||||
testState.sessionStorePath = path.join(agentBlockedDir, "sessions.json");
|
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||||
testState.sessionConfig = {
|
testState.sessionConfig = {
|
||||||
sendPolicy: {
|
sendPolicy: {
|
||||||
default: "allow",
|
default: "allow",
|
||||||
@@ -143,21 +152,27 @@ describe("gateway server chat", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentBlockedRes = await rpcReq(ws, "agent", {
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "agent", {
|
||||||
sessionKey: "cron:job-1",
|
sessionKey: "cron:job-1",
|
||||||
message: "hi",
|
message: "hi",
|
||||||
idempotencyKey: "idem-2",
|
idempotencyKey: "idem-2",
|
||||||
});
|
});
|
||||||
expect(agentBlockedRes.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect((agentBlockedRes.error as { message?: string } | undefined)?.message ?? "").toMatch(
|
expect((res.error as { message?: string } | undefined)?.message ?? "").toMatch(/send blocked/i);
|
||||||
/send blocked/i,
|
|
||||||
);
|
|
||||||
|
|
||||||
testState.sessionStorePath = undefined;
|
ws.close();
|
||||||
testState.sessionConfig = undefined;
|
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;
|
||||||
|
|
||||||
spy.mockClear();
|
|
||||||
const callsBeforeImage = spy.mock.calls.length;
|
|
||||||
const pngB64 =
|
const pngB64 =
|
||||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
|
||||||
|
|
||||||
@@ -183,19 +198,33 @@ describe("gateway server chat", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const imgRes = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
|
const res = await onceMessage(ws, (o) => o.type === "res" && o.id === reqId, 8000);
|
||||||
expect(imgRes.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
expect(imgRes.payload?.runId).toBeDefined();
|
expect(res.payload?.runId).toBeDefined();
|
||||||
|
|
||||||
await waitFor(() => spy.mock.calls.length > callsBeforeImage, 8000);
|
await waitFor(() => spy.mock.calls.length > callsBefore, 8000);
|
||||||
const imgCall = spy.mock.calls.at(-1)?.[0] as
|
const call = spy.mock.calls.at(-1)?.[0] as
|
||||||
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
| { images?: Array<{ type: string; data: string; mimeType: string }> }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(imgCall?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
|
||||||
|
|
||||||
const historyDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
ws.close();
|
||||||
tempDirs.push(historyDir);
|
await server.close();
|
||||||
testState.sessionStorePath = path.join(historyDir, "sessions.json");
|
});
|
||||||
|
|
||||||
|
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({
|
await writeSessionStore({
|
||||||
entries: {
|
entries: {
|
||||||
main: {
|
main: {
|
||||||
@@ -217,32 +246,249 @@ describe("gateway server chat", () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
await fs.writeFile(path.join(historyDir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
|
await fs.writeFile(path.join(dir, "sess-main.jsonl"), lines.join("\n"), "utf-8");
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
const defaultRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||||
sessionKey: "main",
|
sessionKey: "main",
|
||||||
});
|
});
|
||||||
expect(defaultRes.ok).toBe(true);
|
expect(defaultRes.ok).toBe(true);
|
||||||
const defaultMsgs = defaultRes.payload?.messages ?? [];
|
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(defaultMsgs.length).toBe(200);
|
||||||
expect(firstContentText(defaultMsgs[0])).toBe("m100");
|
expect(firstContentText(defaultMsgs[0])).toBe("m100");
|
||||||
} finally {
|
|
||||||
testState.agentConfig = undefined;
|
const limitedRes = await rpcReq<{ messages?: unknown[] }>(ws, "chat.history", {
|
||||||
testState.sessionStorePath = undefined;
|
sessionKey: "main",
|
||||||
testState.sessionConfig = undefined;
|
limit: 5,
|
||||||
if (webchatWs) webchatWs.close();
|
});
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
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();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
await Promise.all(tempDirs.map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,47 +1,39 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
getFreePort,
|
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
startGatewayServer,
|
startServerWithClient,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks();
|
||||||
|
|
||||||
let server: Awaited<ReturnType<typeof startGatewayServer>>;
|
const servers: Array<Awaited<ReturnType<typeof startServerWithClient>>> = [];
|
||||||
let port = 0;
|
|
||||||
let previousToken: string | undefined;
|
|
||||||
|
|
||||||
beforeAll(async () => {
|
afterEach(async () => {
|
||||||
previousToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
for (const { server, ws } of servers) {
|
||||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
try {
|
||||||
port = await getFreePort();
|
ws.close();
|
||||||
server = await startGatewayServer(port);
|
|
||||||
});
|
|
||||||
|
|
||||||
afterAll(async () => {
|
|
||||||
await server.close();
|
await server.close();
|
||||||
if (previousToken === undefined) delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
} catch {
|
||||||
else process.env.CLAWDBOT_GATEWAY_TOKEN = previousToken;
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
servers.length = 0;
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||||
});
|
});
|
||||||
|
|
||||||
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", () => {
|
describe("gateway config.apply", () => {
|
||||||
it("writes config, stores sentinel, and schedules restart", async () => {
|
it("writes config, stores sentinel, and schedules restart", async () => {
|
||||||
const ws = await openClient();
|
const result = await startServerWithClient();
|
||||||
try {
|
servers.push(result);
|
||||||
|
const { ws } = result;
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const id = "req-1";
|
const id = "req-1";
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -75,14 +67,14 @@ describe("gateway config.apply", () => {
|
|||||||
// File may not exist if signal delivery is mocked, verify response was ok instead
|
// File may not exist if signal delivery is mocked, verify response was ok instead
|
||||||
expect(res.ok).toBe(true);
|
expect(res.ok).toBe(true);
|
||||||
}
|
}
|
||||||
} finally {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid raw config", async () => {
|
it("rejects invalid raw config", async () => {
|
||||||
const ws = await openClient();
|
const result = await startServerWithClient();
|
||||||
try {
|
servers.push(result);
|
||||||
|
const { ws } = result;
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const id = "req-2";
|
const id = "req-2";
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
@@ -99,8 +91,5 @@ describe("gateway config.apply", () => {
|
|||||||
(o) => o.type === "res" && o.id === id,
|
(o) => o.type === "res" && o.id === id,
|
||||||
);
|
);
|
||||||
expect(res.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
} finally {
|
|
||||||
ws.close();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
+302
-126
@@ -1,18 +1,17 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test, vi } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import {
|
import {
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
|
||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
testState,
|
testState,
|
||||||
waitForSystemEvent,
|
waitForSystemEvent,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks();
|
||||||
|
|
||||||
async function yieldToEventLoop() {
|
async function yieldToEventLoop() {
|
||||||
// Avoid relying on timers (fake timers can leak between tests).
|
// Avoid relying on timers (fake timers can leak between tests).
|
||||||
@@ -36,43 +35,16 @@ async function rmTempDir(dir: string) {
|
|||||||
await fs.rm(dir, { recursive: true, force: true });
|
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", () => {
|
describe("gateway server cron", () => {
|
||||||
test("handles cron CRUD, normalization, and patch semantics", { timeout: 120_000 }, async () => {
|
test("supports cron.add and cron.list", { timeout: 120_000 }, async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
testState.sessionConfig = { mainKey: "primary" };
|
|
||||||
testState.cronEnabled = false;
|
|
||||||
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
try {
|
|
||||||
const addRes = await rpcReq(ws, "cron.add", {
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
name: "daily",
|
name: "daily",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -93,42 +65,90 @@ describe("gateway server cron", () => {
|
|||||||
expect((jobs as unknown[]).length).toBe(1);
|
expect((jobs as unknown[]).length).toBe(1);
|
||||||
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
|
expect(((jobs as Array<{ name?: unknown }>)[0]?.name as string) ?? "").toBe("daily");
|
||||||
|
|
||||||
const routeAtMs = Date.now() - 1;
|
ws.close();
|
||||||
const routeRes = await rpcReq(ws, "cron.add", {
|
await server.close();
|
||||||
|
await rmTempDir(dir);
|
||||||
|
testState.cronStorePath = undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
test("enqueues main cron system events to the resolved main session key", 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",
|
name: "route test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "at", atMs: routeAtMs },
|
schedule: { kind: "at", atMs },
|
||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "cron route check" },
|
payload: { kind: "systemEvent", text: "cron route check" },
|
||||||
});
|
});
|
||||||
expect(routeRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const routeJobIdValue = (routeRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const routeJobId = typeof routeJobIdValue === "string" ? routeJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(routeJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const runRes = await rpcReq(ws, "cron.run", { id: routeJobId, mode: "force" }, 20_000);
|
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
|
||||||
expect(runRes.ok).toBe(true);
|
expect(runRes.ok).toBe(true);
|
||||||
|
|
||||||
const events = await waitForSystemEvent();
|
const events = await waitForSystemEvent();
|
||||||
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
|
expect(events.some((event) => event.includes("cron route check"))).toBe(true);
|
||||||
|
|
||||||
const wrappedAtMs = Date.now() + 1000;
|
ws.close();
|
||||||
const wrappedRes = await rpcReq(ws, "cron.add", {
|
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: {
|
data: {
|
||||||
name: "wrapped",
|
name: "wrapped",
|
||||||
schedule: { atMs: wrappedAtMs },
|
schedule: { atMs },
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(wrappedRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const wrappedPayload = wrappedRes.payload as
|
const payload = addRes.payload as
|
||||||
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
|
| { schedule?: unknown; sessionTarget?: unknown; wakeMode?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(wrappedPayload?.sessionTarget).toBe("main");
|
expect(payload?.sessionTarget).toBe("main");
|
||||||
expect(wrappedPayload?.wakeMode).toBe("next-heartbeat");
|
expect(payload?.wakeMode).toBe("next-heartbeat");
|
||||||
expect((wrappedPayload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
expect((payload?.schedule as { kind?: unknown } | undefined)?.kind).toBe("at");
|
||||||
|
|
||||||
const patchRes = await rpcReq(ws, "cron.add", {
|
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",
|
name: "patch test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
@@ -136,14 +156,14 @@ describe("gateway server cron", () => {
|
|||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
});
|
});
|
||||||
expect(patchRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const patchJobIdValue = (patchRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const patchJobId = typeof patchJobIdValue === "string" ? patchJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(patchJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const atMs = Date.now() + 1_000;
|
const atMs = Date.now() + 1_000;
|
||||||
const updateRes = await rpcReq(ws, "cron.update", {
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: patchJobId,
|
id: jobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { atMs },
|
schedule: { atMs },
|
||||||
payload: { kind: "systemEvent", text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
@@ -156,7 +176,22 @@ describe("gateway server cron", () => {
|
|||||||
expect(updated?.schedule?.kind).toBe("at");
|
expect(updated?.schedule?.kind).toBe("at");
|
||||||
expect(updated?.payload?.kind).toBe("systemEvent");
|
expect(updated?.payload?.kind).toBe("systemEvent");
|
||||||
|
|
||||||
const mergeRes = await rpcReq(ws, "cron.add", {
|
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",
|
name: "patch merge",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
@@ -164,19 +199,19 @@ describe("gateway server cron", () => {
|
|||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "agentTurn", message: "hello", model: "opus" },
|
payload: { kind: "agentTurn", message: "hello", model: "opus" },
|
||||||
});
|
});
|
||||||
expect(mergeRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const mergeJobIdValue = (mergeRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const mergeJobId = typeof mergeJobIdValue === "string" ? mergeJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(mergeJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const mergeUpdateRes = await rpcReq(ws, "cron.update", {
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: mergeJobId,
|
id: jobId,
|
||||||
patch: {
|
patch: {
|
||||||
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
|
payload: { kind: "agentTurn", deliver: true, channel: "telegram", to: "19098680" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(mergeUpdateRes.ok).toBe(true);
|
expect(updateRes.ok).toBe(true);
|
||||||
const merged = mergeUpdateRes.payload as
|
const updated = updateRes.payload as
|
||||||
| {
|
| {
|
||||||
payload?: {
|
payload?: {
|
||||||
kind?: unknown;
|
kind?: unknown;
|
||||||
@@ -188,14 +223,29 @@ describe("gateway server cron", () => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
| undefined;
|
| undefined;
|
||||||
expect(merged?.payload?.kind).toBe("agentTurn");
|
expect(updated?.payload?.kind).toBe("agentTurn");
|
||||||
expect(merged?.payload?.message).toBe("hello");
|
expect(updated?.payload?.message).toBe("hello");
|
||||||
expect(merged?.payload?.model).toBe("opus");
|
expect(updated?.payload?.model).toBe("opus");
|
||||||
expect(merged?.payload?.deliver).toBe(true);
|
expect(updated?.payload?.deliver).toBe(true);
|
||||||
expect(merged?.payload?.channel).toBe("telegram");
|
expect(updated?.payload?.channel).toBe("telegram");
|
||||||
expect(merged?.payload?.to).toBe("19098680");
|
expect(updated?.payload?.to).toBe("19098680");
|
||||||
|
|
||||||
const rejectRes = await rpcReq(ws, "cron.add", {
|
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",
|
name: "patch reject",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
@@ -203,20 +253,36 @@ describe("gateway server cron", () => {
|
|||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
});
|
});
|
||||||
expect(rejectRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const rejectJobIdValue = (rejectRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const rejectJobId = typeof rejectJobIdValue === "string" ? rejectJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(rejectJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const rejectUpdateRes = await rpcReq(ws, "cron.update", {
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: rejectJobId,
|
id: jobId,
|
||||||
patch: {
|
patch: {
|
||||||
payload: { kind: "agentTurn", deliver: true },
|
payload: { kind: "agentTurn", deliver: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(rejectUpdateRes.ok).toBe(false);
|
expect(updateRes.ok).toBe(false);
|
||||||
|
|
||||||
const jobIdRes = await rpcReq(ws, "cron.add", {
|
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: [] }));
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
name: "jobId test",
|
name: "jobId test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
@@ -224,21 +290,38 @@ describe("gateway server cron", () => {
|
|||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
});
|
});
|
||||||
expect(jobIdRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const jobIdValue = (jobIdRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(jobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const jobIdUpdateRes = await rpcReq(ws, "cron.update", {
|
const atMs = Date.now() + 2_000;
|
||||||
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
jobId,
|
jobId,
|
||||||
patch: {
|
patch: {
|
||||||
schedule: { atMs: Date.now() + 2_000 },
|
schedule: { atMs },
|
||||||
payload: { kind: "systemEvent", text: "updated" },
|
payload: { kind: "systemEvent", text: "updated" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(jobIdUpdateRes.ok).toBe(true);
|
expect(updateRes.ok).toBe(true);
|
||||||
|
|
||||||
const disableRes = await rpcReq(ws, "cron.add", {
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
await rmTempDir(dir);
|
||||||
|
testState.cronStorePath = 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",
|
name: "disable test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "every", everyMs: 60_000 },
|
schedule: { kind: "every", everyMs: 60_000 },
|
||||||
@@ -246,39 +329,34 @@ describe("gateway server cron", () => {
|
|||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "hello" },
|
payload: { kind: "systemEvent", text: "hello" },
|
||||||
});
|
});
|
||||||
expect(disableRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const disableJobIdValue = (disableRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const disableJobId = typeof disableJobIdValue === "string" ? disableJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(disableJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const disableUpdateRes = await rpcReq(ws, "cron.update", {
|
const updateRes = await rpcReq(ws, "cron.update", {
|
||||||
id: disableJobId,
|
id: jobId,
|
||||||
patch: { enabled: false },
|
patch: { enabled: false },
|
||||||
});
|
});
|
||||||
expect(disableUpdateRes.ok).toBe(true);
|
expect(updateRes.ok).toBe(true);
|
||||||
const disabled = disableUpdateRes.payload as { enabled?: unknown } | undefined;
|
const updated = updateRes.payload as { enabled?: unknown } | undefined;
|
||||||
expect(disabled?.enabled).toBe(false);
|
expect(updated?.enabled).toBe(false);
|
||||||
} finally {
|
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
await rmTempDir(dir);
|
await fs.rm(dir, { recursive: true, force: true });
|
||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
testState.sessionConfig = undefined;
|
|
||||||
testState.cronEnabled = undefined;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("writes cron run history and auto-runs due jobs", async () => {
|
test("writes cron run history to runs/<jobId>.jsonl", async () => {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-cron-log-"));
|
||||||
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
testState.cronStorePath = path.join(dir, "cron", "jobs.json");
|
||||||
testState.cronEnabled = undefined;
|
|
||||||
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
await fs.mkdir(path.dirname(testState.cronStorePath), { recursive: true });
|
||||||
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
try {
|
|
||||||
const atMs = Date.now() - 1;
|
const atMs = Date.now() - 1;
|
||||||
const addRes = await rpcReq(ws, "cron.add", {
|
const addRes = await rpcReq(ws, "cron.add", {
|
||||||
name: "log test",
|
name: "log test",
|
||||||
@@ -293,13 +371,21 @@ describe("gateway server cron", () => {
|
|||||||
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(jobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
const finishedP = waitForCronFinished(ws, jobId);
|
// 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);
|
const runRes = await rpcReq(ws, "cron.run", { id: jobId, mode: "force" }, 20_000);
|
||||||
expect(runRes.ok).toBe(true);
|
expect(runRes.ok).toBe(true);
|
||||||
await finishedP;
|
|
||||||
|
|
||||||
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
|
const logPath = path.join(dir, "cron", "runs", `${jobId}.jsonl`);
|
||||||
const raw = await waitForNonEmptyFile(logPath);
|
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
|
const line = raw
|
||||||
.split("\n")
|
.split("\n")
|
||||||
.map((l) => l.trim())
|
.map((l) => l.trim())
|
||||||
@@ -323,6 +409,92 @@ describe("gateway server cron", () => {
|
|||||||
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
|
expect((entries as Array<{ jobId?: unknown }>).at(-1)?.jobId).toBe(jobId);
|
||||||
expect((entries as Array<{ summary?: unknown }>).at(-1)?.summary).toBe("hello");
|
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,
|
||||||
|
});
|
||||||
|
await fs.writeFile(testState.cronStorePath, JSON.stringify({ version: 1, jobs: [] }));
|
||||||
|
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const statusRes = await rpcReq(ws, "cron.status", {});
|
const statusRes = await rpcReq(ws, "cron.status", {});
|
||||||
expect(statusRes.ok).toBe(true);
|
expect(statusRes.ok).toBe(true);
|
||||||
const statusPayload = statusRes.payload as
|
const statusPayload = statusRes.payload as
|
||||||
@@ -332,41 +504,45 @@ describe("gateway server cron", () => {
|
|||||||
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
|
const storePath = typeof statusPayload?.storePath === "string" ? statusPayload.storePath : "";
|
||||||
expect(storePath).toContain("jobs.json");
|
expect(storePath).toContain("jobs.json");
|
||||||
|
|
||||||
const autoRes = await rpcReq(ws, "cron.add", {
|
// 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", {
|
||||||
name: "auto run test",
|
name: "auto run test",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
schedule: { kind: "at", atMs: Date.now() - 10 },
|
schedule: { kind: "at", atMs },
|
||||||
sessionTarget: "main",
|
sessionTarget: "main",
|
||||||
wakeMode: "next-heartbeat",
|
wakeMode: "next-heartbeat",
|
||||||
payload: { kind: "systemEvent", text: "auto" },
|
payload: { kind: "systemEvent", text: "auto" },
|
||||||
});
|
});
|
||||||
expect(autoRes.ok).toBe(true);
|
expect(addRes.ok).toBe(true);
|
||||||
const autoJobIdValue = (autoRes.payload as { id?: unknown } | null)?.id;
|
const jobIdValue = (addRes.payload as { id?: unknown } | null)?.id;
|
||||||
const autoJobId = typeof autoJobIdValue === "string" ? autoJobIdValue : "";
|
const jobId = typeof jobIdValue === "string" ? jobIdValue : "";
|
||||||
expect(autoJobId.length > 0).toBe(true);
|
expect(jobId.length > 0).toBe(true);
|
||||||
|
|
||||||
vi.useFakeTimers();
|
const waitForRuns = async () => {
|
||||||
try {
|
for (let i = 0; i < 500; i += 1) {
|
||||||
const autoFinishedP = waitForCronFinished(ws, autoJobId);
|
const runsRes = await rpcReq(ws, "cron.runs", {
|
||||||
await vi.advanceTimersByTimeAsync(1000);
|
id: jobId,
|
||||||
await autoFinishedP;
|
limit: 10,
|
||||||
} finally {
|
});
|
||||||
vi.useRealTimers();
|
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);
|
||||||
|
|
||||||
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();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
await rmTempDir(dir);
|
} finally {
|
||||||
|
testState.cronEnabled = false;
|
||||||
testState.cronStorePath = undefined;
|
testState.cronStorePath = undefined;
|
||||||
testState.cronEnabled = undefined;
|
await rmTempDir(dir);
|
||||||
}
|
}
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { WebSocket } from "ws";
|
import { WebSocket } from "ws";
|
||||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,35 +21,12 @@ import {
|
|||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
import { buildDeviceAuthPayload } from "./device-auth.js";
|
import { buildDeviceAuthPayload } from "./device-auth.js";
|
||||||
|
|
||||||
installGatewayTestHooks({ scope: "suite" });
|
installGatewayTestHooks();
|
||||||
|
|
||||||
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", () => {
|
describe("gateway server health/presence", () => {
|
||||||
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
|
test("connect + health + presence + status succeed", { timeout: 60_000 }, async () => {
|
||||||
const ws = await openClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
|
const healthP = onceMessage(ws, (o) => o.type === "res" && o.id === "health1");
|
||||||
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
|
const statusP = onceMessage(ws, (o) => o.type === "res" && o.id === "status1");
|
||||||
@@ -74,6 +51,7 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(Array.isArray(presence.payload)).toBe(true);
|
expect(Array.isArray(presence.payload)).toBe(true);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
|
test("broadcasts heartbeat events and serves last-heartbeat", async () => {
|
||||||
@@ -98,7 +76,8 @@ describe("gateway server health/presence", () => {
|
|||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ws = await openClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const waitHeartbeat = onceMessage<EventFrame>(
|
const waitHeartbeat = onceMessage<EventFrame>(
|
||||||
ws,
|
ws,
|
||||||
@@ -138,10 +117,12 @@ describe("gateway server health/presence", () => {
|
|||||||
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
|
expect((toggle.payload as { enabled?: boolean } | undefined)?.enabled).toBe(false);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
test("presence events carry seq + stateVersion", { timeout: 8000 }, async () => {
|
||||||
const ws = await openClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
|
const presenceEventP = onceMessage(ws, (o) => o.type === "event" && o.event === "presence");
|
||||||
ws.send(
|
ws.send(
|
||||||
@@ -159,10 +140,12 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
expect(Array.isArray(evt.payload?.presence)).toBe(true);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
test("agent events stream with seq", { timeout: 8000 }, async () => {
|
||||||
const ws = await openClient();
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
const runId = randomUUID();
|
const runId = randomUUID();
|
||||||
const evtPromise = onceMessage(
|
const evtPromise = onceMessage(
|
||||||
@@ -180,6 +163,7 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(evt.payload.data.msg).toBe("hi");
|
expect(evt.payload.data.msg).toBe("hi");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
test("shutdown event is broadcast on close", { timeout: 8000 }, async () => {
|
||||||
@@ -193,7 +177,16 @@ describe("gateway server health/presence", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
test("presence broadcast reaches multiple clients", { timeout: 8000 }, async () => {
|
||||||
const clients = await Promise.all([openClient(), openClient(), openClient()]);
|
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 waits = clients.map((c) =>
|
const waits = clients.map((c) =>
|
||||||
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
|
onceMessage(c, (o) => o.type === "event" && o.event === "presence"),
|
||||||
);
|
);
|
||||||
@@ -211,6 +204,7 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(typeof evt.seq).toBe("number");
|
expect(typeof evt.seq).toBe("number");
|
||||||
}
|
}
|
||||||
for (const c of clients) c.close();
|
for (const c of clients) c.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("presence includes client fingerprint", async () => {
|
test("presence includes client fingerprint", async () => {
|
||||||
@@ -228,7 +222,8 @@ describe("gateway server health/presence", () => {
|
|||||||
signedAtMs,
|
signedAtMs,
|
||||||
token: null,
|
token: null,
|
||||||
});
|
});
|
||||||
const ws = await openClient({
|
const { server, ws } = await startServerWithClient();
|
||||||
|
await connectOk(ws, {
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
client: {
|
client: {
|
||||||
@@ -269,11 +264,13 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("cli connections are not tracked as instances", async () => {
|
test("cli connections are not tracked as instances", async () => {
|
||||||
|
const { server, ws } = await startServerWithClient();
|
||||||
const cliId = `cli-${randomUUID()}`;
|
const cliId = `cli-${randomUUID()}`;
|
||||||
const ws = await openClient({
|
await connectOk(ws, {
|
||||||
client: {
|
client: {
|
||||||
id: GATEWAY_CLIENT_NAMES.CLI,
|
id: GATEWAY_CLIENT_NAMES.CLI,
|
||||||
version: "dev",
|
version: "dev",
|
||||||
@@ -297,5 +294,6 @@ describe("gateway server health/presence", () => {
|
|||||||
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
|
expect(entries.some((e) => e.instanceId === cliId)).toBe(false);
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,21 +1,9 @@
|
|||||||
import { afterAll, beforeAll, test } from "vitest";
|
import { test } from "vitest";
|
||||||
import WebSocket from "ws";
|
import WebSocket from "ws";
|
||||||
|
|
||||||
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
import { PROTOCOL_VERSION } from "./protocol/index.js";
|
||||||
import { getFreePort, onceMessage, startGatewayServer } from "./test-helpers.server.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(
|
function connectReq(
|
||||||
ws: WebSocket,
|
ws: WebSocket,
|
||||||
params: { clientId: string; platform: string; token?: string; password?: string },
|
params: { clientId: string; platform: string; token?: string; password?: string },
|
||||||
@@ -55,6 +43,8 @@ function connectReq(
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
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}`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
@@ -71,9 +61,12 @@ test("accepts clawdbot-ios as a valid gateway client id", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("accepts clawdbot-android as a valid gateway client id", async () => {
|
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}`);
|
const ws = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
await new Promise<void>((resolve) => ws.once("open", resolve));
|
await new Promise<void>((resolve) => ws.once("open", resolve));
|
||||||
|
|
||||||
@@ -90,4 +83,5 @@ test("accepts clawdbot-android as a valid gateway client id", async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ws.close();
|
ws.close();
|
||||||
|
await server.close();
|
||||||
});
|
});
|
||||||
@@ -66,79 +66,71 @@ const connectNodeClient = async (params: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe("gateway node command allowlist", () => {
|
describe("gateway node command allowlist", () => {
|
||||||
test("enforces command allowlists across node clients", async () => {
|
test("rejects commands outside platform allowlist", async () => {
|
||||||
const { server, ws, port } = await startServerWithClient();
|
const { server, ws, port } = await startServerWithClient();
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const waitForConnectedCount = async (count: number) => {
|
const nodeClient = await connectNodeClient({
|
||||||
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 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;
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
const systemClient = await connectNodeClient({
|
|
||||||
port,
|
port,
|
||||||
commands: ["system.run"],
|
commands: ["system.run"],
|
||||||
instanceId: "node-system-run",
|
|
||||||
displayName: "node-system-run",
|
|
||||||
});
|
});
|
||||||
const systemNodeId = await getConnectedNodeId();
|
|
||||||
const disallowedRes = await rpcReq(ws, "node.invoke", {
|
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
|
||||||
nodeId: systemNodeId,
|
const nodeId = listRes.payload?.nodes?.[0]?.nodeId ?? "";
|
||||||
|
expect(nodeId).toBeTruthy();
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "node.invoke", {
|
||||||
|
nodeId,
|
||||||
command: "system.run",
|
command: "system.run",
|
||||||
params: { command: "echo hi" },
|
params: { command: "echo hi" },
|
||||||
idempotencyKey: "allowlist-1",
|
idempotencyKey: "allowlist-1",
|
||||||
});
|
});
|
||||||
expect(disallowedRes.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect(disallowedRes.error?.message).toContain("node command not allowed");
|
expect(res.error?.message).toContain("node command not allowed");
|
||||||
systemClient.stop();
|
|
||||||
await waitForConnectedCount(0);
|
|
||||||
|
|
||||||
const emptyClient = await connectNodeClient({
|
nodeClient.stop();
|
||||||
|
ws.close();
|
||||||
|
await server.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("rejects commands not declared by node", async () => {
|
||||||
|
const { server, ws, port } = await startServerWithClient();
|
||||||
|
await connectOk(ws);
|
||||||
|
|
||||||
|
const nodeClient = await connectNodeClient({
|
||||||
port,
|
port,
|
||||||
commands: [],
|
commands: [],
|
||||||
instanceId: "node-empty",
|
instanceId: "node-empty",
|
||||||
displayName: "node-empty",
|
displayName: "node-empty",
|
||||||
});
|
});
|
||||||
const emptyNodeId = await getConnectedNodeId();
|
|
||||||
const missingRes = await rpcReq(ws, "node.invoke", {
|
const listRes = await rpcReq<{ nodes?: Array<{ nodeId: string }> }>(ws, "node.list", {});
|
||||||
nodeId: emptyNodeId,
|
const nodeId = listRes.payload?.nodes?.find((entry) => entry.nodeId)?.nodeId ?? "";
|
||||||
|
expect(nodeId).toBeTruthy();
|
||||||
|
|
||||||
|
const res = await rpcReq(ws, "node.invoke", {
|
||||||
|
nodeId,
|
||||||
command: "canvas.snapshot",
|
command: "canvas.snapshot",
|
||||||
params: {},
|
params: {},
|
||||||
idempotencyKey: "allowlist-2",
|
idempotencyKey: "allowlist-2",
|
||||||
});
|
});
|
||||||
expect(missingRes.ok).toBe(false);
|
expect(res.ok).toBe(false);
|
||||||
expect(missingRes.error?.message).toContain("node command not allowed");
|
expect(res.error?.message).toContain("node command not allowed");
|
||||||
emptyClient.stop();
|
|
||||||
await waitForConnectedCount(0);
|
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;
|
let resolveInvoke: ((payload: { id?: string; nodeId?: string }) => void) | null = null;
|
||||||
const waitForInvoke = () =>
|
const invokeReqP = new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
||||||
new Promise<{ id?: string; nodeId?: string }>((resolve) => {
|
|
||||||
resolveInvoke = resolve;
|
resolveInvoke = resolve;
|
||||||
});
|
});
|
||||||
const allowedClient = await connectNodeClient({
|
const nodeClient = await connectNodeClient({
|
||||||
port,
|
port,
|
||||||
commands: ["canvas.snapshot"],
|
commands: ["canvas.snapshot"],
|
||||||
instanceId: "node-allowed",
|
instanceId: "node-allowed",
|
||||||
@@ -150,48 +142,85 @@ describe("gateway node command allowlist", () => {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const allowedNodeId = await getConnectedNodeId();
|
|
||||||
|
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", {
|
const invokeResP = rpcReq(ws, "node.invoke", {
|
||||||
nodeId: allowedNodeId,
|
nodeId,
|
||||||
command: "canvas.snapshot",
|
command: "canvas.snapshot",
|
||||||
params: { format: "png" },
|
params: { format: "png" },
|
||||||
idempotencyKey: "allowlist-3",
|
idempotencyKey: "allowlist-3",
|
||||||
});
|
});
|
||||||
const payload = await waitForInvoke();
|
|
||||||
|
const payload = await invokeReqP;
|
||||||
const requestId = payload?.id ?? "";
|
const requestId = payload?.id ?? "";
|
||||||
const nodeIdFromReq = payload?.nodeId ?? "node-allowed";
|
const nodeIdFromReq = payload?.nodeId ?? "node-allowed";
|
||||||
await allowedClient.request("node.invoke.result", {
|
|
||||||
|
await nodeClient.request("node.invoke.result", {
|
||||||
id: requestId,
|
id: requestId,
|
||||||
nodeId: nodeIdFromReq,
|
nodeId: nodeIdFromReq,
|
||||||
ok: true,
|
ok: true,
|
||||||
payloadJSON: JSON.stringify({ ok: true }),
|
payloadJSON: JSON.stringify({ ok: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const invokeRes = await invokeResP;
|
const invokeRes = await invokeResP;
|
||||||
expect(invokeRes.ok).toBe(true);
|
expect(invokeRes.ok).toBe(true);
|
||||||
|
|
||||||
const invokeNullResP = rpcReq(ws, "node.invoke", {
|
nodeClient.stop();
|
||||||
nodeId: allowedNodeId,
|
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",
|
command: "canvas.snapshot",
|
||||||
params: { format: "png" },
|
params: { format: "png" },
|
||||||
idempotencyKey: "allowlist-null-payloadjson",
|
idempotencyKey: "allowlist-null-payloadjson",
|
||||||
});
|
});
|
||||||
const payloadNull = await waitForInvoke();
|
|
||||||
const requestIdNull = payloadNull?.id ?? "";
|
const payload = await invokeReqP;
|
||||||
const nodeIdNull = payloadNull?.nodeId ?? "node-allowed";
|
const requestId = payload?.id ?? "";
|
||||||
await allowedClient.request("node.invoke.result", {
|
const nodeIdFromReq = payload?.nodeId ?? "node-null-payloadjson";
|
||||||
id: requestIdNull,
|
|
||||||
nodeId: nodeIdNull,
|
await nodeClient.request("node.invoke.result", {
|
||||||
|
id: requestId,
|
||||||
|
nodeId: nodeIdFromReq,
|
||||||
ok: true,
|
ok: true,
|
||||||
payloadJSON: null,
|
payloadJSON: null,
|
||||||
});
|
});
|
||||||
const invokeNullRes = await invokeNullResP;
|
|
||||||
expect(invokeNullRes.ok).toBe(true);
|
|
||||||
|
|
||||||
allowedClient.stop();
|
const invokeRes = await invokeResP;
|
||||||
} finally {
|
expect(invokeRes.ok).toBe(true);
|
||||||
|
|
||||||
|
nodeClient.stop();
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user