Compare commits

..
Author SHA1 Message Date
Peter Steinberger 2e089ec9a0 fix: harden compaction status gating (#1466) (thanks @dlauer) 2026-01-23 06:32:17 +00:00
Dave Lauer fd3c76cad3 fix(control-ui): add agentId to DEFAULT_ASSISTANT_IDENTITY
TypeScript inferred the union type without agentId when falling back to
DEFAULT_ASSISTANT_IDENTITY, causing build errors at control-ui.ts:222-223.
2026-01-23 05:52:04 +00:00
Dave Lauer 8b3e3a4b96 feat(ui): add compaction indicator and improve event error handling
Compaction indicator:
- Add CompactionStatus type and handleCompactionEvent() in app-tool-stream.ts
- Show '🧹 Compacting context...' toast while active (with pulse animation)
- Show '🧹 Context compacted' briefly after completion
- Auto-clear toast after 5 seconds
- Add CSS styles for .callout.info, .callout.success, .compaction-indicator

Error handling improvements:
- Wrap onEvent callback in try/catch in gateway.ts to prevent errors
  from breaking the WebSocket message handler
- Wrap handleGatewayEvent in try/catch with console.error logging
  to isolate errors and make them visible in devtools

These changes address UI freezes during heavy agent activity by:
1. Showing users when compaction is happening
2. Preventing uncaught errors from silently breaking the event loop
2026-01-23 05:52:04 +00:00
Dave Lauer e0e39a6d52 feat(compaction): add adaptive chunk sizing and progressive fallback
- Add computeAdaptiveChunkRatio() to reduce chunk size for large messages
- Add isOversizedForSummary() to detect messages too large to summarize
- Add summarizeWithFallback() with progressive fallback:
  - Tries full summarization first
  - Falls back to partial summarization excluding oversized messages
  - Notes oversized messages in the summary output
- Add SAFETY_MARGIN (1.2x) buffer for token estimation inaccuracy
- Reduce MIN_CHUNK_RATIO to 0.15 for very large messages

This prevents compaction failures when conversations contain
unusually large tool outputs or responses that exceed the
summarization model's context window.
2026-01-23 05:52:04 +00:00
354 changed files with 6304 additions and 10388 deletions

No files matched your search

-1
View File
@@ -29,6 +29,5 @@ jobs:
CLAWDBOT_INSTALL_CLI_URL: https://clawd.bot/install-cli.sh
CLAWDBOT_NO_ONBOARD: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_CLI: "1"
CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT: ${{ github.event_name == 'pull_request' && '1' || '0' }}
CLAWDBOT_INSTALL_SMOKE_PREVIOUS: "2026.1.11-4"
run: pnpm test:install:smoke
-14
View File
@@ -22,15 +22,6 @@
- README (GitHub): keep absolute docs URLs (`https://docs.clawd.bot/...`) so links work on GitHub.
- Docs content must be generic: no personal device names/hostnames/paths; use placeholders like `user@gateway-host` and “gateway host”.
## exe.dev VM ops (general)
- Access: SSH to the VM directly: `ssh vm-name.exe.xyz` (or use exe.dev web terminal).
- Updates: `sudo npm i -g clawdbot@latest` (global install needs root on `/usr/lib/node_modules`).
- Config: use `clawdbot config set ...`; set `gateway.mode=local` if unset.
- Restart: exe.dev often lacks systemd user bus; stop old gateway and run:
`pkill -9 -f clawdbot-gateway || true; nohup clawdbot gateway run --bind loopback --port 18789 --force > /tmp/clawdbot-gateway.log 2>&1 &`
- Verify: `clawdbot --version`, `clawdbot health`, `ss -ltnp | rg 18789`.
- SSH flaky: use exe.dev web terminal or Shelley (web agent) instead of CLI SSH.
## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install`
@@ -60,7 +51,6 @@
- Framework: Vitest with V8 coverage thresholds (70% lines/branches/functions/statements).
- Naming: match source names with `*.test.ts`; e2e in `*.e2e.test.ts`.
- Run `pnpm test` (or `pnpm test:coverage`) before pushing when you touch logic.
- Do not set test workers above 16; tried already.
- Live tests (real keys): `CLAWDBOT_LIVE_TEST=1 pnpm test:live` (Clawdbot-only) or `LIVE=1 pnpm test:live` (includes provider live tests). Docker: `pnpm test:docker:live-models`, `pnpm test:docker:live-gateway`. Onboarding Docker E2E: `pnpm test:docker:onboard`.
- Full kit + what’s covered: `docs/testing.md`.
- Pure test additions/fixes generally do **not** need a changelog entry unless they alter user-facing behavior or the user asks for one.
@@ -128,10 +118,6 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
+17 -66
View File
@@ -2,69 +2,35 @@
Docs: https://docs.clawd.bot
## 2026.1.23 (Unreleased)
### Changes
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
### Fixes
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- CLI: hide auth probe timeout warnings from embedded runs.
- CLI: render auth probe results as a table in `clawdbot models status`.
- CLI: suppress probe-only embedded logs unless `--verbose` is set.
- CLI: move auth probe errors below the table to reduce wrapping.
- CLI: prevent ANSI color bleed when table cells wrap.
- CLI: explain when auth profiles are excluded by auth.order in probe details.
- CLI: drop the em dash when the banner tagline wraps to a second line.
- CLI: inline auth probe errors in status rows to reduce wrapping.
- Telegram: render markdown in media captions. (#1478)
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
- Daemon: use platform PATH delimiters when building minimal service paths.
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
## 2026.1.22
### Changes
- Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.
- Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.
- 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.
- Slack: add chat-type reply threading overrides via `replyToModeByChatType`. (#1442) Thanks @stefangalescu.
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- BlueBubbles: add `asVoice` support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
### Breaking
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.
- Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.
- Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.
- Compaction: harden oversized summarization handling and gate UI compaction status by verbose level. (#1466) Thanks @dlauer.
- Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.
- Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.
- Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.
- Gateway: stop the service before uninstalling and fail if it remains loaded.
- Agents: surface concrete API error details instead of generic AI service errors.
- Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)
- 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.
- Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.
@@ -77,11 +43,9 @@ Docs: https://docs.clawd.bot
- Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.
- Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.
- Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.
- Slack: reduce WebClient retries to avoid duplicate sends. (#1481)
- Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.
- Discord: honor accountId across message actions and cron deliveries. (#1492) Thanks @svkozak.
- macOS: prefer linked channels in gateway summary to avoid false “not linked” status.
- macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)
- Providers: improve GitHub Copilot integration (enterprise support, base URL, and auth flow alignment).
## 2026.1.21-2
@@ -92,8 +56,6 @@ Docs: https://docs.clawd.bot
## 2026.1.21
### Changes
- 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
- Heartbeat: allow running heartbeats in an explicit session key. (#1256) Thanks @zknicker.
- CLI: default exec approvals to the local host, add gateway/node targeting flags, and show target details in allowlist output.
- CLI: exec approvals mutations render tables instead of raw JSON.
@@ -103,24 +65,13 @@ Docs: https://docs.clawd.bot
- CLI: flatten node service commands under `clawdbot node` and remove `service node` docs.
- CLI: move gateway service commands under `clawdbot gateway` and add `gateway probe` for reachability.
- Sessions: add per-channel reset overrides via `session.resetByChannel`. (#1353) Thanks @cash-echo-bot.
- Agents: add identity avatar config support and Control UI avatar rendering. (#1329, #1424) Thanks @dlauer.
- UI: show per-session assistant identity in the Control UI. (#1420) Thanks @robbyczgw-cla.
- CLI: add `clawdbot update wizard` for interactive channel selection and restart prompts. https://docs.clawd.bot/cli/update
- Signal: add typing indicators and DM read receipts via signal-cli.
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
- Docs: add /model allowlist troubleshooting note. (#1405)
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
### Breaking
- **BREAKING:** Control UI now rejects insecure HTTP without device identity by default. Use HTTPS (Tailscale Serve) or set `gateway.controlUi.allowInsecureAuth: true` to allow token-only auth. https://docs.clawd.bot/web/control-ui#insecure-http
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
### Fixes
- 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)
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
- Agents: enforce 9-char alphanumeric tool call ids for Mistral providers. (#1372) Thanks @zerone0x.
- 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.
+16 -48
View File
@@ -2,54 +2,6 @@
<rss xmlns:sparkle="http://www.andymatuschak.org/xml-namespaces/sparkle" version="2.0">
<channel>
<title>Clawdbot</title>
<item>
<title>2026.1.22</title>
<pubDate>Fri, 23 Jan 2026 08:58:14 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>7530</sparkle:version>
<sparkle:shortVersionString>2026.1.22</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.22</h2>
<h3>Changes</h3>
<ul>
<li>Highlight: Compaction safeguard now uses adaptive chunking, progressive fallback, and UI status + retries. (#1466) Thanks @dlauer.</li>
<li>Providers: add Antigravity usage tracking to status output. (#1490) Thanks @patelhiren.</li>
<li>Slack: add chat-type reply threading overrides via <code>replyToModeByChatType</code>. (#1442) Thanks @stefangalescu.</li>
<li>BlueBubbles: add <code>asVoice</code> support for MP3/CAF voice memos in sendAttachment. (#1477, #1482) Thanks @Nicell.</li>
<li>Onboarding: add hatch choice (TUI/Web/Later), token explainer, background dashboard seed on macOS, and showcase link.</li>
</ul>
<h3>Fixes</h3>
<ul>
<li>BlueBubbles: stop typing indicator on idle/no-reply. (#1439) Thanks @Nicell.</li>
<li>Message tool: keep path/filePath as-is for send; hydrate buffers only for sendAttachment. (#1444) Thanks @hopyky.</li>
<li>Auto-reply: only report a model switch when session state is available. (#1465) Thanks @robbyczgw-cla.</li>
<li>Control UI: resolve local avatar URLs with basePath across injection + identity RPC. (#1457) Thanks @dlauer.</li>
<li>Agents: sanitize assistant history text to strip tool-call markers. (#1456) Thanks @zerone0x.</li>
<li>Discord: clarify Message Content Intent onboarding hint. (#1487) Thanks @kyleok.</li>
<li>Gateway: stop the service before uninstalling and fail if it remains loaded.</li>
<li>Agents: surface concrete API error details instead of generic AI service errors.</li>
<li>Exec: fall back to non-PTY when PTY spawn fails (EBADF). (#1484)</li>
<li>Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj.</li>
<li>Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider.</li>
<li>Doctor: honor CLAWDBOT_GATEWAY_TOKEN for auth checks and security audit token reuse. (#1448) Thanks @azade-c.</li>
<li>Agents: make tool summaries more readable and only show optional params when set.</li>
<li>Agents: honor SOUL.md guidance even when the file is nested or path-qualified. (#1434) Thanks @neooriginal.</li>
<li>Matrix (plugin): persist m.direct for resolved DMs and harden room fallback. (#1436, #1486) Thanks @sibbl.</li>
<li>CLI: prefer <code>~</code> for home paths in output.</li>
<li>Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic.</li>
<li>Agents: centralize transcript sanitization in the runner; keep <final> tags and error turns intact.</li>
<li>Auth: skip auth profiles in cooldown during initial selection and rotation. (#1316) Thanks @odrobnik.</li>
<li>Agents/TUI: honor user-pinned auth profiles during cooldown and preserve search picker ranking. (#1432) Thanks @tobiasbischoff.</li>
<li>Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x.</li>
<li>Slack: reduce WebClient retries to avoid duplicate sends. (#1481)</li>
<li>Slack: read thread replies for message reads when threadId is provided (replies-only). (#1450) Thanks @rodrigouroz.</li>
<li>macOS: prefer linked channels in gateway summary to avoid false “not linked” status.</li>
<li>macOS/tests: fix gateway summary lookup after guard unwrap; prevent browser opens during tests. (ECID-1483)</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.22/Clawdbot-2026.1.22.zip" length="22302446" type="application/octet-stream" sparkle:edSignature="w/EzfwGBCRRuCg5vz8enIfYujxOZJWRw9PaunQ7gIafKwnBJSTtxcnkvMVwQsnBwB6VN5Tu2MPij7PjDFFX+CA=="/>
</item>
<item>
<title>2026.1.21</title>
<pubDate>Thu, 22 Jan 2026 12:22:35 +0000</pubDate>
@@ -314,5 +266,21 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.21/Clawdbot-2026.1.21.zip" length="12208102" type="application/octet-stream" sparkle:edSignature="hU495Eii8O3qmmUnxYFhXyEGv+qan6KL+GpeuBhPIXf+7B5F/gBh5Oz9cHaqaAPoZ4/3Bo6xgvic0HTkbz6gDw=="/>
</item>
<item>
<title>2026.1.16-2</title>
<pubDate>Sat, 17 Jan 2026 12:46:22 +0000</pubDate>
<link>https://raw.githubusercontent.com/clawdbot/clawdbot/main/appcast.xml</link>
<sparkle:version>6273</sparkle:version>
<sparkle:shortVersionString>2026.1.16-2</sparkle:shortVersionString>
<sparkle:minimumSystemVersion>15.0</sparkle:minimumSystemVersion>
<description><![CDATA[<h2>Clawdbot 2026.1.16-2</h2>
<h3>Changes</h3>
<ul>
<li>CLI: stamp build commit into dist metadata so banners show the commit in npm installs.</li>
</ul>
<p><a href="https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md">View full changelog</a></p>
]]></description>
<enclosure url="https://github.com/clawdbot/clawdbot/releases/download/v2026.1.16-2/Clawdbot-2026.1.16-2.zip" length="21399591" type="application/octet-stream" sparkle:edSignature="zelT+KzN32cXsihbFniPF5Heq0hkwFfL3Agrh/AaoKUkr7kJAFarkGSOZRTWZ9y+DvOluzn2wHHjVigRjMzrBA=="/>
</item>
</channel>
</rss>
@@ -102,9 +102,7 @@ struct DebugSettings: View {
}
}
Text(
"When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). " +
"It will only attach to an existing Gateway.")
Text("When enabled, Clawdbot won't install or manage \(gatewayLaunchdLabel). It will only attach to an existing Gateway.")
.font(.caption)
.foregroundStyle(.secondary)
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -336,7 +295,6 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -421,7 +379,6 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -441,7 +398,6 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -248,11 +248,12 @@ final class GatewayProcessManager {
guard let linkId else {
return "port \(port), health probe succeeded, \(instanceText)"
}
let linked = snap.channels[linkId]?.linked ?? false
let authAge = snap.channels[linkId]?.authAgeMs.flatMap(msToAge) ?? "unknown age"
let linked = linkId.flatMap { snap.channels[$0]?.linked } ?? false
let authAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }.flatMap(msToAge) ?? "unknown age"
let label =
snap.channelLabels?[linkId] ??
linkId.capitalized
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"channel"
let linkText = linked ? "linked" : "not linked"
return "port \(port), \(label) \(linkText), auth \(authAge), \(instanceText)"
}
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.model.entries, id: \.id) { entry in
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(id: UUID) {
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
@@ -21,7 +21,6 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -32,9 +31,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
private struct IndexedWord: Identifiable {
let id: Int
let value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -106,7 +105,6 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -124,10 +122,8 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -140,16 +136,11 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
}
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private var triggerTable: some View {
@@ -163,42 +154,29 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
}
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
}
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -233,12 +211,24 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
self.state.swabbleTriggerWords.append("")
}
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
}
private func toggleTest() {
@@ -648,14 +638,13 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
var view = VoiceWakeSettings(state: state, isActive: true)
let view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -665,9 +654,8 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
}
}
#endif
-7
View File
@@ -700,15 +700,8 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>`
Set `agents.defaults.model.primary`.
-14
View File
@@ -25,26 +25,12 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks
```bash
+4 -5
View File
@@ -1,5 +1,5 @@
---
summary: "CLI reference for `clawdbot update` (safe-ish source update + gateway auto-restart)"
summary: "CLI reference for `clawdbot update` (safe-ish source update + optional gateway restart)"
read_when:
- You want to update a source checkout safely
- You need to understand `--update` shorthand behavior
@@ -20,14 +20,14 @@ clawdbot update wizard
clawdbot update --channel beta
clawdbot update --channel dev
clawdbot update --tag beta
clawdbot update --no-restart
clawdbot update --restart
clawdbot update --json
clawdbot --update
```
## Options
- `--no-restart`: skip restarting the Gateway service after a successful update.
- `--restart`: restart the Gateway service after a successful update.
- `--channel <stable|beta|dev>`: set the update channel (git + npm; persisted in config).
- `--tag <dist-tag|version>`: override the npm dist-tag or version for this update only.
- `--json`: print machine-readable `UpdateRunResult` JSON.
@@ -52,8 +52,7 @@ Options:
## `update wizard`
Interactive flow to pick an update channel and confirm whether to restart the Gateway
after updating (default is to restart). If you select `dev` without a git checkout, it
offers to create one.
after updating. If you select `dev` without a git checkout, it offers to create one.
## What it does
+1 -25
View File
@@ -25,7 +25,6 @@ stay consistent across channels.
1. **Parse Markdown -> IR**
- IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans.
- Offsets are UTF-16 code units so Signal style ranges align with its API.
- Tables are parsed only when a channel opts into table conversion.
2. **Chunk IR (format-first)**
- Chunking happens on the IR text before rendering.
- Inline formatting does not split across chunks; spans are sliced per chunk.
@@ -60,30 +59,7 @@ IR (schematic):
- Slack, Telegram, and Signal outbound adapters render from the IR.
- Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or
their own formatting rules, with Markdown table conversion applied before
chunking when enabled.
## Table handling
Markdown tables are not consistently supported across chat clients. Use
`markdown.tables` to control conversion per channel (and per account).
- `code`: render tables as code blocks (default for most channels).
- `bullets`: convert each row into bullet points (default for Signal + WhatsApp).
- `off`: disable table parsing and conversion; raw table text passes through.
Config keys:
```yaml
channels:
discord:
markdown:
tables: code
accounts:
work:
markdown:
tables: off
```
their own formatting rules.
## Chunking rules
-4
View File
@@ -159,10 +159,6 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
safe to include every 30 minutes.
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
Example `HEARTBEAT.md`:
+2 -3
View File
@@ -7,7 +7,7 @@ read_when:
# Updating
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart (or use `clawdbot update`, which restarts) → verify.
Clawdbot is moving fast (pre “1.0”). Treat updates like shipping infra: update → run checks → restart → verify.
## Recommended: re-run the website installer (upgrade in place)
@@ -81,7 +81,7 @@ Notes:
For **source installs** (git checkout), prefer:
```bash
clawdbot update
clawdbot update --restart
```
It runs a safe-ish update flow:
@@ -89,7 +89,6 @@ It runs a safe-ish update flow:
- Switches to the selected channel (tag or branch).
- Fetches + rebases against the configured upstream (dev channel).
- Installs deps, builds, builds the Control UI, and runs `clawdbot doctor`.
- Restarts the gateway by default (use `--no-restart` to skip).
If you installed via **npm/pnpm** (no git metadata), `clawdbot update` will try to update via your package manager. If it can’t detect the install, use “Update (global install)” instead.
+1 -1
View File
@@ -11,7 +11,7 @@ This app now ships Sparkle auto-updates. Release builds must be Developer ID–s
## Prereqs
- Developer ID Application cert installed (example: `Developer ID Application: <Developer Name> (<TEAMID>)`).
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist). If it is missing, check `~/.profile`.
- Sparkle private key path set in the environment as `SPARKLE_PRIVATE_KEY_FILE` (path to your Sparkle ed25519 private key; public key baked into Info.plist).
- Notary credentials (keychain profile or API key) for `xcrun notarytool` if you want Gatekeeper-safe DMG/zip distribution.
- We use a Keychain profile named `clawdbot-notary`, created from App Store Connect API key env vars in your shell profile:
- `APP_STORE_CONNECT_API_KEY_P8`, `APP_STORE_CONNECT_KEY_ID`, `APP_STORE_CONNECT_ISSUER_ID`
-2
View File
@@ -82,8 +82,6 @@ Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
```
Other config knobs that affect tool availability:
- Allowlists that only name plugin tools are treated as plugin opt-ins; core tools remain
enabled unless you also include core tools or groups in the allowlist.
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
+9 -5
View File
@@ -16,9 +16,9 @@ provider in two different ways.
### 1) Built-in GitHub Copilot provider (`github-copilot`)
Use the native device-login flow to obtain a GitHub token, then exchange it for
Copilot API tokens when Clawdbot runs. This is the **default** and simplest path
because it does not require VS Code.
Use the native device-login flow to obtain a GitHub token and use it directly
against the Copilot API. This is the **default** and simplest path because it
does not require VS Code. Enterprise domains are supported.
### 2) Copilot Proxy plugin (`copilot-proxy`)
@@ -39,6 +39,8 @@ clawdbot models auth login-github-copilot
You'll be prompted to visit a URL and enter a one-time code. Keep the terminal
open until it completes.
If you're on GitHub Enterprise, the login will ask for your enterprise URL or
domain (for example `company.ghe.com`).
### Optional flags
@@ -66,5 +68,7 @@ clawdbot models set github-copilot/gpt-4o
- Requires an interactive TTY; run it directly in a terminal.
- Copilot model availability depends on your plan; if a model is rejected, try
another ID (for example `github-copilot/gpt-4.1`).
- The login stores a GitHub token in the auth profile store and exchanges it for a
Copilot API token when Clawdbot runs.
- The login stores a GitHub token in the auth profile store and uses it directly
for Copilot API calls.
- Base URL: `https://api.githubcopilot.com` (public) or `https://copilot-api.<domain>`
for GitHub Enterprise.
+2 -3
View File
@@ -13,7 +13,7 @@ Use `pnpm` (Node 22+) from the repo root. Keep the working tree clean before tag
## Operator trigger
When the operator says “release”, immediately do this preflight (no extra questions unless blocked):
- Read this doc and `docs/platforms/mac/release.md`.
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set (SPARKLE_PRIVATE_KEY_FILE should live in `~/.profile`).
- Load env from `~/.profile` and confirm `SPARKLE_PRIVATE_KEY_FILE` + App Store Connect vars are set.
- Use Sparkle keys from `~/Library/CloudStorage/Dropbox/Backup/Sparkle` if needed.
1) **Version & metadata**
@@ -39,9 +39,8 @@ When the operator says “release”, immediately do this preflight (no extra qu
- [ ] `pnpm test` (or `pnpm test:coverage` if you need coverage output)
- [ ] `pnpm run build` (last sanity check after tests)
- [ ] `pnpm release:check` (verifies npm pack contents)
- [ ] `CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1 pnpm test:install:smoke` (Docker install smoke test, fast path; required before release)
- [ ] `pnpm test:install:smoke` (Docker install smoke test; required before release)
- If the immediate previous npm release is known broken, set `CLAWDBOT_INSTALL_SMOKE_PREVIOUS=<last-good-version>` or `CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS=1` for the preinstall step.
- [ ] (Optional) Full installer smoke (adds non-root + CLI coverage): `pnpm test:install:smoke`
- [ ] (Optional) Installer E2E (Docker, runs `curl -fsSL https://clawd.bot/install.sh | bash`, onboards, then runs real tool calls):
- `pnpm test:install:e2e:openai` (requires `OPENAI_API_KEY`)
- `pnpm test:install:e2e:anthropic` (requires `ANTHROPIC_API_KEY`)
+1 -2
View File
@@ -5,5 +5,4 @@ read_when:
---
# HEARTBEAT.md
# Keep this file empty (or with only comments) to skip heartbeat API calls.
# Add tasks below when you want the agent to check something periodically.
Keep this file empty unless you want a tiny checklist. Keep it small.
-2
View File
@@ -182,8 +182,6 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
Set `agents.defaults.heartbeat.every: "0m"` to disable.
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
- If the file is missing, the heartbeat still runs and the model decides what to do.
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
- Heartbeats run full agent turns — shorter intervals burn more tokens.
-5
View File
@@ -324,7 +324,6 @@ brew install <formula>
```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later?
@@ -971,10 +970,6 @@ Heartbeats run every **30m** by default. Tune or disable them:
}
```
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
If the file is missing, the heartbeat still runs and the model decides what to do.
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
### Do I need to add a “bot account” to a WhatsApp group?
-2
View File
@@ -54,7 +54,6 @@ Example schema:
"autoAllowSkills": true,
"allowlist": [
{
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
@@ -97,7 +96,6 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp
- **last used command**
- **last resolved path**
-4
View File
@@ -121,10 +121,6 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
You can also allow it globally with `tools.allow` if every agent should see it.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
tools, include the core tools or groups you want in the allowlist too.
## Example: Email triage
Without Lobster:
-2
View File
@@ -88,8 +88,6 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -26,7 +25,6 @@ const bluebubblesGroupConfigSchema = z.object({
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
+36 -44
View File
@@ -99,8 +99,6 @@ function createMockRuntime(): PluginRuntime {
chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"],
resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"],
hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"],
resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"],
convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"],
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"],
@@ -222,12 +220,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
@@ -514,7 +506,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
@@ -562,7 +554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -609,7 +601,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -648,7 +640,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -689,7 +681,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -732,7 +724,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
@@ -773,7 +765,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -810,7 +802,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -850,7 +842,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -888,7 +880,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -927,7 +919,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -966,7 +958,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1007,7 +999,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1048,7 +1040,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1086,7 +1078,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1129,7 +1121,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1175,7 +1167,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1221,7 +1213,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1245,7 +1237,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1291,7 +1283,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1339,7 +1331,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1392,7 +1384,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1435,7 +1427,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1478,7 +1470,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).toHaveBeenCalled();
});
@@ -1519,7 +1511,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
});
@@ -1562,7 +1554,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1612,7 +1604,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1657,7 +1649,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1705,7 +1697,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1750,7 +1742,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
@@ -1790,7 +1782,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
@@ -1830,7 +1822,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
@@ -1868,7 +1860,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"),
@@ -1909,7 +1901,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1949,7 +1941,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -2001,7 +1993,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+3 -15
View File
@@ -1662,15 +1662,9 @@ async function processMessage(
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
const result = await sendBlueBubblesMedia({
cfg: config,
@@ -1692,14 +1686,8 @@ async function processMessage(
account.config.textChunkLimit && account.config.textChunkLimit > 0
? account.config.textChunkLimit
: DEFAULT_TEXT_LIMIT;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "bluebubbles",
accountId: account.accountId,
});
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
if (!chunks.length && text) chunks.push(text);
const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit);
if (!chunks.length && payload.text) chunks.push(payload.text);
if (!chunks.length) return;
for (const chunk of chunks) {
const result = await sendMessageBlueBubbles(outboundTarget, chunk, {
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -36,7 +35,6 @@ const matrixRoomSchema = z
export const MatrixConfigSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
homeserver: z.string().optional(),
userId: z.string().optional(),
accessToken: z.string().optional(),
@@ -548,11 +548,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
}
let didSendReply = false;
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: route.accountId,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
@@ -567,8 +562,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
textLimit,
replyToMode,
threadId: threadTarget,
accountId: route.accountId,
tableMode,
});
didSendReply = true;
},
@@ -1,6 +1,6 @@
import type { MatrixClient } from "matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
import { getMatrixRuntime } from "../../runtime.js";
@@ -12,17 +12,8 @@ export async function deliverMatrixReplies(params: {
textLimit: number;
replyToMode: "off" | "first" | "all";
threadId?: string;
accountId?: string;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const core = getMatrixRuntime();
const tableMode =
params.tableMode ??
core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "matrix",
accountId: params.accountId,
});
const logVerbose = (message: string) => {
if (core.logging.shouldLogVerbose()) {
params.runtime.log?.(message);
@@ -42,8 +33,6 @@ export async function deliverMatrixReplies(params: {
}
const replyToIdRaw = reply.replyToId?.trim();
const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw;
const rawText = reply.text ?? "";
const text = core.channel.text.convertMarkdownTables(rawText, tableMode);
const mediaList = reply.mediaUrls?.length
? reply.mediaUrls
: reply.mediaUrl
@@ -54,14 +43,13 @@ export async function deliverMatrixReplies(params: {
Boolean(id) && (params.replyToMode === "all" || !hasReplied);
if (mediaList.length === 0) {
for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) {
for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) {
const trimmed = chunk.trim();
if (!trimmed) continue;
await sendMessageMatrix(params.roomId, trimmed, {
client: params.client,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -72,14 +60,13 @@ export async function deliverMatrixReplies(params: {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : "";
const caption = first ? (reply.text ?? "") : "";
await sendMessageMatrix(params.roomId, caption, {
client: params.client,
mediaUrl,
replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined,
threadId: params.threadId,
audioAsVoice: reply.audioAsVoice,
accountId: params.accountId,
});
if (shouldIncludeReply(replyToId)) {
hasReplied = true;
@@ -43,8 +43,6 @@ const runtimeStub = {
text: {
resolveTextChunkLimit: () => 4000,
chunkMarkdownText: (text: string) => (text ? [text] : []),
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
+1 -10
View File
@@ -50,18 +50,9 @@ export async function sendMessageMatrix(
try {
const roomId = await resolveMatrixRoomId(client, to);
const cfg = getCore().config.loadConfig();
const tableMode = getCore().channel.text.resolveMarkdownTableMode({
cfg,
channel: "matrix",
accountId: opts.accountId,
});
const convertedMessage = getCore().channel.text.convertMarkdownTables(
trimmedMessage,
tableMode,
);
const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix");
const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT);
const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit);
const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit);
const threadId = normalizeThreadId(opts.threadId);
const relation = threadId
? buildThreadRelation(threadId, opts.replyToId)
@@ -87,7 +87,6 @@ export type MatrixSendResult = {
export type MatrixSendOpts = {
client?: import("matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;
timeoutMs?: number;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.22",
"version": "2026.1.20-2",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
@@ -4,7 +4,6 @@ import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
@@ -12,7 +11,6 @@ const MattermostAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
markdown: MarkdownConfigSchema,
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
@@ -707,11 +707,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
@@ -725,7 +720,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const text = payload.text ?? "";
if (mediaUrls.length === 0) {
const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
+1 -10
View File
@@ -181,15 +181,6 @@ export async function sendMessageMattermost(
}
}
if (message) {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
message = core.channel.text.convertMarkdownTables(message, tableMode);
}
if (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
@@ -214,4 +205,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown",
channelId,
};
}
}
+6 -8
View File
@@ -14,12 +14,11 @@ import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? "test-key";
const HAS_OPENAI_KEY = Boolean(process.env.OPENAI_API_KEY);
const liveEnabled = HAS_OPENAI_KEY && process.env.CLAWDBOT_LIVE_TEST === "1";
const describeLive = liveEnabled ? describe : describe.skip;
// Skip if no OpenAI API key
const OPENAI_API_KEY = process.env.OPENAI_API_KEY;
const describeWithKey = OPENAI_API_KEY ? describe : describe.skip;
describe("memory plugin e2e", () => {
describeWithKey("memory plugin e2e", () => {
let tmpDir: string;
let dbPath: string;
@@ -160,7 +159,7 @@ describe("memory plugin e2e", () => {
});
// Live tests that require OpenAI API key and actually use LanceDB
describeLive("memory plugin live tests", () => {
describeWithKey("memory plugin live tests", () => {
let tmpDir: string;
let dbPath: string;
@@ -177,7 +176,6 @@ describeLive("memory plugin live tests", () => {
test("memory tools work end-to-end", async () => {
const { default: memoryPlugin } = await import("./index.js");
const liveApiKey = process.env.OPENAI_API_KEY ?? "";
// Mock plugin API
const registeredTools: any[] = [];
@@ -193,7 +191,7 @@ describeLive("memory plugin live tests", () => {
config: {},
pluginConfig: {
embedding: {
apiKey: liveApiKey,
apiKey: OPENAI_API_KEY,
model: "text-embedding-3-small",
},
dbPath,
+3 -7
View File
@@ -21,8 +21,6 @@ const runtimeStub = {
}
return chunks;
},
resolveMarkdownTableMode: () => "code",
convertMarkdownTables: (text: string) => text,
},
},
} as unknown as PluginRuntime;
@@ -36,7 +34,6 @@ describe("msteams messenger", () => {
it("filters silent replies", () => {
const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], {
textChunkLimit: 4000,
tableMode: "code",
});
expect(messages).toEqual([]);
});
@@ -44,7 +41,7 @@ describe("msteams messenger", () => {
it("filters silent reply prefixes", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: `${SILENT_REPLY_TOKEN} -- ignored` }],
{ textChunkLimit: 4000, tableMode: "code" },
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([]);
});
@@ -52,7 +49,7 @@ describe("msteams messenger", () => {
it("splits media into separate messages by default", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, tableMode: "code" },
{ textChunkLimit: 4000 },
);
expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]);
});
@@ -60,7 +57,7 @@ describe("msteams messenger", () => {
it("supports inline media mode", () => {
const messages = renderReplyPayloadsToMessages(
[{ text: "hi", mediaUrl: "https://example.com/a.png" }],
{ textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" },
{ textChunkLimit: 4000, mediaMode: "inline" },
);
expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]);
});
@@ -69,7 +66,6 @@ describe("msteams messenger", () => {
const long = "hello ".repeat(200);
const messages = renderReplyPayloadsToMessages([{ text: long }], {
textChunkLimit: 50,
tableMode: "code",
});
expect(messages.length).toBeGreaterThan(1);
});
+1 -12
View File
@@ -1,7 +1,6 @@
import {
isSilentReplyText,
loadWebMedia,
type MarkdownTableMode,
type MSTeamsReplyStyle,
type ReplyPayload,
SILENT_REPLY_TOKEN,
@@ -62,7 +61,6 @@ export type MSTeamsReplyRenderOptions = {
textChunkLimit: number;
chunkText?: boolean;
mediaMode?: "split" | "inline";
tableMode?: MarkdownTableMode;
};
/**
@@ -198,19 +196,10 @@ export function renderReplyPayloadsToMessages(
const chunkLimit = Math.min(options.textChunkLimit, 4000);
const chunkText = options.chunkText !== false;
const mediaMode = options.mediaMode ?? "split";
const tableMode =
options.tableMode ??
getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg: getMSTeamsRuntime().config.loadConfig(),
channel: "msteams",
});
for (const payload of replies) {
const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = getMSTeamsRuntime().channel.text.convertMarkdownTables(
payload.text ?? "",
tableMode,
);
const text = payload.text ?? "";
if (!text && mediaList.length === 0) continue;
@@ -53,15 +53,10 @@ export function createMSTeamsReplyDispatcher(params: {
).responsePrefix,
humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId),
deliver: async (payload) => {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
+7 -16
View File
@@ -16,7 +16,6 @@ import {
import { extractFilename, extractMessageId } from "./media-helpers.js";
import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js";
import { buildMSTeamsPollCard } from "./polls.js";
import { getMSTeamsRuntime } from "./runtime.js";
import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js";
export type SendMSTeamsMessageParams = {
@@ -94,21 +93,13 @@ export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl } = params;
const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "msteams",
});
const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables(
text ?? "",
tableMode,
);
const ctx = await resolveMSTeamsSendContext({ cfg, to });
const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx;
log.debug("sending proactive message", {
conversationId,
conversationType,
textLength: messageText.length,
textLength: text.length,
hasMedia: Boolean(mediaUrl),
});
@@ -143,7 +134,7 @@ export async function sendMessageMSTeams(
const { activity, uploadId } = prepareFileConsentActivity({
media: { buffer: media.buffer, filename: fileName, contentType: media.contentType },
conversationId,
description: messageText || undefined,
description: text || undefined,
});
log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length });
@@ -181,14 +172,14 @@ export async function sendMessageMSTeams(
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
if (isImage && !sharePointSiteId) {
// Group chat/channel without SharePoint: send image inline (avoids OneDrive failures)
const base64 = media.buffer.toString("base64");
const finalMediaUrl = `data:${media.contentType};base64,${base64}`;
return sendTextWithMedia(ctx, messageText, finalMediaUrl);
return sendTextWithMedia(ctx, text, finalMediaUrl);
}
// Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive
@@ -232,7 +223,7 @@ export async function sendMessageMSTeams(
const fileCardAttachment = buildTeamsFileInfoCard(driveItem);
const activity = {
type: "message",
text: messageText || undefined,
text: text || undefined,
attachments: [fileCardAttachment],
};
@@ -273,7 +264,7 @@ export async function sendMessageMSTeams(
const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`;
const activity = {
type: "message",
text: messageText ? `${messageText}\n\n${fileLink}` : fileLink,
text: text ? `${text}\n\n${fileLink}` : fileLink,
};
const baseRef = buildConversationReference(ref);
@@ -299,7 +290,7 @@ export async function sendMessageMSTeams(
}
// No media: send text only
return sendTextWithMedia(ctx, messageText, undefined);
return sendTextWithMedia(ctx, text, undefined);
}
/**
@@ -3,7 +3,6 @@ import {
DmConfigSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "clawdbot/plugin-sdk";
import { z } from "zod";
@@ -22,7 +21,6 @@ export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(),
botSecret: z.string().optional(),
botSecretFile: z.string().optional(),
+1 -11
View File
@@ -71,18 +71,8 @@ export async function sendMessageNextcloudTalk(
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({
cfg,
channel: "nextcloud-talk",
accountId: account.accountId,
});
const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables(
text.trim(),
tableMode,
);
const body: Record<string, unknown> = {
message,
message: text.trim(),
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
+1 -8
View File
@@ -133,20 +133,13 @@ export const nostrPlugin: ChannelPlugin<ResolvedNostrAccount> = {
deliveryMode: "direct",
textChunkLimit: 4000,
sendText: async ({ to, text, accountId }) => {
const core = getNostrRuntime();
const aid = accountId ?? DEFAULT_ACCOUNT_ID;
const bus = activeBuses.get(aid);
if (!bus) {
throw new Error(`Nostr bus not running for account ${aid}`);
}
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: core.config.loadConfig(),
channel: "nostr",
accountId: aid,
});
const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode);
const normalizedTo = normalizePubkey(to);
await bus.sendDm(normalizedTo, message);
await bus.sendDm(normalizedTo, text);
return { channel: "nostr", to: normalizedTo };
},
},
+1 -4
View File
@@ -1,5 +1,5 @@
import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -63,9 +63,6 @@ export const NostrConfigSchema = z.object({
/** Whether this channel is enabled */
enabled: z.boolean().optional(),
/** Markdown formatting overrides (tables). */
markdown: MarkdownConfigSchema,
/** Private key in hex or nsec bech32 format */
privateKey: z.string().optional(),
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@clawdbot/open-prose",
"version": "2026.1.22",
"version": "2026.1.23",
"type": "module",
"description": "OpenProse VM skill pack plugin (slash command + telemetry).",
"clawdbot": {
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -6,7 +5,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
const zaloAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
botToken: z.string().optional(),
tokenFile: z.string().optional(),
webhookUrl: z.string().optional(),
+4 -14
View File
@@ -1,6 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
@@ -578,12 +578,6 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalo",
accountId: account.accountId,
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
@@ -597,7 +591,6 @@ async function processMessageWithPipeline(params: {
core,
statusSink,
fetcher,
tableMode,
});
},
onError: (err, info) => {
@@ -615,11 +608,8 @@ async function deliverZaloReply(params: {
core: ZaloCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -630,7 +620,7 @@ async function deliverZaloReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
@@ -642,8 +632,8 @@ async function deliverZaloReply(params: {
return;
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT);
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
-2
View File
@@ -1,4 +1,3 @@
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
@@ -11,7 +10,6 @@ const groupConfigSchema = z.object({
const zalouserAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
profile: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
+4 -12
View File
@@ -1,6 +1,6 @@
import type { ChildProcess } from "node:child_process";
import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk";
import { sendMessageZalouser } from "./send.js";
import type {
@@ -332,11 +332,6 @@ async function processMessage(
runtime,
core,
statusSink,
tableMode: core.channel.text.resolveMarkdownTableMode({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
}),
});
},
onError: (err, info) => {
@@ -356,11 +351,8 @@ async function deliverZalouserReply(params: {
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
tableMode?: MarkdownTableMode;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const tableMode = params.tableMode ?? "code";
const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode);
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -371,7 +363,7 @@ async function deliverZalouserReply(params: {
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? text : undefined;
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(core, runtime, `Sending media to ${chatId}`);
@@ -388,8 +380,8 @@ async function deliverZalouserReply(params: {
return;
}
if (text) {
const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT);
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
+1 -1
View File
@@ -111,7 +111,7 @@
"format:swift": "swiftformat --lint --config .swiftformat apps/macos/Sources apps/ios/Sources apps/shared/ClawdbotKit/Sources",
"format:all": "pnpm format && pnpm format:swift",
"format:fix": "oxfmt --write src test",
"test": "node scripts/test-parallel.mjs",
"test": "vitest run",
"test:watch": "vitest",
"test:ui": "pnpm --dir ui test",
"test:force": "node --import tsx scripts/test-force.ts",
-2
View File
@@ -376,8 +376,6 @@ importers:
specifier: ^4.3.5
version: 4.3.5
extensions/open-prose: {}
extensions/signal: {}
extensions/slack: {}
+1 -6
View File
@@ -19,12 +19,7 @@ echo "==> Verify git installed"
command -v git >/dev/null
echo "==> Verify clawdbot installed"
EXPECTED_VERSION="${CLAWDBOT_INSTALL_EXPECT_VERSION:-}"
if [[ -n "$EXPECTED_VERSION" ]]; then
LATEST_VERSION="$EXPECTED_VERSION"
else
LATEST_VERSION="$(npm view clawdbot version)"
fi
LATEST_VERSION="$(npm view clawdbot version)"
CMD_PATH="$(command -v clawdbot || true)"
if [[ -z "$CMD_PATH" && -x "$HOME/.npm-global/bin/clawdbot" ]]; then
CMD_PATH="$HOME/.npm-global/bin/clawdbot"
+8 -23
View File
@@ -6,36 +6,21 @@ SMOKE_PREVIOUS_VERSION="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}"
SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}"
echo "==> Resolve npm versions"
LATEST_VERSION="$(npm view clawdbot version)"
if [[ -n "$SMOKE_PREVIOUS_VERSION" ]]; then
LATEST_VERSION="$(npm view clawdbot version)"
PREVIOUS_VERSION="$SMOKE_PREVIOUS_VERSION"
else
VERSIONS_JSON="$(npm view clawdbot versions --json)"
versions_line="$(node - <<'NODE'
const raw = process.env.VERSIONS_JSON || "[]";
let versions;
try {
versions = JSON.parse(raw);
} catch {
versions = raw ? [raw] : [];
}
if (!Array.isArray(versions)) {
versions = [versions];
}
if (versions.length === 0) {
PREVIOUS_VERSION="$(node - <<'NODE'
const { execSync } = require("node:child_process");
const versions = JSON.parse(execSync("npm view clawdbot versions --json", { encoding: "utf8" }));
if (!Array.isArray(versions) || versions.length === 0) {
process.exit(1);
}
const latest = versions[versions.length - 1];
const previous = versions.length >= 2 ? versions[versions.length - 2] : latest;
process.stdout.write(`${latest} ${previous}`);
const previous = versions.length >= 2 ? versions[versions.length - 2] : versions[0];
process.stdout.write(previous);
NODE
)"
LATEST_VERSION="${versions_line%% *}"
PREVIOUS_VERSION="${versions_line#* }"
fi
if [[ -n "${CLAWDBOT_INSTALL_LATEST_OUT:-}" ]]; then
printf "%s" "$LATEST_VERSION" > "$CLAWDBOT_INSTALL_LATEST_OUT"
fi
echo "latest=$LATEST_VERSION previous=$PREVIOUS_VERSION"
+11 -31
View File
@@ -6,9 +6,6 @@ SMOKE_IMAGE="${CLAWDBOT_INSTALL_SMOKE_IMAGE:-clawdbot-install-smoke:local}"
NONROOT_IMAGE="${CLAWDBOT_INSTALL_NONROOT_IMAGE:-clawdbot-install-nonroot:local}"
INSTALL_URL="${CLAWDBOT_INSTALL_URL:-https://clawd.bot/install.sh}"
CLI_INSTALL_URL="${CLAWDBOT_INSTALL_CLI_URL:-https://clawd.bot/install-cli.sh}"
SKIP_NONROOT="${CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT:-0}"
LATEST_DIR="$(mktemp -d)"
LATEST_FILE="${LATEST_DIR}/latest"
echo "==> Build smoke image (upgrade, root): $SMOKE_IMAGE"
docker build \
@@ -18,48 +15,31 @@ docker build \
echo "==> Run installer smoke test (root): $INSTALL_URL"
docker run --rm -t \
-v "${LATEST_DIR}:/out" \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_LATEST_OUT="/out/latest" \
-e CLAWDBOT_INSTALL_SMOKE_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_PREVIOUS:-}" \
-e CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS="${CLAWDBOT_INSTALL_SMOKE_SKIP_PREVIOUS:-0}" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$SMOKE_IMAGE"
LATEST_VERSION=""
if [[ -f "$LATEST_FILE" ]]; then
LATEST_VERSION="$(cat "$LATEST_FILE")"
fi
echo "==> Build non-root image: $NONROOT_IMAGE"
docker build \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
if [[ "$SKIP_NONROOT" == "1" ]]; then
echo "==> Skip non-root installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_NONROOT=1)"
else
echo "==> Build non-root image: $NONROOT_IMAGE"
docker build \
-t "$NONROOT_IMAGE" \
-f "$ROOT_DIR/scripts/docker/install-sh-nonroot/Dockerfile" \
"$ROOT_DIR/scripts/docker/install-sh-nonroot"
echo "==> Run installer non-root test: $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_INSTALL_EXPECT_VERSION="$LATEST_VERSION" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$NONROOT_IMAGE"
fi
echo "==> Run installer non-root test: $INSTALL_URL"
docker run --rm -t \
-e CLAWDBOT_INSTALL_URL="$INSTALL_URL" \
-e CLAWDBOT_NO_ONBOARD=1 \
-e DEBIAN_FRONTEND=noninteractive \
"$NONROOT_IMAGE"
if [[ "${CLAWDBOT_INSTALL_SMOKE_SKIP_CLI:-0}" == "1" ]]; then
echo "==> Skip CLI installer smoke (CLAWDBOT_INSTALL_SMOKE_SKIP_CLI=1)"
exit 0
fi
if [[ "$SKIP_NONROOT" == "1" ]]; then
echo "==> Skip CLI installer smoke (non-root image skipped)"
exit 0
fi
echo "==> Run CLI installer non-root test (same image)"
docker run --rm -t \
--entrypoint /bin/bash \
-70
View File
@@ -1,70 +0,0 @@
import { spawn } from "node:child_process";
import os from "node:os";
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
const runs = [
{
name: "unit",
args: ["vitest", "run", "--config", "vitest.unit.config.ts"],
},
{
name: "extensions",
args: ["vitest", "run", "--config", "vitest.extensions.config.ts"],
},
{
name: "gateway",
args: ["vitest", "run", "--config", "vitest.gateway.config.ts"],
},
];
const parallelRuns = runs.filter((entry) => entry.name !== "gateway");
const serialRuns = runs.filter((entry) => entry.name === "gateway");
const children = new Set();
const isCI = process.env.CI === "true" || process.env.GITHUB_ACTIONS === "true";
const overrideWorkers = Number.parseInt(process.env.CLAWDBOT_TEST_WORKERS ?? "", 10);
const resolvedOverride = Number.isFinite(overrideWorkers) && overrideWorkers > 0 ? overrideWorkers : null;
const localWorkers = Math.max(4, Math.min(16, os.cpus().length));
const perRunWorkers = Math.max(1, Math.floor(localWorkers / parallelRuns.length));
const maxWorkers = isCI ? null : resolvedOverride ?? perRunWorkers;
const run = (entry) =>
new Promise((resolve) => {
const args = maxWorkers ? [...entry.args, "--maxWorkers", String(maxWorkers)] : entry.args;
const child = spawn(pnpm, args, {
stdio: "inherit",
env: { ...process.env, VITEST_GROUP: entry.name },
shell: process.platform === "win32",
});
children.add(child);
child.on("exit", (code, signal) => {
children.delete(child);
resolve(code ?? (signal ? 1 : 0));
});
});
const shutdown = (signal) => {
for (const child of children) {
child.kill(signal);
}
};
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
const parallelCodes = await Promise.all(parallelRuns.map(run));
const failedParallel = parallelCodes.find((code) => code !== 0);
if (failedParallel !== undefined) {
process.exit(failedParallel);
}
for (const entry of serialRuns) {
// eslint-disable-next-line no-await-in-loop
const code = await run(entry);
if (code !== 0) {
process.exit(code);
}
}
process.exit(0);
+70
View File
@@ -0,0 +1,70 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
type AuthProfileStore,
ensureAuthProfileStore,
resolveApiKeyForProfile,
} from "./auth-profiles.js";
vi.mock("@mariozechner/pi-ai", () => ({
getOAuthApiKey: vi.fn(() => {
throw new Error("refresh should not be called");
}),
}));
describe("auth-profiles (github-copilot)", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tempDir: string | null = null;
afterEach(async () => {
vi.unstubAllGlobals();
if (tempDir) {
await fs.rm(tempDir, { recursive: true, force: true });
tempDir = null;
}
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
});
it("treats copilot oauth tokens with expires=0 as non-expiring", async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-copilot-"));
process.env.CLAWDBOT_STATE_DIR = tempDir;
process.env.CLAWDBOT_AGENT_DIR = path.join(tempDir, "agents", "main", "agent");
process.env.PI_CODING_AGENT_DIR = process.env.CLAWDBOT_AGENT_DIR;
const authProfilePath = path.join(tempDir, "agents", "main", "agent", "auth-profiles.json");
await fs.mkdir(path.dirname(authProfilePath), { recursive: true });
const store: AuthProfileStore = {
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
};
await fs.writeFile(authProfilePath, `${JSON.stringify(store)}\n`);
const loaded = ensureAuthProfileStore();
const resolved = await resolveApiKeyForProfile({
store: loaded,
profileId: "github-copilot:github",
});
expect(resolved?.apiKey).toBe("gh-token");
});
});
+23
View File
@@ -39,6 +39,15 @@ async function refreshOAuthTokenWithLock(params: {
const cred = store.profiles[params.profileId];
if (!cred || cred.type !== "oauth") return null;
if (
cred.provider === "github-copilot" &&
(!Number.isFinite(cred.expires) || cred.expires <= 0)
) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
newCredentials: cred,
};
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
@@ -103,6 +112,20 @@ async function tryResolveOAuthProfile(params: {
if (profileConfig && profileConfig.provider !== cred.provider) return null;
if (profileConfig && profileConfig.mode !== cred.type) return null;
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (cred.provider === "github-copilot" && (!Number.isFinite(cred.expires) || cred.expires <= 0)) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
provider: cred.provider,
email: cred.email,
};
}
if (Date.now() < cred.expires) {
return {
apiKey: buildOAuthApiKey(cred.provider, cred),
+1
View File
@@ -19,6 +19,7 @@ export type TokenCredential = {
token: string;
/** Optional expiry timestamp (ms since epoch). */
expires?: number;
enterpriseUrl?: string;
email?: string;
};
@@ -1,31 +0,0 @@
import { afterEach, expect, test, vi } from "vitest";
import { resetProcessRegistryForTests } from "./bash-process-registry";
afterEach(() => {
resetProcessRegistryForTests();
vi.resetModules();
vi.clearAllMocks();
});
test("exec falls back when PTY spawn fails", async () => {
vi.doMock("@lydell/node-pty", () => ({
spawn: () => {
const err = new Error("spawn EBADF");
(err as NodeJS.ErrnoException).code = "EBADF";
throw err;
},
}));
const { createExecTool } = await import("./bash-tools.exec");
const tool = createExecTool({ allowBackground: false });
const result = await tool.execute("toolcall", {
command: "printf ok",
pty: true,
});
expect(result.details.status).toBe("completed");
const text = result.content?.[0]?.text ?? "";
expect(text).toContain("ok");
expect(text).toContain("PTY spawn failed");
});
+38 -53
View File
@@ -26,7 +26,7 @@ import {
resolveShellEnvFallbackTimeoutMs,
} from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js";
import { logInfo } from "../logger.js";
import {
type ProcessSession,
type SessionStdin,
@@ -381,56 +381,41 @@ async function runExecProcess(opts: {
) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
} else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig();
try {
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} catch (err) {
const errText = String(err);
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning);
child = spawn(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
}) as ChildProcessWithoutNullStreams;
stdin = child.stdin;
const ptyModule = (await import("@lydell/node-pty")) as unknown as {
spawn?: PtySpawn;
default?: { spawn?: PtySpawn };
};
const spawnPty = ptyModule.spawn ?? ptyModule.default?.spawn;
if (!spawnPty) {
throw new Error("PTY support is unavailable (node-pty spawn not found).");
}
const { shell, args: shellArgs } = getShellConfig();
pty = spawnPty(shell, [...shellArgs, opts.command], {
cwd: opts.workdir,
env: opts.env,
name: process.env.TERM ?? "xterm-256color",
cols: 120,
rows: 30,
});
stdin = {
destroyed: false,
write: (data, cb) => {
try {
pty?.write(data);
cb?.(null);
} catch (err) {
cb?.(err as Error);
}
},
end: () => {
try {
const eof = process.platform === "win32" ? "\x1a" : "\x04";
pty?.write(eof);
} catch {
// ignore EOF errors
}
},
};
} else {
const { shell, args: shellArgs } = getShellConfig();
child = spawn(shell, [...shellArgs, opts.command], {
@@ -1335,7 +1320,7 @@ export function createExecTool(
const effectiveTimeout =
typeof params.timeout === "number" ? params.timeout : defaultTimeoutSec;
const getWarningText = () => (warnings.length ? `${warnings.join("\n")}\n\n` : "");
const warningText = warnings.length ? `${warnings.join("\n")}\n\n` : "";
const usePty = params.pty === true && !sandbox;
const run = await runExecProcess({
command: params.command,
@@ -1375,7 +1360,7 @@ export function createExecTool(
{
type: "text",
text:
`${getWarningText()}` +
`${warningText}` +
`Command still running (session ${run.session.id}, pid ${
run.session.pid ?? "n/a"
}). ` +
@@ -1425,7 +1410,7 @@ export function createExecTool(
content: [
{
type: "text",
text: `${getWarningText()}${outcome.aggregated || "(no output)"}`,
text: `${warningText}${outcome.aggregated || "(no output)"}`,
},
],
details: {
-1
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
-1
View File
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => {
-1
View File
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("nodes camera_snap", () => {
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
formatUsageSummaryLine: () => null,
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
+3 -1
View File
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -0,0 +1,235 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => configOverride,
resolveGatewayPort: () => 18789,
};
});
import { emitAgentEvent } from "../infra/agent-events.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
describe("clawdbot-tools: subagents", () => {
beforeEach(() => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
};
});
it("sessions_spawn runs cleanup via lifecycle events", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as {
message?: string;
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
// Only capture the first agent call (subagent spawn, not main agent trigger)
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
// Return "ok" with timing info for the child run
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined;
deletedKey = params?.key;
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call1", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "delete",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1234,
endedAt: 2345,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
// Two agent calls: subagent spawn + main agent trigger
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
// First call: subagent spawn
const first = agentCalls[0]?.params as
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
// Second call: main agent trigger with announce message
const second = agentCalls[1]?.params as
| {
sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
expect(second?.message).toContain("background task");
// No direct send to external channel (main agent handles delivery)
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
// Session should be deleted since cleanup=delete
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn announces with requester accountId", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1000,
endedAt: 2000,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(true);
expect(announceParams?.channel).toBe("whatsapp");
expect(announceParams?.accountId).toBe("kev");
});
});
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,8 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -122,195 +120,4 @@ describe("clawdbot-tools: subagents", () => {
});
expect(callGatewayMock).not.toHaveBeenCalled();
});
it("sessions_spawn runs cleanup via lifecycle events", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let deletedKey: string | undefined;
let childRunId: string | undefined;
let childSessionKey: string | undefined;
const waitCalls: Array<{ runId?: string; timeoutMs?: number }> = [];
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as {
message?: string;
sessionKey?: string;
channel?: string;
timeout?: number;
lane?: string;
};
if (params?.lane === "subagent") {
childRunId = runId;
childSessionKey = params?.sessionKey ?? "";
expect(params?.channel).toBe("discord");
expect(params?.timeout).toBe(1);
}
return {
runId,
status: "accepted",
acceptedAt: 1000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
waitCalls.push(params ?? {});
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete") {
const params = request.params as { key?: string } | undefined;
deletedKey = params?.key;
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "discord:group:req",
agentChannel: "discord",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call1", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "delete",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1234,
endedAt: 2345,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const childWait = waitCalls.find((call) => call.runId === childRunId);
expect(childWait?.timeoutMs).toBe(1000);
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const first = agentCalls[0]?.params as
| {
lane?: string;
deliver?: boolean;
sessionKey?: string;
channel?: string;
}
| undefined;
expect(first?.lane).toBe("subagent");
expect(first?.deliver).toBe(false);
expect(first?.channel).toBe("discord");
expect(first?.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
expect(childSessionKey?.startsWith("agent:main:subagent:")).toBe(true);
const second = agentCalls[1]?.params as
| {
sessionKey?: string;
message?: string;
deliver?: boolean;
}
| undefined;
expect(second?.sessionKey).toBe("discord:group:req");
expect(second?.deliver).toBe(true);
expect(second?.message).toContain("background task");
const sendCalls = calls.filter((c) => c.method === "send");
expect(sendCalls.length).toBe(0);
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
});
it("sessions_spawn announces with requester accountId", async () => {
resetSubagentRegistryForTests();
callGatewayMock.mockReset();
const calls: Array<{ method?: string; params?: unknown }> = [];
let agentCallCount = 0;
let childRunId: string | undefined;
callGatewayMock.mockImplementation(async (opts: unknown) => {
const request = opts as { method?: string; params?: unknown };
calls.push(request);
if (request.method === "agent") {
agentCallCount += 1;
const runId = `run-${agentCallCount}`;
const params = request.params as { lane?: string; sessionKey?: string } | undefined;
if (params?.lane === "subagent") {
childRunId = runId;
}
return {
runId,
status: "accepted",
acceptedAt: 4000 + agentCallCount,
};
}
if (request.method === "agent.wait") {
const params = request.params as { runId?: string; timeoutMs?: number } | undefined;
return { runId: params?.runId ?? "run-1", status: "ok", startedAt: 1000, endedAt: 2000 };
}
if (request.method === "sessions.delete" || request.method === "sessions.patch") {
return { ok: true };
}
return {};
});
const tool = createClawdbotTools({
agentSessionKey: "main",
agentChannel: "whatsapp",
agentAccountId: "kev",
}).find((candidate) => candidate.name === "sessions_spawn");
if (!tool) throw new Error("missing sessions_spawn tool");
const result = await tool.execute("call2", {
task: "do thing",
runTimeoutSeconds: 1,
cleanup: "keep",
});
expect(result.details).toMatchObject({
status: "accepted",
runId: "run-1",
});
if (!childRunId) throw new Error("missing child runId");
emitAgentEvent({
runId: childRunId,
stream: "lifecycle",
data: {
phase: "end",
startedAt: 1000,
endedAt: 2000,
},
});
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
const agentCalls = calls.filter((call) => call.method === "agent");
expect(agentCalls).toHaveLength(2);
const announceParams = agentCalls[1]?.params as
| { accountId?: string; channel?: string; deliver?: boolean }
| undefined;
expect(announceParams?.deliver).toBe(true);
expect(announceParams?.channel).toBe("whatsapp");
expect(announceParams?.accountId).toBe("kev");
});
});
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
-4
View File
@@ -66,10 +66,6 @@ export function isModernModelRef(ref: ModelRef): boolean {
return matchesPrefix(id, XAI_PREFIXES);
}
if (provider === "opencode" && id.endsWith("-free")) {
return false;
}
if (provider === "openrouter" || provider === "opencode") {
return matchesAny(id, [
...ANTHROPIC_PREFIXES,
@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -51,16 +52,6 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const agentDir = path.join(home, "agent-default-base-url");
@@ -71,48 +62,55 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string; models?: unknown[] }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
expect(parsed.providers["github-copilot"]?.models?.length ?? 0).toBe(0);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("prefers COPILOT_GITHUB_TOKEN over GH_TOKEN and GITHUB_TOKEN", async () => {
it("uses enterprise URL from auth profiles to derive base URL", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
const previousGithub = process.env.GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "copilot-token";
process.env.GH_TOKEN = "gh-token";
process.env.GITHUB_TOKEN = "github-token";
try {
vi.resetModules();
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const agentDir = path.join(process.env.HOME ?? home, "agent-enterprise");
await fs.mkdir(agentDir, { recursive: true });
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
JSON.stringify(
{
version: 1,
profiles: {
"github-copilot:github": {
type: "oauth",
provider: "github-copilot",
refresh: "gh-token",
access: "gh-token",
expires: 0,
enterpriseUrl: "company.ghe.com",
},
},
},
null,
2,
),
);
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } });
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "copilot-token" }),
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
process.env.GH_TOKEN = previousGh;
process.env.GITHUB_TOKEN = previousGithub;
// no-op
}
});
});
@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -43,7 +44,7 @@ describe("models-config", () => {
process.env.HOME = previousHome;
});
it("falls back to default baseUrl when token exchange fails", async () => {
it("uses default baseUrl when env token is present", async () => {
await withTempHome(async () => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
process.env.COPILOT_GITHUB_TOKEN = "gh-token";
@@ -51,11 +52,6 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.default.test",
resolveCopilotApiToken: vi.fn().mockRejectedValue(new Error("boom")),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
@@ -67,13 +63,13 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.default.test");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
} finally {
process.env.COPILOT_GITHUB_TOKEN = previous;
}
});
});
it("uses agentDir override auth profiles for copilot injection", async () => {
it("normalizes enterprise URL when deriving base URL", async () => {
await withTempHome(async (home) => {
const previous = process.env.COPILOT_GITHUB_TOKEN;
const previousGh = process.env.GH_TOKEN;
@@ -94,9 +90,12 @@ describe("models-config", () => {
version: 1,
profiles: {
"github-copilot:github": {
type: "token",
type: "oauth",
provider: "github-copilot",
token: "gh-profile-token",
refresh: "gh-profile-token",
access: "gh-profile-token",
expires: 0,
enterpriseUrl: "https://company.ghe.com/",
},
},
},
@@ -105,16 +104,6 @@ describe("models-config", () => {
),
);
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
@@ -124,7 +113,9 @@ describe("models-config", () => {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe("https://api.copilot.example");
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(
"https://copilot-api.company.ghe.com",
);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
+9 -20
View File
@@ -1,8 +1,8 @@
import type { ClawdbotConfig } from "../config/config.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
normalizeGithubCopilotDomain,
resolveGithubCopilotBaseUrl,
} from "../providers/github-copilot-utils.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import {
@@ -331,29 +331,18 @@ export async function resolveImplicitCopilotProvider(params: {
if (!hasProfile && !githubToken) return null;
let selectedGithubToken = githubToken;
if (!selectedGithubToken && hasProfile) {
let enterpriseDomain: string | null = null;
if (hasProfile) {
// Use the first available profile as a default for discovery (it will be
// re-resolved per-run by the embedded runner).
const profileId = listProfilesForProvider(authStore, "github-copilot")[0];
const profile = profileId ? authStore.profiles[profileId] : undefined;
if (profile && profile.type === "token") {
selectedGithubToken = profile.token;
if (profile && "enterpriseUrl" in profile && typeof profile.enterpriseUrl === "string") {
enterpriseDomain = normalizeGithubCopilotDomain(profile.enterpriseUrl);
}
}
let baseUrl = DEFAULT_COPILOT_API_BASE_URL;
if (selectedGithubToken) {
try {
const token = await resolveCopilotApiToken({
githubToken: selectedGithubToken,
env,
});
baseUrl = token.baseUrl;
} catch {
baseUrl = DEFAULT_COPILOT_API_BASE_URL;
}
}
const baseUrl = resolveGithubCopilotBaseUrl(enterpriseDomain);
// pi-coding-agent's ModelRegistry marks a model "available" only if its
// `AuthStorage` has auth configured for that provider (via auth.json/env/etc).
@@ -364,7 +353,7 @@ export async function resolveImplicitCopilotProvider(params: {
// GitHub token (not the exchanged Copilot token), and (3) matches existing
// patterns for OAuth-like providers in pi-coding-agent.
// Note: we deliberately do not write pi-coding-agent's `auth.json` here.
// Clawdbot uses its own auth store and exchanges tokens at runtime.
// Clawdbot uses its own auth store and passes the GitHub token at runtime.
// `models list` uses Clawdbot's auth heuristics for availability.
// We intentionally do NOT define custom models for Copilot in models.json.
@@ -3,6 +3,7 @@ import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { ClawdbotConfig } from "../config/config.js";
import { DEFAULT_GITHUB_COPILOT_BASE_URL } from "../providers/github-copilot-utils.js";
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "clawdbot-models-" });
@@ -80,25 +81,16 @@ describe("models-config", () => {
),
);
const resolveCopilotApiToken = vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
});
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken,
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
await ensureClawdbotModelsJson({ models: { providers: {} } }, agentDir);
expect(resolveCopilotApiToken).toHaveBeenCalledWith(
expect.objectContaining({ githubToken: "alpha-token" }),
);
const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8");
const parsed = JSON.parse(raw) as {
providers: Record<string, { baseUrl?: string }>;
};
expect(parsed.providers["github-copilot"]?.baseUrl).toBe(DEFAULT_GITHUB_COPILOT_BASE_URL);
} finally {
if (previous === undefined) delete process.env.COPILOT_GITHUB_TOKEN;
else process.env.COPILOT_GITHUB_TOKEN = previous;
@@ -117,16 +109,6 @@ describe("models-config", () => {
try {
vi.resetModules();
vi.doMock("../providers/github-copilot-token.js", () => ({
DEFAULT_COPILOT_API_BASE_URL: "https://api.individual.githubcopilot.com",
resolveCopilotApiToken: vi.fn().mockResolvedValue({
token: "copilot",
expiresAt: Date.now() + 60 * 60 * 1000,
source: "mock",
baseUrl: "https://api.copilot.example",
}),
}));
const { ensureClawdbotModelsJson } = await import("./models-config.js");
const { resolveClawdbotAgentDir } = await import("./agent-paths.js");
+8 -5
View File
@@ -41,11 +41,12 @@ describe("resolveOpencodeZenAlias", () => {
describe("resolveOpencodeZenModelApi", () => {
it("maps APIs by model family", () => {
expect(resolveOpencodeZenModelApi("claude-opus-4-5")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
});
});
@@ -54,10 +55,10 @@ describe("getOpencodeZenStaticFallbackModels", () => {
it("returns an array of models", () => {
const models = getOpencodeZenStaticFallbackModels();
expect(Array.isArray(models)).toBe(true);
expect(models.length).toBe(10);
expect(models.length).toBe(11);
});
it("includes Claude, GPT, Gemini, and GLM models", () => {
it("includes Claude, GPT, Gemini, GLM, and MiniMax models", () => {
const models = getOpencodeZenStaticFallbackModels();
const ids = models.map((m) => m.id);
@@ -65,7 +66,8 @@ describe("getOpencodeZenStaticFallbackModels", () => {
expect(ids).toContain("gpt-5.2");
expect(ids).toContain("gpt-5.1-codex");
expect(ids).toContain("gemini-3-pro");
expect(ids).toContain("glm-4.7");
expect(ids).toContain("glm-4.7-free");
expect(ids).toContain("minimax-m2.1-free");
});
it("returns valid ModelDefinitionConfig objects", () => {
@@ -88,7 +90,8 @@ describe("OPENCODE_ZEN_MODEL_ALIASES", () => {
expect(OPENCODE_ZEN_MODEL_ALIASES.codex).toBe("gpt-5.1-codex");
expect(OPENCODE_ZEN_MODEL_ALIASES.gpt5).toBe("gpt-5.2");
expect(OPENCODE_ZEN_MODEL_ALIASES.gemini).toBe("gemini-3-pro");
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7");
expect(OPENCODE_ZEN_MODEL_ALIASES.glm).toBe("glm-4.7-free");
expect(OPENCODE_ZEN_MODEL_ALIASES.minimax).toBe("minimax-m2.1-free");
// Legacy aliases (kept for backward compatibility).
expect(OPENCODE_ZEN_MODEL_ALIASES.sonnet).toBe("claude-opus-4-5");
+16 -7
View File
@@ -68,9 +68,13 @@ export const OPENCODE_ZEN_MODEL_ALIASES: Record<string, string> = {
"gemini-2.5-flash": "gemini-3-flash",
// GLM (free + alpha)
glm: "glm-4.7",
"glm-free": "glm-4.7",
glm: "glm-4.7-free",
"glm-free": "glm-4.7-free",
"alpha-glm": "alpha-glm-4.7",
// MiniMax
minimax: "minimax-m2.1-free",
"minimax-free": "minimax-m2.1-free",
};
/**
@@ -130,7 +134,7 @@ const MODEL_COSTS: Record<
cacheWrite: 0,
},
"gpt-5.1": { input: 1.07, output: 8.5, cacheRead: 0.107, cacheWrite: 0 },
"glm-4.7": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"glm-4.7-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"gemini-3-flash": { input: 0.5, output: 3, cacheRead: 0.05, cacheWrite: 0 },
"gpt-5.1-codex-max": {
input: 1.25,
@@ -138,6 +142,7 @@ const MODEL_COSTS: Record<
cacheRead: 0.125,
cacheWrite: 0,
},
"minimax-m2.1-free": { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
"gpt-5.2": { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
};
@@ -150,9 +155,10 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
"alpha-glm-4.7": 204800,
"gpt-5.1-codex-mini": 400000,
"gpt-5.1": 400000,
"glm-4.7": 204800,
"glm-4.7-free": 204800,
"gemini-3-flash": 1048576,
"gpt-5.1-codex-max": 400000,
"minimax-m2.1-free": 204800,
"gpt-5.2": 400000,
};
@@ -167,9 +173,10 @@ const MODEL_MAX_TOKENS: Record<string, number> = {
"alpha-glm-4.7": 131072,
"gpt-5.1-codex-mini": 128000,
"gpt-5.1": 128000,
"glm-4.7": 131072,
"glm-4.7-free": 131072,
"gemini-3-flash": 65536,
"gpt-5.1-codex-max": 128000,
"minimax-m2.1-free": 131072,
"gpt-5.2": 128000,
};
@@ -204,9 +211,10 @@ const MODEL_NAMES: Record<string, string> = {
"alpha-glm-4.7": "Alpha GLM-4.7",
"gpt-5.1-codex-mini": "GPT-5.1 Codex Mini",
"gpt-5.1": "GPT-5.1",
"glm-4.7": "GLM-4.7",
"glm-4.7-free": "GLM-4.7",
"gemini-3-flash": "Gemini 3 Flash",
"gpt-5.1-codex-max": "GPT-5.1 Codex Max",
"minimax-m2.1-free": "MiniMax M2.1",
"gpt-5.2": "GPT-5.2",
};
@@ -232,9 +240,10 @@ export function getOpencodeZenStaticFallbackModels(): ModelDefinitionConfig[] {
"alpha-glm-4.7",
"gpt-5.1-codex-mini",
"gpt-5.1",
"glm-4.7",
"glm-4.7-free",
"gemini-3-flash",
"gpt-5.1-codex-max",
"minimax-m2.1-free",
"gpt-5.2",
];
@@ -3,7 +3,7 @@ import os from "node:os";
import path from "node:path";
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { EmbeddedRunAttemptResult } from "./pi-embedded-runner/run/types.js";
@@ -16,13 +16,11 @@ vi.mock("./pi-embedded-runner/run/attempt.js", () => ({
let runEmbeddedPiAgent: typeof import("./pi-embedded-runner.js").runEmbeddedPiAgent;
beforeAll(async () => {
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
});
beforeEach(() => {
beforeEach(async () => {
vi.useRealTimers();
vi.resetModules();
runEmbeddedAttemptMock.mockReset();
({ runEmbeddedPiAgent } = await import("./pi-embedded-runner.js"));
});
const baseUsage = {
-7
View File
@@ -128,13 +128,6 @@ export async function compactEmbeddedPiSession(params: {
`No API key resolved for provider "${model.provider}" (auth mode: ${apiKeyInfo.mode}).`,
);
}
} else if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
+18 -3
View File
@@ -7,9 +7,20 @@ import { resolveClawdbotAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { normalizeProviderId } from "../model-selection.js";
import { resolveGithubCopilotUserAgent } from "../../providers/github-copilot-utils.js";
type InlineModelEntry = ModelDefinitionConfig & { provider: string };
function applyProviderModelOverrides(model: Model<Api>): Model<Api> {
if (model.provider === "github-copilot") {
const headers = model.headers
? { ...model.headers, "User-Agent": resolveGithubCopilotUserAgent() }
: { "User-Agent": resolveGithubCopilotUserAgent() };
return { ...model, headers };
}
return model;
}
export function buildInlineProviderModels(
providers: Record<string, { models?: ModelDefinitionConfig[] }>,
): InlineModelEntry[] {
@@ -60,7 +71,7 @@ export function resolveModel(
if (inlineMatch) {
const normalized = normalizeModelCompat(inlineMatch as Model<Api>);
return {
model: normalized,
model: applyProviderModelOverrides(normalized),
authStorage,
modelRegistry,
};
@@ -78,7 +89,7 @@ export function resolveModel(
contextWindow: providerCfg?.models?.[0]?.contextWindow ?? DEFAULT_CONTEXT_TOKENS,
maxTokens: providerCfg?.models?.[0]?.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
return { model: fallbackModel, authStorage, modelRegistry };
return { model: applyProviderModelOverrides(fallbackModel), authStorage, modelRegistry };
}
return {
error: `Unknown model: ${provider}/${modelId}`,
@@ -86,5 +97,9 @@ export function resolveModel(
modelRegistry,
};
}
return { model: normalizeModelCompat(model), authStorage, modelRegistry };
return {
model: applyProviderModelOverrides(normalizeModelCompat(model)),
authStorage,
modelRegistry,
};
}
+2 -11
View File
@@ -184,17 +184,8 @@ export async function runEmbeddedPiAgent(
lastProfileId = resolvedProfileId;
return;
}
if (model.provider === "github-copilot") {
const { resolveCopilotApiToken } =
await import("../../providers/github-copilot-token.js");
const copilotToken = await resolveCopilotApiToken({
githubToken: apiKeyInfo.apiKey,
});
authStorage.setRuntimeApiKey(model.provider, copilotToken.token);
} else {
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
lastProfileId = apiKeyInfo.profileId;
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
lastProfileId = resolvedProfileId;
};
const advanceAuthProfile = async (): Promise<boolean> => {
+2 -6
View File
@@ -109,18 +109,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
notifyEmbeddedRunEnded(sessionId);
} else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
+3 -3
View File
@@ -9,7 +9,7 @@ import { formatToolDetail, resolveToolDisplay } from "./tool-display.js";
* - <invoke name="...">...</invoke> blocks
* - </minimax:tool_call> closing tags
*/
export function stripMinimaxToolCallXml(text: string): string {
function stripMinimaxToolCallXml(text: string): string {
if (!text) return text;
if (!/minimax:tool_call/i.test(text)) return text;
@@ -28,7 +28,7 @@ export function stripMinimaxToolCallXml(text: string): string {
* downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should
* not be shown to users.
*/
export function stripDowngradedToolCallText(text: string): string {
function stripDowngradedToolCallText(text: string): string {
if (!text) return text;
if (!/\[Tool (?:Call|Result)/i.test(text)) return text;
@@ -165,7 +165,7 @@ export function stripDowngradedToolCallText(text: string): string {
* This is a safety net for cases where the model outputs <think> tags
* that slip through other filtering mechanisms.
*/
export function stripThinkingTagsFromText(text: string): string {
function stripThinkingTagsFromText(text: string): string {
if (!text) return text;
// Quick check to avoid regex overhead when no tags present.
if (!/(?:think(?:ing)?|thought|antthinking)/i.test(text)) return text;
@@ -15,6 +15,15 @@ const TURN_PREFIX_INSTRUCTIONS =
const MAX_TOOL_FAILURES = 8;
const MAX_TOOL_FAILURE_CHARS = 240;
function isAbortError(err: unknown): boolean {
if (!err || typeof err !== "object") return false;
const name = "name" in err ? String(err.name) : "";
if (name === "AbortError") return true;
const message =
"message" in err && typeof err.message === "string" ? err.message.toLowerCase() : "";
return message.includes("aborted");
}
type ToolFailure = {
toolCallId: string;
toolName: string;
@@ -251,6 +260,9 @@ async function summarizeWithFallback(params: {
try {
return await summarizeChunks(params);
} catch (fullError) {
if (params.signal.aborted || isAbortError(fullError)) {
throw fullError;
}
console.warn(
`Full summarization failed, trying partial: ${
fullError instanceof Error ? fullError.message : String(fullError)
@@ -283,6 +295,9 @@ async function summarizeWithFallback(params: {
const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : "";
return partialSummary + notes;
} catch (partialError) {
if (params.signal.aborted || isAbortError(partialError)) {
throw partialError;
}
console.warn(
`Partial summarization also failed: ${
partialError instanceof Error ? partialError.message : String(partialError)
@@ -0,0 +1,212 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createBrowserTool } from "./tools/browser-tool.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
const browser = createBrowserTool();
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="chrome"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown[];
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("exposes raw for gateway config.apply tool calls", () => {
const tools = createClawdbotCodingTools();
const gateway = tools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined();
const parameters = gateway?.parameters as {
type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
};
expect(parameters.type).toBe("object");
expect(parameters.properties?.raw).toBeDefined();
expect(parameters.required ?? []).not.toContain("raw");
});
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
const tools = createClawdbotCodingTools();
const browser = tools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(action?.type).toBe("string");
expect(action?.anyOf).toBeUndefined();
expect(Array.isArray(action?.enum)).toBe(true);
expect(action?.enum).toContain("act");
const snapshotFormat = parameters.properties?.snapshotFormat as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(snapshotFormat?.type).toBe("string");
expect(snapshotFormat?.anyOf).toBeUndefined();
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.anyOf).toBeUndefined();
expect(parentId?.oneOf).toBeUndefined();
expect(parentId?.type).toBe("string");
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(count?.anyOf).toBeUndefined();
expect(Array.isArray(count?.oneOf)).toBe(true);
});
});
@@ -1,12 +1,74 @@
import { describe, expect, it } from "vitest";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("preserves action enums in normalized schemas", () => {
const tools = createClawdbotCodingTools();
const toolNames = ["browser", "canvas", "nodes", "cron", "gateway", "message"];
const collectActionValues = (schema: unknown, values: Set<string>): void => {
@@ -26,7 +88,7 @@ describe("createClawdbotCodingTools", () => {
};
for (const name of toolNames) {
const tool = defaultTools.find((candidate) => candidate.name === name);
const tool = tools.find((candidate) => candidate.name === name);
expect(tool).toBeDefined();
const parameters = tool?.parameters as {
properties?: Record<string, unknown>;
@@ -46,9 +108,10 @@ describe("createClawdbotCodingTools", () => {
}
});
it("includes exec and process tools by default", () => {
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const tools = createClawdbotCodingTools();
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
expect(tools.some((tool) => tool.name === "process")).toBe(true);
expect(tools.some((tool) => tool.name === "apply_patch")).toBe(false);
});
it("gates apply_patch behind tools.exec.applyPatch for OpenAI models", () => {
const config: ClawdbotConfig = {
@@ -1,16 +1,78 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("keeps read tool image metadata intact", async () => {
const readTool = defaultTools.find((tool) => tool.name === "read");
const tools = createClawdbotCodingTools();
const readTool = tools.find((tool) => tool.name === "read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-read-"));
@@ -0,0 +1,183 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const tools = createClawdbotCodingTools();
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") continue;
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of tools) {
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});
});
@@ -1,11 +1,74 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("uses workspaceDir for Read tool path resolution", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-ws-"));
try {
@@ -0,0 +1,95 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
it("adds Claude-style aliases to schemas without dropping metadata", () => {
const base: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string", description: "Path" },
content: { type: "string", description: "Body" },
},
},
execute: vi.fn(),
};
const patched = __testing.patchToolSchemaForClaudeCompatibility(base);
const params = patched.parameters as {
properties?: Record<string, unknown>;
required?: string[];
};
const props = params.properties ?? {};
expect(props.file_path).toEqual(props.path);
expect(params.required ?? []).not.toContain("path");
expect(params.required ?? []).not.toContain("file_path");
});
it("normalizes file_path to path and enforces required groups at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
description: "test",
parameters: {
type: "object",
required: ["path", "content"],
properties: {
path: { type: "string" },
content: { type: "string" },
},
},
execute,
};
const wrapped = __testing.wrapToolParamNormalization(tool, [{ keys: ["path", "file_path"] }]);
await wrapped.execute("tool-1", { file_path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
});
});
it("applies sandbox path guards to file_path alias", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
await fs.writeFile(outsidePath, "outside", "utf8");
try {
const readTool = createSandboxedReadTool(tmpDir);
await expect(readTool.execute("tool-sbx-1", { file_path: outsidePath })).rejects.toThrow();
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
await fs.rm(outsidePath, { force: true });
}
});
it("falls back to process.cwd() when workspaceDir not provided", () => {
const prevCwd = process.cwd();
const tools = createClawdbotCodingTools();
// Tools should be created without error
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(true);
expect(tools.some((tool) => tool.name === "edit")).toBe(true);
// cwd should be unchanged
expect(process.cwd()).toBe(prevCwd);
});
});
@@ -1,15 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
import { createBrowserTool } from "./tools/browser-tool.js";
const defaultTools = createClawdbotCodingTools();
describe("createClawdbotCodingTools", () => {
describe("Claude/Gemini alias support", () => {
@@ -75,144 +67,8 @@ describe("createClawdbotCodingTools", () => {
});
});
it("keeps browser tool schema OpenAI-compatible without normalization", () => {
const browser = createBrowserTool();
const schema = browser.parameters as { type?: unknown; anyOf?: unknown };
expect(schema.type).toBe("object");
expect(schema.anyOf).toBeUndefined();
});
it("mentions Chrome extension relay in browser tool description", () => {
const browser = createBrowserTool();
expect(browser.description).toMatch(/Chrome extension/i);
expect(browser.description).toMatch(/profile="chrome"/i);
});
it("keeps browser tool schema properties after normalization", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
anyOf?: unknown[];
properties?: Record<string, unknown>;
required?: string[];
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
});
it("exposes raw for gateway config.apply tool calls", () => {
const gateway = defaultTools.find((tool) => tool.name === "gateway");
expect(gateway).toBeDefined();
const parameters = gateway?.parameters as {
type?: unknown;
required?: string[];
properties?: Record<string, unknown>;
};
expect(parameters.type).toBe("object");
expect(parameters.properties?.raw).toBeDefined();
expect(parameters.required ?? []).not.toContain("raw");
});
it("flattens anyOf-of-literals to enum for provider compatibility", () => {
const browser = defaultTools.find((tool) => tool.name === "browser");
expect(browser).toBeDefined();
const parameters = browser?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(action?.type).toBe("string");
expect(action?.anyOf).toBeUndefined();
expect(Array.isArray(action?.enum)).toBe(true);
expect(action?.enum).toContain("act");
const snapshotFormat = parameters.properties?.snapshotFormat as
| {
type?: unknown;
enum?: unknown[];
anyOf?: unknown[];
}
| undefined;
expect(snapshotFormat?.type).toBe("string");
expect(snapshotFormat?.anyOf).toBeUndefined();
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = __testing.cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.type).toBe("string");
expect(parentId?.anyOf).toBeUndefined();
expect(count?.oneOf).toBeDefined();
});
it("avoids anyOf/oneOf/allOf in tool schemas", () => {
const tools = createClawdbotCodingTools();
const offenders: Array<{
name: string;
keyword: string;
@@ -240,7 +96,7 @@ describe("createClawdbotCodingTools", () => {
}
};
for (const tool of defaultTools) {
for (const tool of tools) {
walk(tool.parameters, "", tool.name);
}
@@ -336,131 +192,4 @@ describe("createClawdbotCodingTools", () => {
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createClawdbotCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createClawdbotCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createClawdbotCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
// Helper to recursively check schema for unsupported keywords
const unsupportedKeywords = new Set([
"patternProperties",
"additionalProperties",
"$schema",
"$id",
"$ref",
"$defs",
"definitions",
"examples",
"minLength",
"maxLength",
"minimum",
"maximum",
"multipleOf",
"pattern",
"format",
"minItems",
"maxItems",
"uniqueItems",
"minProperties",
"maxProperties",
]);
const findUnsupportedKeywords = (schema: unknown, path: string): string[] => {
const found: string[] = [];
if (!schema || typeof schema !== "object") return found;
if (Array.isArray(schema)) {
schema.forEach((item, i) => {
found.push(...findUnsupportedKeywords(item, `${path}[${i}]`));
});
return found;
}
const record = schema as Record<string, unknown>;
const properties =
record.properties &&
typeof record.properties === "object" &&
!Array.isArray(record.properties)
? (record.properties as Record<string, unknown>)
: undefined;
if (properties) {
for (const [key, value] of Object.entries(properties)) {
found.push(...findUnsupportedKeywords(value, `${path}.properties.${key}`));
}
}
for (const [key, value] of Object.entries(record)) {
if (key === "properties") continue;
if (unsupportedKeywords.has(key)) {
found.push(`${path}.${key}`);
}
if (value && typeof value === "object") {
found.push(...findUnsupportedKeywords(value, `${path}.${key}`));
}
}
return found;
};
for (const tool of defaultTools) {
const violations = findUnsupportedKeywords(tool.parameters, `${tool.name}.parameters`);
expect(violations).toEqual([]);
}
});
it("applies sandbox path guards to file_path alias", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sbx-"));
const outsidePath = path.join(os.tmpdir(), "clawdbot-outside.txt");
await fs.writeFile(outsidePath, "outside", "utf8");
try {
const readTool = createSandboxedReadTool(tmpDir);
await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow(
/sandbox root/i,
);
} finally {
await fs.rm(outsidePath, { force: true });
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});
+6 -25
View File
@@ -44,7 +44,6 @@ import {
collectExplicitAllowlist,
expandPolicyWithPluginGroups,
resolveToolProfilePolicy,
stripPluginOnlyAllowlist,
} from "./tool-policy.js";
import { getPluginToolMeta } from "../plugins/tools.js";
@@ -299,30 +298,12 @@ export function createClawdbotCodingTools(options?: {
tools,
toolMeta: (tool) => getPluginToolMeta(tool as AnyAgentTool),
});
const profilePolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(profilePolicy, pluginGroups),
pluginGroups,
);
const providerProfileExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(providerProfilePolicy, pluginGroups),
pluginGroups,
);
const globalPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalPolicy, pluginGroups),
pluginGroups,
);
const globalProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(globalProviderPolicy, pluginGroups),
pluginGroups,
);
const agentPolicyExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentPolicy, pluginGroups),
pluginGroups,
);
const agentProviderExpanded = expandPolicyWithPluginGroups(
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
pluginGroups,
);
const profilePolicyExpanded = expandPolicyWithPluginGroups(profilePolicy, pluginGroups);
const providerProfileExpanded = expandPolicyWithPluginGroups(providerProfilePolicy, pluginGroups);
const globalPolicyExpanded = expandPolicyWithPluginGroups(globalPolicy, pluginGroups);
const globalProviderExpanded = expandPolicyWithPluginGroups(globalProviderPolicy, pluginGroups);
const agentPolicyExpanded = expandPolicyWithPluginGroups(agentPolicy, pluginGroups);
const agentProviderExpanded = expandPolicyWithPluginGroups(agentProviderPolicy, pluginGroups);
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
-34
View File
@@ -1,34 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
if (process.platform === "win32") {
expect(true).toBe(true);
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const realDir = path.join(root, "real");
const linkDir = path.join(root, "link");
await fs.mkdir(realDir, { recursive: true });
await fs.symlink(realDir, linkDir);
const sessionReal = path.join(realDir, "sessions.json");
const sessionLink = path.join(linkDir, "sessions.json");
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
await lockB.release();
await lockA.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
+9 -17
View File
@@ -45,28 +45,20 @@ export async function acquireSessionWriteLock(params: {
}> {
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
const sessionDir = path.dirname(sessionFile);
await fs.mkdir(sessionDir, { recursive: true });
let normalizedDir = sessionDir;
try {
normalizedDir = await fs.realpath(sessionDir);
} catch {
// Fall back to the resolved path if realpath fails (permissions, transient FS).
}
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
const lockPath = `${normalizedSessionFile}.lock`;
const sessionFile = params.sessionFile;
const lockPath = `${sessionFile}.lock`;
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const held = HELD_LOCKS.get(normalizedSessionFile);
const held = HELD_LOCKS.get(sessionFile);
if (held) {
held.count += 1;
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -83,14 +75,14 @@ export async function acquireSessionWriteLock(params: {
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8",
);
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -1,22 +0,0 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
Loaded 100 of 354 files, more files were not shown because too many files have changed in this diff. Show more