Compare commits

..
Author SHA1 Message Date
Peter Steinberger 47ba85f36a fix: keep BlueBubbles reply tag GUIDs (#1641) (thanks @tyler6204) 2026-01-24 22:42:23 +00:00
Tyler Yust e516e923ff refactor: update message ID handling to use GUIDs instead of UUIDs for consistency 2026-01-24 22:31:13 +00:00
Tyler Yust fd36e05bf3 test: add unit tests for tapback text parsing in BlueBubbles webhook 2026-01-24 22:31:13 +00:00
Tyler Yust e8e226ddf3 refactor: update reply formatting to use inline [[reply_to:N]] tag and normalize message IDs 2026-01-24 22:31:13 +00:00
Peter Steinberger dd150d69c6 fix: use active auth profile for auto-compaction 2026-01-24 22:23:49 +00:00
9ceac415c5 fix: auto-compact on context overflow promptError before returning error (#1627)
* fix: detect Anthropic 'Request size exceeds model context window' as context overflow

Anthropic now returns 'Request size exceeds model context window' instead of
the previously detected 'prompt is too long' format. This new error message
was not recognized by isContextOverflowError(), causing auto-compaction to
NOT trigger. Users would see the raw error twice without any recovery attempt.

Changes:
- Add 'exceeds model context window' and 'request size exceeds' to
  isContextOverflowError() detection patterns
- Add tests that fail without the fix, verifying both the raw error
  string and the JSON-wrapped format from Anthropic's API
- Add test for formatAssistantErrorText to ensure the friendly
  'Context overflow' message is shown instead of the raw error

Note: The upstream pi-ai package (@mariozechner/pi-ai) also needs a fix
in its OVERFLOW_PATTERNS regex: /exceeds the context window/i should be
changed to /exceeds.*context window/i to match both 'the' and 'model'
variants for triggering auto-compaction retry.

* fix(tests): remove unused imports and helper from test files

Remove WorkspaceBootstrapFile references and _makeFile helper that were
incorrectly copied from another test file. These caused type errors and
were unrelated to the context overflow detection tests.

* fix: trigger auto-compaction on context overflow promptError

When the LLM rejects a request with a context overflow error that surfaces
as a promptError (thrown exception rather than streamed error), the existing
auto-compaction in pi-coding-agent never triggers. This happens because the
error bypasses the agent's message_end → agent_end → _checkCompaction path.

This fix adds a fallback compaction attempt directly in the run loop:
- Detects context overflow in promptError (excluding compaction_failure)
- Calls compactEmbeddedPiSessionDirect (bypassing lane queues since already in-lane)
- Retries the prompt after successful compaction
- Limits to one compaction attempt per run to prevent infinite loops

Fixes: context overflow errors shown to user without auto-compaction attempt

* style: format compact.ts and run.ts with oxfmt

* fix: tighten context overflow match (#1627) (thanks @rodrigouroz)

---------

Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 22:09:24 +00:00
Peter Steinberger ac00065727 fix: normalize telegram fetch for long-polling 2026-01-24 21:58:42 +00:00
Peter Steinberger 30534c5c33 docs: add Bedrock EC2 role notes (#1625) (thanks @sergical) 2026-01-24 21:18:18 +00:00
Sergiy DybskiyandClaude Opus 4.5 97755683c7 docs: add EC2 instance role setup for Bedrock (#1625)
- Add EC2 Instance Roles section with workaround for IMDS credential detection
- Include step-by-step IAM role and instance profile setup
- Document required permissions (bedrock:InvokeModel, ListFoundationModels)
- Update example model to Claude Opus 4.5 (latest)

The AWS SDK auto-detects EC2 instance roles via IMDS, but Clawdbot's
credential detection only checks environment variables. The workaround
is to set AWS_PROFILE=default to signal credentials are available.

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:17:21 +00:00
Peter Steinberger a4f6b3528a fix: cover elevated ask approvals (#1636) 2026-01-24 21:12:46 +00:00
Peter Steinberger 9f8e66359e fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:10:54 +00:00
Hunter MillerandPeter Steinberger 8a2720db4c fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes (#1631)
* fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes

- Fix Zod v4.3.6 bug: single-arg z.record() fails with toJSONSchema()
  - Use two-arg form: z.record(z.string(), schema)
  - Fixes 'Cannot read properties of undefined (reading _zod)' error

- Fix @urbit/aura v3.0.0 API migration:
  - unixToDa() → da.fromUnix()
  - formatUd() → scot('ud', ...)
  - Fixes '(0 , _aura.unixToDa) is not a function' error

These were blocking Tlon plugin loading and outbound messaging.

* fix: add tlon schema/aura tests (#1631) (thanks @arthyn)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 21:09:18 +00:00
Nimrod Gutman 5330595a5a feat(macos): add direct gateway transport 2026-01-24 21:02:13 +00:00
Peter Steinberger 2c5141d7df docs: clarify beta promotion flow 2026-01-24 20:59:41 +00:00
483fba41b9 feat(discord): add exec approval forwarding to DMs (#1621)
* feat(discord): add exec approval forwarding to DMs

Add support for forwarding exec approval requests to Discord DMs,
allowing users to approve/deny command execution via interactive buttons.

Features:
- New DiscordExecApprovalHandler that connects to gateway and listens
  for exec.approval.requested/resolved events
- Sends DMs with embeds showing command details and 3 buttons:
  Allow once, Always allow, Deny
- Configurable via channels.discord.execApprovals with:
  - enabled: boolean
  - approvers: Discord user IDs to notify
  - agentFilter: only forward for specific agents
  - sessionFilter: only forward for matching session patterns
- Updates message embed when approval is resolved or expires

Also fixes exec completion routing: when async exec completes after
approval, the heartbeat now uses a specialized prompt to ensure the
model relays the result to the user instead of responding HEARTBEAT_OK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: generic exec approvals forwarding (#1621) (thanks @czekaj)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:56:40 +00:00
Ivan Casco fe7436a1f6 fix(exec): only set security=full when elevated mode is full (#1616) 2026-01-24 20:55:21 +00:00
46 changed files with 2756 additions and 538 deletions

No files matched your search

+7
View File
@@ -8,10 +8,17 @@ Docs: https://docs.clawd.bot
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
### Fixes
- BlueBubbles: preserve full message GUIDs in reply tags + reaction events (no part-prefix stripping). (#1641) Thanks @tyler6204.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
+67 -4
View File
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
auth: "aws-sdk",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
id: "anthropic.claude-opus-4-5-20251101-v1:0",
name: "Claude Opus 4.5 (Bedrock)",
reasoning: true,
input: ["text"],
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
@@ -89,12 +89,75 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
}
}
}
```
## EC2 Instance Roles
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
will automatically use the instance metadata service (IMDS) for authentication.
However, Clawdbot's credential detection currently only checks for environment
variables, not IMDS credentials.
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
available. The actual authentication still uses the instance role via IMDS.
```bash
# Add to ~/.bashrc or your shell profile
export AWS_PROFILE=default
export AWS_REGION=us-east-1
```
**Required IAM permissions** for the EC2 instance role:
- `bedrock:InvokeModel`
- `bedrock:InvokeModelWithResponseStream`
- `bedrock:ListFoundationModels` (for automatic discovery)
Or attach the managed policy `AmazonBedrockFullAccess`.
**Quick setup:**
```bash
# 1. Create IAM role and instance profile
aws iam create-role --role-name EC2-Bedrock-Access \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
aws iam add-role-to-instance-profile \
--instance-profile-name EC2-Bedrock-Access \
--role-name EC2-Bedrock-Access
# 2. Attach to your EC2 instance
aws ec2 associate-iam-instance-profile \
--instance-id i-xxxxx \
--iam-instance-profile Name=EC2-Bedrock-Access
# 3. On the EC2 instance, enable discovery
clawdbot config set models.bedrockDiscovery.enabled true
clawdbot config set models.bedrockDiscovery.region us-east-1
# 4. Set the workaround env vars
echo 'export AWS_PROFILE=default' >> ~/.bashrc
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
source ~/.bashrc
# 5. Verify models are discovered
clawdbot models list
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.
+25 -1
View File
@@ -19,6 +19,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
- [What’s the difference between stable and beta?](#whats-the-difference-between-stable-and-beta)
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
@@ -42,6 +43,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [Skills and automation](#skills-and-automation)
@@ -322,9 +324,22 @@ Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_s
If you still can't reach the site, the docs are mirrored on GitHub:
https://github.com/clawdbot/clawdbot/tree/main/docs
### What’s the difference between stable and beta?
**Stable** and **beta** are **npm dist‑tags**, not separate code lines:
- `latest` = stable
- `beta` = early build for testing
We ship builds to **beta**, test them, and once a build is solid we **promote
that same version to `latest`**. That’s why beta and stable can point at the
**same version**.
See what changed:
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
### How do I install the beta version, and what’s the difference between beta and dev?
**Beta** is a prerelease tag (`vYYYY.M.D-beta.N`) published to the npm dist‑tag `beta`.
**Beta** is the npm dist‑tag `beta` (may match `latest`).
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
One‑liners (macOS/Linux):
@@ -543,6 +558,15 @@ 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.
### What’s the difference between the hackable (git) install and npm install?
- **Hackable (git) install:** full source checkout, editable, best for contributors.
You run builds locally and can patch code/docs.
- **npm install:** global CLI install, no repo, best for “just run it.”
Updates come from npm dist‑tags.
Docs: [Getting started](/start/getting-started), [Updating](/install/updating).
### Can I switch between npm and git installs later?
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
+9 -7
View File
@@ -11,10 +11,13 @@ Last updated: 2026-01-21
Clawdbot ships three update channels:
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
- **stable**: npm dist-tag `latest`.
- **beta**: npm dist-tag `beta` (builds under test).
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
without changing the version number — dist-tags are the source of truth for npm installs.
## Switching channels
Git checkout:
@@ -25,7 +28,7 @@ clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `stable`/`beta` check out the latest matching tag (often the same tag).
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
@@ -56,12 +59,11 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
## Tagging best practices
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Keep tags immutable: never move or reuse a tag.
- Publish dist-tags alongside git tags:
- npm dist-tags remain the source of truth for npm installs:
- `latest` → stable
- `beta` → prerelease
- `beta` → candidate build
- `dev` → main snapshot (optional)
## macOS app availability
+4 -3
View File
@@ -6,9 +6,10 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
@@ -18,8 +19,8 @@ read_when:
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
+30
View File
@@ -157,6 +157,36 @@ Actions:
- **Always allow** → add to allowlist + run
- **Deny** → block
## Approval forwarding to chat channels
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
them with `/approve`. This uses the normal outbound delivery pipeline.
Config:
```json5
{
approvals: {
exec: {
enabled: true,
mode: "session", // "session" | "targets" | "both"
agentFilter: ["main"],
sessionFilter: ["discord"], // substring or regex
targets: [
{ channel: "slack", to: "U12345678" },
{ channel: "telegram", to: "123456789" }
]
}
}
}
```
Reply in chat:
```
/approve <id> allow-once
/approve <id> allow-always
/approve <id> deny
```
### macOS IPC flow
```
Gateway -> Node Service (WS)
+1 -1
View File
@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
Notes:
- `host` defaults to `sandbox`.
+1
View File
@@ -61,6 +61,7 @@ Text + native (when enabled):
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
+92 -12
View File
@@ -1189,9 +1189,8 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
// Body uses inline [[reply_to:N]] tag format
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
@@ -1260,9 +1259,8 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
// Body uses inline [[reply_to:N]] tag format with short ID
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
@@ -1305,6 +1303,88 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("tapback text parsing", () => {
it("does not rewrite tapback-like text without metadata", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "Loved this idea",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
});
it("parses tapback text with custom emoji when metadata is present", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: 'Reacted 😅 to "nice one"',
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-2",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
});
});
describe("ack reactions", () => {
it("sends ack reaction when configured", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
@@ -1759,7 +1839,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.any(Object),
);
});
@@ -1799,7 +1879,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
expect.any(Object),
);
});
@@ -1905,7 +1985,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1921,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
@@ -1945,7 +2025,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-uuid-12345",
guid: "p:1/msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -1958,7 +2038,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
+187 -33
View File
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this UUID
// Check if we already have a short ID for this GUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
* Returns the input unchanged if it's already a UUID or not found in the mapping.
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
* Returns the input unchanged if it's already a GUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
}
/**
* Gets the short ID for a UUID, if one exists.
* Gets the short ID for a message GUID, if one exists.
*/
function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
@@ -390,29 +390,15 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return "";
}
const REPLY_BODY_TRUNCATE_LENGTH = 60;
function formatReplyContext(message: {
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
function formatReplyTag(message: {
replyToId?: string;
replyToShortId?: string;
replyToBody?: string;
replyToSender?: string;
}): string | null {
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
// Prefer short ID for token savings
const displayId = message.replyToShortId || message.replyToId;
// Only include sender if we don't have an ID (fallback)
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
const rawBody = message.replyToBody?.trim();
if (!rawBody) {
return `[Replying to ${label}]\n[/Replying]`;
}
// Truncate long reply bodies for token savings
const body =
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
: rawBody;
return `[Replying to ${label}]\n${body}\n[/Replying]`;
// Prefer short ID
const rawId = message.replyToShortId || message.replyToId;
if (!rawId) return null;
return `[[reply_to:${rawId}]]`;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
@@ -629,6 +615,10 @@ type NormalizedWebhookMessage = {
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
associatedMessageGuid?: string;
associatedMessageType?: number;
associatedMessageEmoji?: string;
isTapback?: boolean;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
@@ -665,6 +655,113 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
[3005, { emoji: "❓", action: "removed" }],
]);
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
["loved", { emoji: "❤️", action: "added" }],
["liked", { emoji: "👍", action: "added" }],
["disliked", { emoji: "👎", action: "added" }],
["laughed at", { emoji: "😂", action: "added" }],
["emphasized", { emoji: "‼️", action: "added" }],
["questioned", { emoji: "❓", action: "added" }],
// Removal patterns (e.g., "Removed a heart from")
["removed a heart from", { emoji: "❤️", action: "removed" }],
["removed a like from", { emoji: "👍", action: "removed" }],
["removed a dislike from", { emoji: "👎", action: "removed" }],
["removed a laugh from", { emoji: "😂", action: "removed" }],
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
["removed a question from", { emoji: "❓", action: "removed" }],
]);
const TAPBACK_EMOJI_REGEX =
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
function extractFirstEmoji(text: string): string | null {
const match = text.match(TAPBACK_EMOJI_REGEX);
return match ? match[0] : null;
}
function extractQuotedTapbackText(text: string): string | null {
const match = text.match(/[“"]([^”"]+)[”"]/s);
return match ? match[1] : null;
}
function isTapbackAssociatedType(type: number | undefined): boolean {
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
}
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
if (type >= 3000 && type < 4000) return "removed";
if (type >= 2000 && type < 3000) return "added";
return undefined;
}
function resolveTapbackContext(message: NormalizedWebhookMessage): {
emojiHint?: string;
actionHint?: "added" | "removed";
replyToId?: string;
} | null {
const associatedType = message.associatedMessageType;
const hasTapbackType = isTapbackAssociatedType(associatedType);
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
if (!hasTapbackType && !hasTapbackMarker) return null;
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint =
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
return { emojiHint, actionHint, replyToId };
}
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
function parseTapbackText(params: {
text: string;
emojiHint?: string;
actionHint?: "added" | "removed";
requireQuoted?: boolean;
}): {
emoji: string;
action: "added" | "removed";
quotedText: string;
} | null {
const trimmed = params.text.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return null;
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
if (lower.startsWith(pattern)) {
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
const afterPattern = trimmed.slice(pattern.length).trim();
if (params.requireQuoted) {
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
if (!strictMatch) return null;
return { emoji, action, quotedText: strictMatch[1] };
}
const quotedText =
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
return { emoji, action, quotedText };
}
}
if (lower.startsWith("reacted")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("reacted".length).trim();
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
}
if (lower.startsWith("removed")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("removed".length).trim();
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
}
return null;
}
function maskSecret(value: string): string {
if (value.length <= 6) return "***";
return `${value.slice(0, 2)}***${value.slice(-2)}`;
@@ -805,6 +902,25 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
readString(message, "messageId") ??
undefined;
const balloonBundleId = readString(message, "balloonBundleId");
const associatedMessageGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId") ??
undefined;
const associatedMessageType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedMessageEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji") ??
undefined;
const isTapback =
readBoolean(message, "isTapback") ??
readBoolean(message, "is_tapback") ??
readBoolean(message, "tapback") ??
undefined;
const timestampRaw =
readNumber(message, "date") ??
@@ -835,6 +951,10 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
associatedMessageGuid,
associatedMessageType,
associatedMessageEmoji,
isTapback,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
@@ -856,8 +976,13 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
if (!associatedGuid || associatedType === undefined) return null;
const mapping = REACTION_TYPE_MAP.get(associatedType);
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
const action = mapping?.action ?? "added";
const associatedEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji");
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
const handleValue = message.handle ?? message.sender;
const handle =
@@ -1122,7 +1247,21 @@ async function processMessage(
const text = message.text.trim();
const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message);
const rawBody = text || placeholder;
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
const tapbackContext = resolveTapbackContext(message);
const tapbackParsed = parseTapbackText({
text,
emojiHint: tapbackContext?.emojiHint,
actionHint: tapbackContext?.actionHint,
requireQuoted: !tapbackContext,
});
const isTapbackMessage = Boolean(tapbackParsed);
const rawBody = tapbackParsed
? tapbackParsed.action === "removed"
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
@@ -1449,7 +1588,11 @@ async function processMessage(
let replyToSender = message.replyToSender;
let replyToShortId: string | undefined;
if (replyToId && (!replyToBody || !replyToSender)) {
if (isTapbackMessage && tapbackContext?.replyToId) {
replyToId = tapbackContext.replyToId;
}
if (replyToId) {
const cached = resolveReplyContextFromCache({
accountId: account.accountId,
replyToId,
@@ -1477,8 +1620,15 @@ async function processMessage(
replyToShortId = getShortIdForUuid(replyToId);
}
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
// Use inline [[reply_to:N]] tag format
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
const replyTag = formatReplyTag({ replyToId, replyToShortId });
const baseBody = replyTag
? isTapbackMessage
? `${rawBody} ${replyTag}`
: `${replyTag} ${rawBody}`
: rawBody;
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
@@ -1871,7 +2021,11 @@ async function processReaction(
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
// Use short ID for token savings
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
const text =
reaction.action === "removed"
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
describe("Tlon config schema", () => {
it("accepts channelRules with string keys", () => {
const parsed = TlonAuthorizationSchema.parse({
channelRules: {
"chat/~zod/test": {
mode: "open",
allowedShips: ["~zod"],
},
},
});
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
});
it("accepts accounts with string keys", () => {
const parsed = TlonConfigSchema.parse({
accounts: {
primary: {
ship: "~zod",
url: "https://example.com",
code: "code-123",
},
},
});
expect(parsed.accounts?.primary?.ship).toBe("~zod");
});
});
+2 -2
View File
@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
});
export const TlonAuthorizationSchema = z.object({
channelRules: z.record(TlonChannelRuleSchema).optional(),
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
});
export const TlonAccountSchema = z.object({
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
showModelSignature: z.boolean().optional(),
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(TlonAccountSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),
});
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
+38
View File
@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@urbit/aura", () => ({
scot: vi.fn(() => "mocked-ud"),
da: {
fromUnix: vi.fn(() => 123n),
},
}));
describe("sendDm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses aura v3 helpers for the DM id", async () => {
const { sendDm } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
const fromUnix = vi.mocked(aura.da.fromUnix);
const sentAt = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(sentAt);
const poke = vi.fn(async () => ({}));
const result = await sendDm({
api: { poke },
fromShip: "~zod",
toShip: "~nec",
text: "hi",
});
expect(fromUnix).toHaveBeenCalledWith(sentAt);
expect(scot).toHaveBeenCalledWith("ud", 123n);
expect(poke).toHaveBeenCalledTimes(1);
expect(result.messageId).toBe("~zod/mocked-ud");
});
});
+2 -2
View File
@@ -1,4 +1,4 @@
import { unixToDa, formatUd } from "@urbit/aura";
import { scot, da } from "@urbit/aura";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
@@ -14,7 +14,7 @@ type SendTextParams = {
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt));
const idUd = scot('ud', da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {
@@ -150,4 +150,35 @@ describe("exec approvals", () => {
expect(result.details.status).toBe("completed");
expect(calls).not.toContain("exec.approval.request");
});
it("requires approval for elevated ask when allowlist misses", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
let resolveApproval: (() => void) | undefined;
const approvalSeen = new Promise<void>((resolve) => {
resolveApproval = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approval.request") {
resolveApproval?.();
return { decision: "deny" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
});
});
+1 -1
View File
@@ -791,7 +791,7 @@ export function createExecTool(
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) {
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
const configuredAsk = defaults?.ask ?? "on-miss";
@@ -1,15 +1,7 @@
import type { AssistantMessage } from "@mariozechner/pi-ai";
import { describe, expect, it } from "vitest";
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("formatAssistantErrorText", () => {
const makeAssistantError = (errorMessage: string): AssistantMessage =>
({
@@ -21,6 +13,16 @@ describe("formatAssistantErrorText", () => {
const msg = makeAssistantError("request_too_large");
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns context overflow for Anthropic 'Request size exceeds model context window'", () => {
// This is the new Anthropic error format that wasn't being detected.
// Without the fix, this falls through to the invalidRequest regex and returns
// "LLM request rejected: Request size exceeds model context window"
// instead of the context overflow message, preventing auto-compaction.
const msg = makeAssistantError(
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
);
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
});
it("returns a friendly message for Anthropic role ordering", () => {
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
@@ -1,14 +1,6 @@
import { describe, expect, it } from "vitest";
import { isContextOverflowError } from "./pi-embedded-helpers.js";
import { DEFAULT_AGENTS_FILENAME } from "./workspace.js";
const _makeFile = (overrides: Partial<WorkspaceBootstrapFile>): WorkspaceBootstrapFile => ({
name: DEFAULT_AGENTS_FILENAME,
path: "/tmp/AGENTS.md",
content: "",
missing: false,
...overrides,
});
describe("isContextOverflowError", () => {
it("matches known overflow hints", () => {
const samples = [
@@ -24,7 +16,34 @@ describe("isContextOverflowError", () => {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("matches Anthropic 'Request size exceeds model context window' error", () => {
// Anthropic returns this error format when the prompt exceeds the context window.
// Without this fix, auto-compaction is NOT triggered because neither
// isContextOverflowError nor pi-ai's isContextOverflow recognizes this pattern.
// The user sees: "LLM request rejected: Request size exceeds model context window"
// instead of automatic compaction + retry.
const anthropicRawError =
'{"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}';
expect(isContextOverflowError(anthropicRawError)).toBe(true);
});
it("matches 'exceeds model context window' in various formats", () => {
const samples = [
"Request size exceeds model context window",
"request size exceeds model context window",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"Request size exceeds model context window"}}',
"The request size exceeds model context window limit",
];
for (const sample of samples) {
expect(isContextOverflowError(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
expect(isContextOverflowError("request size exceeds upload limit")).toBe(false);
expect(isContextOverflowError("model not found")).toBe(false);
expect(isContextOverflowError("authentication failed")).toBe(false);
});
});
+7
View File
@@ -7,12 +7,19 @@ import type { FailoverReason } from "./types.js";
export function isContextOverflowError(errorMessage?: string): boolean {
if (!errorMessage) return false;
const lower = errorMessage.toLowerCase();
const hasRequestSizeExceeds = lower.includes("request size exceeds");
const hasContextWindow =
lower.includes("context window") ||
lower.includes("context length") ||
lower.includes("maximum context length");
return (
lower.includes("request_too_large") ||
lower.includes("request exceeds the maximum size") ||
lower.includes("context length exceeded") ||
lower.includes("maximum context length") ||
lower.includes("prompt is too long") ||
lower.includes("exceeds model context window") ||
(hasRequestSizeExceeds && hasContextWindow) ||
lower.includes("context overflow") ||
(lower.includes("413") && lower.includes("too large"))
);
+358 -345
View File
@@ -68,12 +68,13 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
export async function compactEmbeddedPiSession(params: {
export type CompactEmbeddedPiSessionParams = {
sessionId: string;
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
agentAccountId?: string;
authProfileId?: string;
/** Group id for channel-level tool policy resolution. */
groupId?: string | null;
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
@@ -97,354 +98,366 @@ export async function compactEmbeddedPiSession(params: {
enqueue?: typeof enqueueCommand;
extraSystemPrompt?: string;
ownerNumbers?: string[];
}): Promise<EmbeddedPiCompactResult> {
};
/**
* Core compaction logic without lane queueing.
* Use this when already inside a session/global lane to avoid deadlocks.
*/
export async function compactEmbeddedPiSessionDirect(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult> {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
agentDir,
params.config,
);
if (!model) {
return {
ok: false,
compacted: false,
reason: error ?? `Unknown model: ${provider}/${modelId}`,
};
}
try {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.config,
profileId: params.authProfileId,
agentDir,
});
if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error(
`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);
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
}
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries = !params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,
});
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(params.messageChannel ?? params.messageProvider);
let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) runtimeCapabilities = [];
if (
!runtimeCapabilities.some((cap) => String(cap).trim().toLowerCase() === "inlinebuttons")
) {
runtimeCapabilities.push("inlineButtons");
}
}
}
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
});
try {
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
});
trackSessionManagerAccess(params.sessionFile);
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
ensurePiCompactionReserveTokens({
settingsManager,
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
});
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
cfg: params.config,
sessionManager,
provider,
modelId,
model,
});
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
});
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
({ session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
systemPrompt,
tools: builtInTools,
customTools,
sessionManager,
settingsManager,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));
try {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
try {
tokensAfter = 0;
for (const message of session.messages) {
tokensAfter += estimateTokens(message);
}
// Sanity check: tokensAfter should be less than tokensBefore
if (tokensAfter > result.tokensBefore) {
tokensAfter = undefined; // Don't trust the estimate
}
} catch {
// If estimation fails, leave tokensAfter undefined
tokensAfter = undefined;
}
return {
ok: true,
compacted: true,
result: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details,
},
};
} finally {
sessionManager.flushPendingToolResults?.();
session.dispose();
}
} finally {
await sessionLock.release();
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}
/**
* Compacts a session with lane queueing (session lane + global lane).
* Use this from outside a lane context. If already inside a lane, use
* `compactEmbeddedPiSessionDirect` to avoid deadlocks.
*/
export async function compactEmbeddedPiSession(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult> {
const sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
const globalLane = resolveGlobalLane(params.lane);
const enqueueGlobal =
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
return enqueueCommandInLane(sessionLane, () =>
enqueueGlobal(async () => {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
await ensureClawdbotModelsJson(params.config, agentDir);
const { model, error, authStorage, modelRegistry } = resolveModel(
provider,
modelId,
agentDir,
params.config,
);
if (!model) {
return {
ok: false,
compacted: false,
reason: error ?? `Unknown model: ${provider}/${modelId}`,
};
}
try {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.config,
agentDir,
});
if (!apiKeyInfo.apiKey) {
if (apiKeyInfo.mode !== "aws-sdk") {
throw new Error(
`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);
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
}
await fs.mkdir(resolvedWorkspace, { recursive: true });
const sandboxSessionKey = params.sessionKey?.trim() || params.sessionId;
const sandbox = await resolveSandboxContext({
config: params.config,
sessionKey: sandboxSessionKey,
workspaceDir: resolvedWorkspace,
});
const effectiveWorkspace = sandbox?.enabled
? sandbox.workspaceAccess === "rw"
? resolvedWorkspace
: sandbox.workspaceDir
: resolvedWorkspace;
await fs.mkdir(effectiveWorkspace, { recursive: true });
await ensureSessionHeader({
sessionFile: params.sessionFile,
sessionId: params.sessionId,
cwd: effectiveWorkspace,
});
let restoreSkillEnv: (() => void) | undefined;
process.chdir(effectiveWorkspace);
try {
const shouldLoadSkillEntries =
!params.skillsSnapshot || !params.skillsSnapshot.resolvedSkills;
const skillEntries = shouldLoadSkillEntries
? loadWorkspaceSkillEntries(effectiveWorkspace)
: [];
restoreSkillEnv = params.skillsSnapshot
? applySkillEnvOverridesFromSnapshot({
snapshot: params.skillsSnapshot,
config: params.config,
})
: applySkillEnvOverrides({
skills: skillEntries ?? [],
config: params.config,
});
const skillsPrompt = resolveSkillsPromptForRun({
skillsSnapshot: params.skillsSnapshot,
entries: shouldLoadSkillEntries ? skillEntries : undefined,
config: params.config,
workspaceDir: effectiveWorkspace,
});
const sessionLabel = params.sessionKey ?? params.sessionId;
const { contextFiles } = await resolveBootstrapContextForRun({
workspaceDir: effectiveWorkspace,
config: params.config,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
warn: makeBootstrapWarn({ sessionLabel, warn: (message) => log.warn(message) }),
});
const runAbortController = new AbortController();
const toolsRaw = createClawdbotCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
messageProvider: params.messageChannel ?? params.messageProvider,
agentAccountId: params.agentAccountId,
sessionKey: params.sessionKey ?? params.sessionId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
agentDir,
workspaceDir: effectiveWorkspace,
config: params.config,
abortSignal: runAbortController.signal,
modelProvider: model.provider,
modelId,
modelAuthMode: resolveModelAuthMode(model.provider, params.config),
});
const tools = sanitizeToolsForGoogle({ tools: toolsRaw, provider });
logToolSchemasForGoogle({ tools, provider });
const machineName = await getMachineDisplayName();
const runtimeChannel = normalizeMessageChannel(
params.messageChannel ?? params.messageProvider,
);
let runtimeCapabilities = runtimeChannel
? (resolveChannelCapabilities({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
}) ?? [])
: undefined;
if (runtimeChannel === "telegram" && params.config) {
const inlineButtonsScope = resolveTelegramInlineButtonsScope({
cfg: params.config,
accountId: params.agentAccountId ?? undefined,
});
if (inlineButtonsScope !== "off") {
if (!runtimeCapabilities) runtimeCapabilities = [];
if (
!runtimeCapabilities.some(
(cap) => String(cap).trim().toLowerCase() === "inlinebuttons",
)
) {
runtimeCapabilities.push("inlineButtons");
}
}
}
// Resolve channel-specific message actions for system prompt
const channelActions = runtimeChannel
? listChannelSupportedActions({
cfg: params.config,
channel: runtimeChannel,
})
: undefined;
const messageToolHints = runtimeChannel
? resolveChannelMessageToolHints({
cfg: params.config,
channel: runtimeChannel,
accountId: params.agentAccountId,
})
: undefined;
const runtimeInfo = {
host: machineName,
os: `${os.type()} ${os.release()}`,
arch: os.arch(),
node: process.version,
model: `${provider}/${modelId}`,
channel: runtimeChannel,
capabilities: runtimeCapabilities,
channelActions,
};
const sandboxInfo = buildEmbeddedSandboxInfo(sandbox, params.bashElevated);
const reasoningTagHint = isReasoningTagProvider(provider);
const userTimezone = resolveUserTimezone(params.config?.agents?.defaults?.userTimezone);
const userTimeFormat = resolveUserTimeFormat(params.config?.agents?.defaults?.timeFormat);
const userTime = formatUserTime(new Date(), userTimezone, userTimeFormat);
const { defaultAgentId, sessionAgentId } = resolveSessionAgentIds({
sessionKey: params.sessionKey,
config: params.config,
});
const isDefaultAgent = sessionAgentId === defaultAgentId;
const promptMode = isSubagentSessionKey(params.sessionKey) ? "minimal" : "full";
const docsPath = await resolveClawdbotDocsPath({
workspaceDir: effectiveWorkspace,
argv1: process.argv[1],
cwd: process.cwd(),
moduleUrl: import.meta.url,
});
const ttsHint = params.config ? buildTtsSystemPromptHint(params.config) : undefined;
const appendPrompt = buildEmbeddedSystemPrompt({
workspaceDir: effectiveWorkspace,
defaultThinkLevel: params.thinkLevel,
reasoningLevel: params.reasoningLevel ?? "off",
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
reasoningTagHint,
heartbeatPrompt: isDefaultAgent
? resolveHeartbeatPrompt(params.config?.agents?.defaults?.heartbeat?.prompt)
: undefined,
skillsPrompt,
docsPath: docsPath ?? undefined,
ttsHint,
promptMode,
runtimeInfo,
messageToolHints,
sandboxInfo,
tools,
modelAliasLines: buildModelAliasLines(params.config),
userTimezone,
userTime,
userTimeFormat,
contextFiles,
});
const systemPrompt = createSystemPromptOverride(appendPrompt);
const sessionLock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
});
try {
await prewarmSessionFile(params.sessionFile);
const transcriptPolicy = resolveTranscriptPolicy({
modelApi: model.api,
provider,
modelId,
});
const sessionManager = guardSessionManager(SessionManager.open(params.sessionFile), {
agentId: sessionAgentId,
sessionKey: params.sessionKey,
allowSyntheticToolResults: transcriptPolicy.allowSyntheticToolResults,
});
trackSessionManagerAccess(params.sessionFile);
const settingsManager = SettingsManager.create(effectiveWorkspace, agentDir);
ensurePiCompactionReserveTokens({
settingsManager,
minReserveTokens: resolveCompactionReserveTokensFloor(params.config),
});
const additionalExtensionPaths = buildEmbeddedExtensionPaths({
cfg: params.config,
sessionManager,
provider,
modelId,
model,
});
const { builtInTools, customTools } = splitSdkTools({
tools,
sandboxEnabled: !!sandbox?.enabled,
});
let session: Awaited<ReturnType<typeof createAgentSession>>["session"];
({ session } = await createAgentSession({
cwd: resolvedWorkspace,
agentDir,
authStorage,
modelRegistry,
model,
thinkingLevel: mapThinkingLevel(params.thinkLevel),
systemPrompt,
tools: builtInTools,
customTools,
sessionManager,
settingsManager,
skills: [],
contextFiles: [],
additionalExtensionPaths,
}));
try {
const prior = await sanitizeSessionHistory({
messages: session.messages,
modelApi: model.api,
modelId,
provider,
sessionManager,
sessionId: params.sessionId,
policy: transcriptPolicy,
});
const validatedGemini = transcriptPolicy.validateGeminiTurns
? validateGeminiTurns(prior)
: prior;
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
const limited = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
);
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
try {
tokensAfter = 0;
for (const message of session.messages) {
tokensAfter += estimateTokens(message);
}
// Sanity check: tokensAfter should be less than tokensBefore
if (tokensAfter > result.tokensBefore) {
tokensAfter = undefined; // Don't trust the estimate
}
} catch {
// If estimation fails, leave tokensAfter undefined
tokensAfter = undefined;
}
return {
ok: true,
compacted: true,
result: {
summary: result.summary,
firstKeptEntryId: result.firstKeptEntryId,
tokensBefore: result.tokensBefore,
tokensAfter,
details: result.details,
},
};
} finally {
sessionManager.flushPendingToolResults?.();
session.dispose();
}
} finally {
await sessionLock.release();
}
} catch (err) {
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
};
} finally {
restoreSkillEnv?.();
process.chdir(prevCwd);
}
}),
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
);
}
@@ -0,0 +1,285 @@
import { describe, expect, it, vi, beforeEach } from "vitest";
vi.mock("./run/attempt.js", () => ({
runEmbeddedAttempt: vi.fn(),
}));
vi.mock("./compact.js", () => ({
compactEmbeddedPiSessionDirect: vi.fn(),
}));
vi.mock("./model.js", () => ({
resolveModel: vi.fn(() => ({
model: {
id: "test-model",
provider: "anthropic",
contextWindow: 200000,
api: "messages",
},
error: null,
authStorage: {
setRuntimeApiKey: vi.fn(),
},
modelRegistry: {},
})),
}));
vi.mock("../model-auth.js", () => ({
ensureAuthProfileStore: vi.fn(() => ({})),
getApiKeyForModel: vi.fn(async () => ({
apiKey: "test-key",
profileId: "test-profile",
source: "test",
})),
resolveAuthProfileOrder: vi.fn(() => []),
}));
vi.mock("../models-config.js", () => ({
ensureClawdbotModelsJson: vi.fn(async () => {}),
}));
vi.mock("../context-window-guard.js", () => ({
CONTEXT_WINDOW_HARD_MIN_TOKENS: 1000,
CONTEXT_WINDOW_WARN_BELOW_TOKENS: 5000,
evaluateContextWindowGuard: vi.fn(() => ({
shouldWarn: false,
shouldBlock: false,
tokens: 200000,
source: "model",
})),
resolveContextWindowInfo: vi.fn(() => ({
tokens: 200000,
source: "model",
})),
}));
vi.mock("../../process/command-queue.js", () => ({
enqueueCommandInLane: vi.fn((_lane: string, task: () => unknown) => task()),
}));
vi.mock("../../utils.js", () => ({
resolveUserPath: vi.fn((p: string) => p),
}));
vi.mock("../../utils/message-channel.js", () => ({
isMarkdownCapableMessageChannel: vi.fn(() => true),
}));
vi.mock("../agent-paths.js", () => ({
resolveClawdbotAgentDir: vi.fn(() => "/tmp/agent-dir"),
}));
vi.mock("../auth-profiles.js", () => ({
markAuthProfileFailure: vi.fn(async () => {}),
markAuthProfileGood: vi.fn(async () => {}),
markAuthProfileUsed: vi.fn(async () => {}),
}));
vi.mock("../defaults.js", () => ({
DEFAULT_CONTEXT_TOKENS: 200000,
DEFAULT_MODEL: "test-model",
DEFAULT_PROVIDER: "anthropic",
}));
vi.mock("../failover-error.js", () => ({
FailoverError: class extends Error {
constructor(msg: string) {
super(msg);
}
},
resolveFailoverStatus: vi.fn(),
}));
vi.mock("../usage.js", () => ({
normalizeUsage: vi.fn(() => undefined),
}));
vi.mock("./lanes.js", () => ({
resolveSessionLane: vi.fn(() => "session-lane"),
resolveGlobalLane: vi.fn(() => "global-lane"),
}));
vi.mock("./logger.js", () => ({
log: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
}));
vi.mock("./run/payloads.js", () => ({
buildEmbeddedRunPayloads: vi.fn(() => []),
}));
vi.mock("./utils.js", () => ({
describeUnknownError: vi.fn((err: unknown) => {
if (err instanceof Error) return err.message;
return String(err);
}),
}));
vi.mock("../pi-embedded-helpers.js", async () => {
return {
isCompactionFailureError: (msg?: string) => {
if (!msg) return false;
const lower = msg.toLowerCase();
return lower.includes("request_too_large") && lower.includes("summarization failed");
},
isContextOverflowError: (msg?: string) => {
if (!msg) return false;
const lower = msg.toLowerCase();
return lower.includes("request_too_large") || lower.includes("request size exceeds");
},
isFailoverAssistantError: vi.fn(() => false),
isFailoverErrorMessage: vi.fn(() => false),
isAuthAssistantError: vi.fn(() => false),
isRateLimitAssistantError: vi.fn(() => false),
classifyFailoverReason: vi.fn(() => null),
formatAssistantErrorText: vi.fn(() => ""),
pickFallbackThinkingLevel: vi.fn(() => null),
isTimeoutErrorMessage: vi.fn(() => false),
parseImageDimensionError: vi.fn(() => null),
};
});
import { runEmbeddedPiAgent } from "./run.js";
import { runEmbeddedAttempt } from "./run/attempt.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { log } from "./logger.js";
import type { EmbeddedRunAttemptResult } from "./run/types.js";
const mockedRunEmbeddedAttempt = vi.mocked(runEmbeddedAttempt);
const mockedCompactDirect = vi.mocked(compactEmbeddedPiSessionDirect);
function makeAttemptResult(
overrides: Partial<EmbeddedRunAttemptResult> = {},
): EmbeddedRunAttemptResult {
return {
aborted: false,
timedOut: false,
promptError: null,
sessionIdUsed: "test-session",
assistantTexts: ["Hello!"],
toolMetas: [],
lastAssistant: undefined,
messagesSnapshot: [],
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentTargets: [],
cloudCodeAssistFormatError: false,
...overrides,
};
}
const baseParams = {
sessionId: "test-session",
sessionKey: "test-key",
sessionFile: "/tmp/session.json",
workspaceDir: "/tmp/workspace",
prompt: "hello",
timeoutMs: 30000,
runId: "run-1",
};
describe("overflow compaction in run loop", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("retries after successful compaction on context overflow promptError", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "Compacted session",
firstKeptEntryId: "entry-5",
tokensBefore: 150000,
},
});
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedCompactDirect).toHaveBeenCalledWith(
expect.objectContaining({ authProfileId: "test-profile" }),
);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(log.warn).toHaveBeenCalledWith(
expect.stringContaining("context overflow detected; attempting auto-compaction"),
);
expect(log.info).toHaveBeenCalledWith(expect.stringContaining("auto-compaction succeeded"));
// Should not be an error result
expect(result.meta.error).toBeUndefined();
});
it("returns error if compaction fails", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt.mockResolvedValue(makeAttemptResult({ promptError: overflowError }));
mockedCompactDirect.mockResolvedValueOnce({
ok: false,
compacted: false,
reason: "nothing to compact",
});
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
expect(log.warn).toHaveBeenCalledWith(expect.stringContaining("auto-compaction failed"));
});
it("returns error if overflow happens again after compaction", async () => {
const overflowError = new Error("request_too_large: Request size exceeds model context window");
mockedRunEmbeddedAttempt
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }))
.mockResolvedValueOnce(makeAttemptResult({ promptError: overflowError }));
mockedCompactDirect.mockResolvedValueOnce({
ok: true,
compacted: true,
result: {
summary: "Compacted",
firstKeptEntryId: "entry-3",
tokensBefore: 180000,
},
});
const result = await runEmbeddedPiAgent(baseParams);
// Compaction attempted only once
expect(mockedCompactDirect).toHaveBeenCalledTimes(1);
// Two attempts: first overflow -> compact -> retry -> second overflow -> return error
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2);
expect(result.meta.error?.kind).toBe("context_overflow");
expect(result.payloads?.[0]?.isError).toBe(true);
});
it("does not attempt compaction for compaction_failure errors", async () => {
const compactionFailureError = new Error(
"request_too_large: summarization failed - Request size exceeds model context window",
);
mockedRunEmbeddedAttempt.mockResolvedValue(
makeAttemptResult({ promptError: compactionFailureError }),
);
const result = await runEmbeddedPiAgent(baseParams);
expect(mockedCompactDirect).not.toHaveBeenCalled();
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
expect(result.meta.error?.kind).toBe("compaction_failure");
});
});
+38 -3
View File
@@ -42,6 +42,7 @@ import {
} from "../pi-embedded-helpers.js";
import { normalizeUsage, type UsageLike } from "../usage.js";
import { compactEmbeddedPiSessionDirect } from "./compact.js";
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
import { log } from "./logger.js";
import { resolveModel } from "./model.js";
@@ -290,6 +291,7 @@ export async function runEmbeddedPiAgent(
}
}
let overflowCompactionAttempted = false;
try {
while (true) {
attemptedThinking.add(thinkLevel);
@@ -358,9 +360,42 @@ export async function runEmbeddedPiAgent(
if (promptError && !aborted) {
const errorText = describeUnknownError(promptError);
if (isContextOverflowError(errorText)) {
const kind = isCompactionFailureError(errorText)
? "compaction_failure"
: "context_overflow";
const isCompactionFailure = isCompactionFailureError(errorText);
// Attempt auto-compaction on context overflow (not compaction_failure)
if (!isCompactionFailure && !overflowCompactionAttempted) {
log.warn(
`context overflow detected; attempting auto-compaction for ${provider}/${modelId}`,
);
overflowCompactionAttempted = true;
const compactResult = await compactEmbeddedPiSessionDirect({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
messageChannel: params.messageChannel,
messageProvider: params.messageProvider,
agentAccountId: params.agentAccountId,
authProfileId: lastProfileId,
sessionFile: params.sessionFile,
workspaceDir: params.workspaceDir,
agentDir,
config: params.config,
skillsSnapshot: params.skillsSnapshot,
provider,
model: modelId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
});
if (compactResult.compacted) {
log.info(`auto-compaction succeeded for ${provider}/${modelId}; retrying prompt`);
continue;
}
log.warn(
`auto-compaction failed for ${provider}/${modelId}: ${compactResult.reason ?? "nothing to compact"}`,
);
}
const kind = isCompactionFailure ? "compaction_failure" : "context_overflow";
return {
payloads: [
{
+7
View File
@@ -164,6 +164,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
acceptsArgs: true,
scope: "text",
}),
defineChatCommand({
key: "approve",
nativeName: "approve",
description: "Approve or deny exec requests.",
textAlias: "/approve",
acceptsArgs: true,
}),
defineChatCommand({
key: "context",
nativeName: "context",
@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { callGateway } from "../../gateway/call.js";
vi.mock("../../gateway/call.js", () => ({
callGateway: vi.fn(),
}));
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("/approve command", () => {
it("rejects invalid usage", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Usage: /approve");
});
it("submits approval", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
const mockCallGateway = vi.mocked(callGateway);
mockCallGateway.mockResolvedValueOnce({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
expect(mockCallGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc", decision: "allow-once" },
}),
);
});
});
+101
View File
@@ -0,0 +1,101 @@
import { callGateway } from "../../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js";
const COMMAND = "/approve";
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
allow: "allow-once",
once: "allow-once",
"allow-once": "allow-once",
allowonce: "allow-once",
always: "allow-always",
"allow-always": "allow-always",
allowalways: "allow-always",
deny: "deny",
reject: "deny",
block: "deny",
};
type ParsedApproveCommand =
| { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" }
| { ok: false; error: string };
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
const rest = trimmed.slice(COMMAND.length).trim();
if (!rest) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
const tokens = rest.split(/\s+/).filter(Boolean);
if (tokens.length < 2) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
const first = tokens[0].toLowerCase();
const second = tokens[1].toLowerCase();
if (DECISION_ALIASES[first]) {
return {
ok: true,
decision: DECISION_ALIASES[first],
id: tokens.slice(1).join(" ").trim(),
};
}
if (DECISION_ALIASES[second]) {
return {
ok: true,
decision: DECISION_ALIASES[second],
id: tokens[0],
};
}
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
const channel = params.command.channel;
const sender = params.command.senderId ?? "unknown";
return `${channel}:${sender}`;
}
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
const parsed = parseApproveCommand(normalized);
if (!parsed) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
const resolvedBy = buildResolvedByLabel(params);
try {
await callGateway({
method: "exec.approval.resolve",
params: { id: parsed.id, decision: parsed.decision },
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: `Chat approval (${resolvedBy})`,
mode: GATEWAY_CLIENT_MODES.BACKEND,
});
} catch (err) {
return {
shouldContinue: false,
reply: {
text: `❌ Failed to submit approval: ${String(err)}`,
},
};
}
return {
shouldContinue: false,
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
};
};
+2
View File
@@ -14,6 +14,7 @@ import {
handleWhoamiCommand,
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
@@ -45,6 +46,7 @@ const HANDLERS: CommandHandler[] = [
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,
+29
View File
@@ -0,0 +1,29 @@
export type ExecApprovalForwardingMode = "session" | "targets" | "both";
export type ExecApprovalForwardTarget = {
/** Channel id (e.g. "discord", "slack", or plugin channel id). */
channel: string;
/** Destination id (channel id, user id, etc. depending on channel). */
to: string;
/** Optional account id for multi-account channels. */
accountId?: string;
/** Optional thread id to reply inside a thread. */
threadId?: string | number;
};
export type ExecApprovalForwardingConfig = {
/** Enable forwarding exec approvals to chat channels. Default: false. */
enabled?: boolean;
/** Delivery mode (session=origin chat, targets=config targets, both=both). Default: session. */
mode?: ExecApprovalForwardingMode;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Explicit delivery targets (used when mode includes targets). */
targets?: ExecApprovalForwardTarget[];
};
export type ApprovalsConfig = {
exec?: ExecApprovalForwardingConfig;
};
+2
View File
@@ -1,4 +1,5 @@
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { ApprovalsConfig } from "./types.approvals.js";
import type { AuthConfig } from "./types.auth.js";
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
@@ -84,6 +85,7 @@ export type ClawdbotConfig = {
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
approvals?: ApprovalsConfig;
session?: SessionConfig;
web?: WebConfig;
channels?: ChannelsConfig;
+13
View File
@@ -72,6 +72,17 @@ export type DiscordActionConfig = {
channels?: boolean;
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Discord user IDs to receive approval prompts. Required if enabled. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
};
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -124,6 +135,8 @@ export type DiscordAccountConfig = {
guilds?: Record<string, DiscordGuildEntry>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding configuration. */
execApprovals?: DiscordExecApprovalConfig;
};
export type DiscordConfig = {
+1
View File
@@ -2,6 +2,7 @@
export * from "./types.agent-defaults.js";
export * from "./types.agents.js";
export * from "./types.approvals.js";
export * from "./types.auth.js";
export * from "./types.base.js";
export * from "./types.browser.js";
+28
View File
@@ -0,0 +1,28 @@
import { z } from "zod";
const ExecApprovalForwardTargetSchema = z
.object({
channel: z.string().min(1),
to: z.string().min(1),
accountId: z.string().optional(),
threadId: z.union([z.string(), z.number()]).optional(),
})
.strict();
const ExecApprovalForwardingSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.union([z.literal("session"), z.literal("targets"), z.literal("both")]).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
targets: z.array(ExecApprovalForwardTargetSchema).optional(),
})
.strict()
.optional();
export const ApprovalsSchema = z
.object({
exec: ExecApprovalForwardingSchema,
})
.strict()
.optional();
+9
View File
@@ -244,6 +244,15 @@ export const DiscordAccountSchema = z
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: z.array(z.union([z.string(), z.number()])).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
})
.strict()
.optional(),
})
.strict();
+2
View File
@@ -1,5 +1,6 @@
import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
@@ -220,6 +221,7 @@ export const ClawdbotSchema = z
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
approvals: ApprovalsSchema,
session: SessionSchema,
cron: z
.object({
+199
View File
@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
buildExecApprovalCustomId,
parseExecApprovalData,
type ExecApprovalRequest,
DiscordExecApprovalHandler,
} from "./exec-approvals.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
describe("buildExecApprovalCustomId", () => {
it("encodes approval id and action", () => {
const customId = buildExecApprovalCustomId("abc-123", "allow-once");
expect(customId).toBe("execapproval:id=abc-123;action=allow-once");
});
it("encodes special characters in approval id", () => {
const customId = buildExecApprovalCustomId("abc=123;test", "deny");
expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny");
});
});
describe("parseExecApprovalData", () => {
it("parses valid data", () => {
const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" });
expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
});
it("parses encoded data", () => {
const result = parseExecApprovalData({
id: "abc%3D123%3Btest",
action: "allow-always",
});
expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" });
});
it("rejects invalid action", () => {
const result = parseExecApprovalData({ id: "abc-123", action: "invalid" });
expect(result).toBeNull();
});
it("rejects missing id", () => {
const result = parseExecApprovalData({ action: "deny" });
expect(result).toBeNull();
});
it("rejects missing action", () => {
const result = parseExecApprovalData({ id: "abc-123" });
expect(result).toBeNull();
});
it("rejects null/undefined input", () => {
expect(parseExecApprovalData(null as any)).toBeNull();
expect(parseExecApprovalData(undefined as any)).toBeNull();
});
it("accepts all valid actions", () => {
expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once");
expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always");
expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny");
});
});
describe("roundtrip encoding", () => {
it("encodes and decodes correctly", () => {
const approvalId = "test-approval-with=special;chars&more";
const action = "allow-always" as const;
const customId = buildExecApprovalCustomId(approvalId, action);
// Parse the key=value pairs from the custom ID
const parts = customId.split(";");
const data: Record<string, string> = {};
for (const part of parts) {
const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/);
if (match) {
data[match[2]] = match[3];
}
}
const result = parseExecApprovalData(data);
expect(result).toEqual({ approvalId, action });
});
});
describe("DiscordExecApprovalHandler.shouldHandle", () => {
function createHandler(config: DiscordExecApprovalConfig) {
return new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
config,
cfg: {},
});
}
function createRequest(
overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
return {
id: "test-id",
request: {
command: "echo hello",
cwd: "/home/user",
host: "gateway",
agentId: "test-agent",
sessionKey: "agent:test-agent:discord:123",
...overrides,
},
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60000,
};
}
it("returns false when disabled", () => {
const handler = createHandler({ enabled: false, approvers: ["123"] });
expect(handler.shouldHandle(createRequest())).toBe(false);
});
it("returns false when no approvers", () => {
const handler = createHandler({ enabled: true, approvers: [] });
expect(handler.shouldHandle(createRequest())).toBe(false);
});
it("returns true with minimal config", () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
expect(handler.shouldHandle(createRequest())).toBe(true);
});
it("filters by agent ID", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
agentFilter: ["allowed-agent"],
});
expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
});
it("filters by session key substring", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
sessionFilter: ["discord"],
});
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
true,
);
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
false,
);
expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
});
it("filters by session key regex", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
sessionFilter: ["^agent:.*:discord:"],
});
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
true,
);
expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe(
false,
);
});
it("combines agent and session filters", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
agentFilter: ["my-agent"],
sessionFilter: ["discord"],
});
expect(
handler.shouldHandle(
createRequest({
agentId: "my-agent",
sessionKey: "agent:my-agent:discord:123",
}),
),
).toBe(true);
expect(
handler.shouldHandle(
createRequest({
agentId: "other-agent",
sessionKey: "agent:other:discord:123",
}),
),
).toBe(false);
expect(
handler.shouldHandle(
createRequest({
agentId: "my-agent",
sessionKey: "agent:my-agent:telegram:123",
}),
),
).toBe(false);
});
});
+549
View File
@@ -0,0 +1,549 @@
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type { ClawdbotConfig } from "../../config/config.js";
import { GatewayClient } from "../../gateway/client.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import { createDiscordClient } from "../send.shared.js";
import { logDebug, logError } from "../../logger.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import type { RuntimeEnv } from "../../runtime.js";
const EXEC_APPROVAL_KEY = "execapproval";
export type ExecApprovalRequest = {
id: string;
request: {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
ts: number;
};
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId: NodeJS.Timeout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
if (!data || typeof data !== "object") return null;
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawId = coerce(data.id);
const rawAction = coerce(data.action);
if (!rawId || !rawAction) return null;
const action = rawAction as ExecApprovalDecision;
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
return null;
}
return {
approvalId: decodeCustomIdValue(rawId),
action,
};
}
function formatExecApprovalEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview =
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
const fields: Array<{ name: string; value: string; inline: boolean }> = [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
];
if (request.request.cwd) {
fields.push({
name: "Working Directory",
value: request.request.cwd,
inline: true,
});
}
if (request.request.host) {
fields.push({
name: "Host",
value: request.request.host,
inline: true,
});
}
if (request.request.agentId) {
fields.push({
name: "Agent",
value: request.request.agentId,
inline: true,
});
}
return {
title: "Exec Approval Required",
description: "A command needs your approval.",
color: 0xffa500, // Orange
fields,
footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatResolvedEmbed(
request: ExecApprovalRequest,
decision: ExecApprovalDecision,
resolvedBy?: string | null,
) {
const commandText = request.request.command;
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
const decisionLabel =
decision === "allow-once"
? "Allowed (once)"
: decision === "allow-always"
? "Allowed (always)"
: "Denied";
const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287;
return {
title: `Exec Approval: ${decisionLabel}`,
description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
color,
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatExpiredEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
return {
title: "Exec Approval: Expired",
description: "This approval request has expired.",
color: 0x99aab5, // Gray
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
export type DiscordExecApprovalHandlerOpts = {
token: string;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, ExecApprovalRequest>();
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
}
shouldHandle(request: ExecApprovalRequest): boolean {
const config = this.opts.config;
if (!config.enabled) return false;
if (!config.approvers || config.approvers.length === 0) return false;
// Check agent filter
if (config.agentFilter?.length) {
if (!request.request.agentId) return false;
if (!config.agentFilter.includes(request.request.agentId)) return false;
}
// Check session filter (substring match)
if (config.sessionFilter?.length) {
const session = request.request.sessionKey;
if (!session) return false;
const matches = config.sessionFilter.some((p) => {
try {
return session.includes(p) || new RegExp(p).test(session);
} catch {
return session.includes(p);
}
});
if (!matches) return false;
}
return true;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
const config = this.opts.config;
if (!config.enabled) {
logDebug("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord exec approvals: starting handler");
this.gatewayClient = new GatewayClient({
url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789",
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "Discord Exec Approvals",
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes: ["operator.approvals"],
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
},
});
this.gatewayClient.start();
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
// Clear all pending timeouts
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.requestCache.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
logDebug("discord exec approvals: stopped");
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
const request = evt.payload as ExecApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "exec.approval.resolved") {
const resolved = evt.payload as ExecApprovalResolved;
void this.handleApprovalResolved(resolved);
}
}
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) return;
logDebug(`discord exec approvals: received request ${request.id}`);
this.requestCache.set(request.id, request);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const embed = formatExecApprovalEmbed(request);
// Build action rows with buttons
const components = [
{
type: 1, // ACTION_ROW
components: [
{
type: 2, // BUTTON
style: ButtonStyle.Success,
label: "Allow once",
custom_id: buildExecApprovalCustomId(request.id, "allow-once"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Primary,
label: "Always allow",
custom_id: buildExecApprovalCustomId(request.id, "allow-always"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Danger,
label: "Deny",
custom_id: buildExecApprovalCustomId(request.id, "deny"),
},
],
},
];
const approvers = this.opts.config.approvers ?? [];
for (const approver of approvers) {
const userId = String(approver);
try {
// Create DM channel
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
// Send message with embed and buttons
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body: {
embeds: [embed],
components,
},
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord exec approvals: failed to send message to user ${userId}`);
continue;
}
// Set up timeout
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id);
}, timeoutMs);
this.pending.set(request.id, {
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
}
}
}
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) return;
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
const request = this.requestCache.get(resolved.id);
this.requestCache.delete(resolved.id);
if (!request) return;
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy),
);
}
private async handleApprovalTimeout(approvalId: string): Promise<void> {
const pending = this.pending.get(approvalId);
if (!pending) return;
this.pending.delete(approvalId);
const request = this.requestCache.get(approvalId);
this.requestCache.delete(approvalId);
if (!request) return;
logDebug(`discord exec approvals: timeout for ${approvalId}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatExpiredEmbed(request),
);
}
private async updateMessage(
channelId: string,
messageId: string,
embed: ReturnType<typeof formatExpiredEmbed>,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: {
embeds: [embed],
components: [], // Remove buttons
},
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
try {
await this.gatewayClient.request("exec.approval.resolve", {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseExecApprovalData(data);
if (!parsed) {
try {
await interaction.update({
content: "This approval is no longer valid.",
components: [],
});
} catch {
// Interaction may have expired
}
return;
}
const decisionLabel =
parsed.action === "allow-once"
? "Allowed (once)"
: parsed.action === "allow-always"
? "Allowed (always)"
: "Denied";
// Update the message immediately to show the decision
try {
await interaction.update({
content: `Submitting decision: **${decisionLabel}**...`,
components: [], // Remove buttons
});
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content:
"Failed to submit approval decision. The request may have expired or already been resolved.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}
+35 -8
View File
@@ -37,6 +37,7 @@ import {
createDiscordCommandArgFallbackButton,
createDiscordNativeCommand,
} from "./native-command.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
export type MonitorDiscordOpts = {
token?: string;
@@ -406,6 +407,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}),
);
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = execApprovalsConfig.enabled
? new DiscordExecApprovalHandler({
token,
accountId: account.accountId,
config: execApprovalsConfig,
cfg,
runtime,
})
: null;
const components = [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
}
const client = new Client(
{
baseUrl: "http://localhost",
@@ -418,14 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
{
commands,
listeners: [],
components: [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
],
components,
},
[
new GatewayPlugin({
@@ -510,6 +529,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
// Start exec approvals handler after client is ready
if (execApprovalsHandler) {
await execApprovalsHandler.start();
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
@@ -575,6 +599,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (helloTimeoutId) clearTimeout(helloTimeoutId);
gatewayEmitter?.removeListener("debug", onGatewayDebug);
abortSignal?.removeEventListener("abort", onAbort);
if (execApprovalsHandler) {
await execApprovalsHandler.stop();
}
}
}
+20 -1
View File
@@ -1,4 +1,5 @@
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import {
ErrorCodes,
@@ -9,7 +10,10 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers {
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
return {
"exec.approval.request": async ({ params, respond, context }) => {
if (!validateExecApprovalRequestParams(params)) {
@@ -69,6 +73,16 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
},
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
});
const decision = await decisionPromise;
respond(
true,
@@ -112,6 +126,11 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
{ id: p.id, decision, resolvedBy, ts: Date.now() },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
},
};
+5 -1
View File
@@ -43,6 +43,7 @@ import {
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createChannelManager } from "./server-channels.js";
import { createAgentEventHandler } from "./server-chat.js";
@@ -396,7 +397,10 @@ export async function startGatewayServer(
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
const execApprovalManager = new ExecApprovalManager();
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
const execApprovalForwarder = createExecApprovalForwarder();
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
forwarder: execApprovalForwarder,
});
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;
+77
View File
@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
id: "req-1",
request: {
command: "echo hello",
agentId: "main",
sessionKey: "agent:main:main",
},
createdAtMs: 1000,
expiresAtMs: 6000,
};
afterEach(() => {
vi.useRealTimers();
});
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await forwarder.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "slack:U1",
ts: 2000,
});
expect(deliver).toHaveBeenCalledTimes(2);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
it("forwards to explicit targets and expires", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
});
+282
View File
@@ -0,0 +1,282 @@
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import type {
ExecApprovalForwardingConfig,
ExecApprovalForwardTarget,
} from "../config/types.approvals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
const log = createSubsystemLogger("gateway/exec-approvals");
export type ExecApprovalRequest = {
id: string;
request: {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
ts: number;
};
type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" };
type PendingApproval = {
request: ExecApprovalRequest;
targets: ForwardTarget[];
timeoutId: NodeJS.Timeout | null;
};
export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<void>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
stop: () => void;
};
export type ExecApprovalForwarderDeps = {
getConfig?: () => ClawdbotConfig;
deliver?: typeof deliverOutboundPayloads;
nowMs?: () => number;
resolveSessionTarget?: (params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}) => ExecApprovalForwardTarget | null;
};
const DEFAULT_MODE = "session" as const;
function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) {
return mode ?? DEFAULT_MODE;
}
function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
try {
return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey);
} catch {
return sessionKey.includes(pattern);
}
});
}
function shouldForward(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
const config = params.config;
if (!config?.enabled) return false;
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId) return false;
if (!config.agentFilter.includes(agentId)) return false;
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) return false;
if (!matchSessionFilter(sessionKey, config.sessionFilter)) return false;
}
return true;
}
function buildTargetKey(target: ExecApprovalForwardTarget): string {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
const accountId = target.accountId ?? "";
const threadId = target.threadId ?? "";
return [channel, target.to, accountId, threadId].join(":");
}
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
lines.push(`Command: ${request.request.command}`);
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
if (request.request.host) lines.push(`Host: ${request.request.host}`);
if (request.request.agentId) lines.push(`Agent: ${request.request.agentId}`);
if (request.request.security) lines.push(`Security: ${request.request.security}`);
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
}
function decisionLabel(decision: ExecApprovalDecision): string {
if (decision === "allow-once") return "allowed once";
if (decision === "allow-always") return "allowed always";
return "denied";
}
function buildResolvedMessage(resolved: ExecApprovalResolved) {
const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`;
const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : "";
return `${base}${by} ID: ${resolved.id}`;
}
function buildExpiredMessage(request: ExecApprovalRequest) {
return `⏱️ Exec approval expired. ID: ${request.id}`;
}
function defaultResolveSessionTarget(params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}): ExecApprovalForwardTarget | null {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) return null;
const parsed = parseAgentSessionKey(sessionKey);
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (!entry) return null;
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
if (!target.channel || !target.to) return null;
if (!isDeliverableMessageChannel(target.channel)) return null;
return {
channel: target.channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
};
}
async function deliverToTargets(params: {
cfg: ClawdbotConfig;
targets: ForwardTarget[];
text: string;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
const deliveries = params.targets.map(async (target) => {
if (params.shouldSend && !params.shouldSend()) return;
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (!isDeliverableMessageChannel(channel)) return;
try {
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
}
});
await Promise.allSettled(deliveries);
}
export function createExecApprovalForwarder(
deps: ExecApprovalForwarderDeps = {},
): ExecApprovalForwarder {
const getConfig = deps.getConfig ?? loadConfig;
const deliver = deps.deliver ?? deliverOutboundPayloads;
const nowMs = deps.nowMs ?? Date.now;
const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget;
const pending = new Map<string, PendingApproval>();
const handleRequested = async (request: ExecApprovalRequest) => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) return;
const mode = normalizeMode(config?.mode);
const targets: ForwardTarget[] = [];
const seen = new Set<string>();
if (mode === "session" || mode === "both") {
const sessionTarget = resolveSessionTarget({ cfg, request });
if (sessionTarget) {
const key = buildTargetKey(sessionTarget);
if (!seen.has(key)) {
seen.add(key);
targets.push({ ...sessionTarget, source: "session" });
}
}
}
if (mode === "targets" || mode === "both") {
const explicitTargets = config?.targets ?? [];
for (const target of explicitTargets) {
const key = buildTargetKey(target);
if (seen.has(key)) continue;
seen.add(key);
targets.push({ ...target, source: "target" });
}
}
if (targets.length === 0) return;
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
void (async () => {
const entry = pending.get(request.id);
if (!entry) return;
pending.delete(request.id);
const expiredText = buildExpiredMessage(request);
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
})();
}, expiresInMs);
timeoutId.unref?.();
const pendingEntry: PendingApproval = { request, targets, timeoutId };
pending.set(request.id, pendingEntry);
if (pending.get(request.id) !== pendingEntry) return;
const text = buildRequestMessage(request, nowMs());
await deliverToTargets({
cfg,
targets,
text,
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
});
};
const handleResolved = async (resolved: ExecApprovalResolved) => {
const entry = pending.get(resolved.id);
if (!entry) return;
if (entry.timeoutId) clearTimeout(entry.timeoutId);
pending.delete(resolved.id);
const cfg = getConfig();
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets: entry.targets, text, deliver });
};
const stop = () => {
for (const entry of pending.values()) {
if (entry.timeoutId) clearTimeout(entry.timeoutId);
}
pending.clear();
};
return { handleRequested, handleResolved, stop };
}
export function shouldForwardExecApproval(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
return shouldForward(params);
}
+35 -4
View File
@@ -35,6 +35,7 @@ import {
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
@@ -88,6 +89,14 @@ export type HeartbeatSummary = {
const DEFAULT_HEARTBEAT_TARGET = "last";
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
// Prompt used when an async exec has completed and the result should be relayed to the user.
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
// instead of just "HEARTBEAT_OK".
const EXEC_EVENT_PROMPT =
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong.";
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
@@ -453,11 +462,13 @@ export async function runHeartbeatOnce(opts: {
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
// This saves API calls/costs when the file is effectively empty (only comments/headers).
// EXCEPTION: Don't skip for exec events - they have pending system events to process.
const isExecEventReason = opts.reason === "exec-event";
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
@@ -483,12 +494,20 @@ export async function runHeartbeatOnce(opts: {
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
// Check if this is an exec event with pending exec completion system events.
// If so, use a specialized prompt that instructs the model to relay the result
// instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
const isExecEvent = opts.reason === "exec-event";
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished"));
const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
From: sender,
To: sender,
Provider: "heartbeat",
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
SessionKey: sessionKey,
};
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
@@ -558,7 +577,19 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
// The model should be responding with exec results, not ack tokens.
// Also, if normalized.text is empty due to token stripping but we have exec completion,
// fall back to the original reply text.
const execFallbackText =
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
? replyPayload.text.trim()
: null;
if (execFallbackText) {
normalized.text = execFallbackText;
normalized.shouldSkip = false;
}
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
if (shouldSkipMain && reasoningPayloads.length === 0) {
await restoreHeartbeatUpdatedAt({
storePath,
@@ -177,13 +177,11 @@ describe("createTelegramBot", () => {
expect(throttlerSpy).toHaveBeenCalledTimes(1);
expect(useSpy).toHaveBeenCalledWith("throttler");
});
it("forces native fetch only under Bun", () => {
it("uses wrapped fetch when global fetch is available", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
(globalThis as { Bun?: unknown }).Bun = {};
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeTypeOf("function");
@@ -194,33 +192,6 @@ describe("createTelegramBot", () => {
expect(clientFetch).not.toBe(fetchSpy);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("does not force native fetch on Node", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
if (originalBun !== undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
}
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeUndefined();
expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("passes timeoutSeconds even without a custom fetch", () => {
+1 -31
View File
@@ -309,13 +309,11 @@ describe("createTelegramBot", () => {
expect(registered.some((command) => reserved.includes(command.command))).toBe(false);
});
it("forces native fetch only under Bun", () => {
it("uses wrapped fetch when global fetch is available", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
(globalThis as { Bun?: unknown }).Bun = {};
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeTypeOf("function");
@@ -326,34 +324,6 @@ describe("createTelegramBot", () => {
expect(clientFetch).not.toBe(fetchSpy);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
it("does not force native fetch on Node", () => {
const originalFetch = globalThis.fetch;
const originalBun = (globalThis as { Bun?: unknown }).Bun;
const fetchSpy = vi.fn() as unknown as typeof fetch;
globalThis.fetch = fetchSpy;
try {
if (originalBun !== undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
}
createTelegramBot({ token: "tok" });
const fetchImpl = resolveTelegramFetch();
expect(fetchImpl).toBeUndefined();
expect(botCtorSpy).toHaveBeenCalledWith("tok", undefined);
} finally {
globalThis.fetch = originalFetch;
if (originalBun === undefined) {
delete (globalThis as { Bun?: unknown }).Bun;
} else {
(globalThis as { Bun?: unknown }).Bun = originalBun;
}
}
});
+1 -2
View File
@@ -117,8 +117,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
const telegramCfg = account.config;
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
const shouldProvideFetch = Boolean(fetchImpl);
const timeoutSeconds =
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
+19 -28
View File
@@ -1,37 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveTelegramFetch } from "./fetch.js";
describe("resolveTelegramFetch", () => {
it("wraps proxy fetch to normalize foreign abort signals", async () => {
let seenSignal: AbortSignal | undefined;
const proxyFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
seenSignal = init?.signal as AbortSignal | undefined;
return {} as Response;
});
const originalFetch = globalThis.fetch;
const fetcher = resolveTelegramFetch(proxyFetch);
expect(fetcher).toBeTypeOf("function");
afterEach(() => {
if (originalFetch) {
globalThis.fetch = originalFetch;
} else {
delete (globalThis as { fetch?: typeof fetch }).fetch;
}
});
let abortHandler: (() => void) | null = null;
const fakeSignal = {
aborted: false,
addEventListener: (event: string, handler: () => void) => {
if (event === "abort") abortHandler = handler;
},
removeEventListener: (event: string, handler: () => void) => {
if (event === "abort" && abortHandler === handler) abortHandler = null;
},
} as AbortSignal;
it("returns wrapped global fetch when available", () => {
const fetchMock = vi.fn(async () => ({}));
globalThis.fetch = fetchMock as unknown as typeof fetch;
const resolved = resolveTelegramFetch();
expect(resolved).toBeTypeOf("function");
});
const promise = fetcher!("https://example.com", { signal: fakeSignal });
expect(proxyFetch).toHaveBeenCalledOnce();
expect(seenSignal).toBeInstanceOf(AbortSignal);
expect(seenSignal).not.toBe(fakeSignal);
abortHandler?.();
expect(seenSignal?.aborted).toBe(true);
await promise;
it("prefers proxy fetch when provided", () => {
const fetchMock = vi.fn(async () => ({}));
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
expect(resolved).toBeTypeOf("function");
});
});
+1 -3
View File
@@ -1,10 +1,8 @@
import { resolveFetch } from "../infra/fetch.js";
// Bun-only: force native fetch to avoid grammY's Node shim under Bun.
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
if (proxyFetch) return resolveFetch(proxyFetch);
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
if (!isBun) return undefined;
const fetchImpl = resolveFetch();
if (!fetchImpl) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");