Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4e4e54055 | ||
|
|
a8a3c85b4c |
No files matched your search
@@ -8,17 +8,10 @@ Docs: https://docs.clawd.bot
|
|||||||
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
|
- 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: add verbose installer troubleshooting guidance.
|
||||||
- Docs: update Fly.io guide notes.
|
- 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
|
### 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.
|
- Web UI: hide internal `message_id` hints in chat bubbles.
|
||||||
- Heartbeat: normalize target identifiers for consistent routing.
|
- 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.
|
- 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.
|
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
|
||||||
|
|
||||||
|
|||||||
+4
-67
@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
auth: "aws-sdk",
|
auth: "aws-sdk",
|
||||||
models: [
|
models: [
|
||||||
{
|
{
|
||||||
id: "anthropic.claude-opus-4-5-20251101-v1:0",
|
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
|
||||||
name: "Claude Opus 4.5 (Bedrock)",
|
name: "Claude 3.7 Sonnet (Bedrock)",
|
||||||
reasoning: true,
|
reasoning: true,
|
||||||
input: ["text", "image"],
|
input: ["text"],
|
||||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||||
contextWindow: 200000,
|
contextWindow: 200000,
|
||||||
maxTokens: 8192
|
maxTokens: 8192
|
||||||
@@ -89,75 +89,12 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
|
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-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
|
## Notes
|
||||||
|
|
||||||
- Bedrock requires **model access** enabled in your AWS account/region.
|
- Bedrock requires **model access** enabled in your AWS account/region.
|
||||||
|
|||||||
+1
-25
@@ -19,7 +19,6 @@ 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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
|
||||||
@@ -43,7 +42,6 @@ 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 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)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [Skills and automation](#skills-and-automation)
|
||||||
@@ -324,22 +322,9 @@ 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:
|
If you still can't reach the site, the docs are mirrored on GitHub:
|
||||||
https://github.com/clawdbot/clawdbot/tree/main/docs
|
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?
|
### How do I install the beta version, and what’s the difference between beta and dev?
|
||||||
|
|
||||||
**Beta** is the npm dist‑tag `beta` (may match `latest`).
|
**Beta** is a prerelease tag (`vYYYY.M.D-beta.N`) published to the npm dist‑tag `beta`.
|
||||||
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
|
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
|
||||||
|
|
||||||
One‑liners (macOS/Linux):
|
One‑liners (macOS/Linux):
|
||||||
@@ -558,15 +543,6 @@ brew install <formula>
|
|||||||
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
|
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.
|
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?
|
### 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.
|
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.
|
||||||
|
|||||||
@@ -11,13 +11,10 @@ Last updated: 2026-01-21
|
|||||||
|
|
||||||
Clawdbot ships three update channels:
|
Clawdbot ships three update channels:
|
||||||
|
|
||||||
- **stable**: npm dist-tag `latest`.
|
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
|
||||||
- **beta**: npm dist-tag `beta` (builds under test).
|
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
|
||||||
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
|
- **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
|
## Switching channels
|
||||||
|
|
||||||
Git checkout:
|
Git checkout:
|
||||||
@@ -28,7 +25,7 @@ clawdbot update --channel beta
|
|||||||
clawdbot update --channel dev
|
clawdbot update --channel dev
|
||||||
```
|
```
|
||||||
|
|
||||||
- `stable`/`beta` check out the latest matching tag (often the same tag).
|
- `stable`/`beta` check out the latest matching tag.
|
||||||
- `dev` switches to `main` and rebases on the upstream.
|
- `dev` switches to `main` and rebases on the upstream.
|
||||||
|
|
||||||
npm/pnpm global install:
|
npm/pnpm global install:
|
||||||
@@ -59,11 +56,12 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
|
|||||||
|
|
||||||
## Tagging best practices
|
## Tagging best practices
|
||||||
|
|
||||||
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
|
||||||
|
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
|
||||||
- Keep tags immutable: never move or reuse a tag.
|
- Keep tags immutable: never move or reuse a tag.
|
||||||
- npm dist-tags remain the source of truth for npm installs:
|
- Publish dist-tags alongside git tags:
|
||||||
- `latest` → stable
|
- `latest` → stable
|
||||||
- `beta` → candidate build
|
- `beta` → prerelease
|
||||||
- `dev` → main snapshot (optional)
|
- `dev` → main snapshot (optional)
|
||||||
|
|
||||||
## macOS app availability
|
## macOS app availability
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ read_when:
|
|||||||
# Elevated Mode (/elevated directives)
|
# Elevated Mode (/elevated directives)
|
||||||
|
|
||||||
## What it does
|
## What it does
|
||||||
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
|
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
|
||||||
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
|
- `/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`).
|
- `/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).
|
- 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`.
|
- 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.
|
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
|
||||||
@@ -19,8 +18,8 @@ read_when:
|
|||||||
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
|
- **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.
|
- **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.
|
- **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; `full` also sets `security=full`.
|
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
|
||||||
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
|
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
|
||||||
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
|
- **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.
|
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
|
||||||
|
|
||||||
|
|||||||
@@ -157,36 +157,6 @@ Actions:
|
|||||||
- **Always allow** → add to allowlist + run
|
- **Always allow** → add to allowlist + run
|
||||||
- **Deny** → block
|
- **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
|
### macOS IPC flow
|
||||||
```
|
```
|
||||||
Gateway -> Node Service (WS)
|
Gateway -> Node Service (WS)
|
||||||
|
|||||||
+1
-1
@@ -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`
|
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
|
||||||
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
|
||||||
- `node` (string): node id/name for `host=node`
|
- `node` (string): node id/name for `host=node`
|
||||||
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
|
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- `host` defaults to `sandbox`.
|
- `host` defaults to `sandbox`.
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ Text + native (when enabled):
|
|||||||
- `/skill <name> [input]` (run a skill by name)
|
- `/skill <name> [input]` (run a skill by name)
|
||||||
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
|
||||||
- `/allowlist` (list/add/remove allowlist entries)
|
- `/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)
|
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
|
||||||
- `/whoami` (show your sender id; alias: `/id`)
|
- `/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)
|
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)
|
||||||
|
|||||||
@@ -1189,8 +1189,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
// Body uses inline [[reply_to:N]] tag format
|
// Body uses just the ID (no sender) for token savings
|
||||||
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
|
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
|
||||||
|
expect(callArgs.ctx.Body).toContain("original message");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
it("hydrates missing reply sender/body from the recent-message cache", async () => {
|
||||||
@@ -1259,8 +1260,9 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
|
||||||
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
|
||||||
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
|
||||||
// Body uses inline [[reply_to:N]] tag format with short ID
|
// Body uses just the short ID (no sender) for token savings
|
||||||
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
|
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
|
||||||
|
expect(callArgs.ctx.Body).toContain("original message (cached)");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
|
||||||
@@ -1303,88 +1305,6 @@ 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", () => {
|
describe("ack reactions", () => {
|
||||||
it("sends ack reaction when configured", async () => {
|
it("sends ack reaction when configured", async () => {
|
||||||
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
const { sendBlueBubblesReaction } = await import("./reactions.js");
|
||||||
@@ -1839,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("reacted with ❤️ [[reply_to:"),
|
expect.stringContaining("reaction added"),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1879,7 +1799,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
|
||||||
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
|
expect.stringContaining("reaction removed"),
|
||||||
expect.any(Object),
|
expect.any(Object),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -1985,7 +1905,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
handle: { address: "+15551234567" },
|
handle: { address: "+15551234567" },
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
guid: "p:1/msg-uuid-12345",
|
guid: "msg-uuid-12345",
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
},
|
||||||
@@ -2001,7 +1921,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
|
||||||
// MessageSid should be short ID "1" instead of full UUID
|
// MessageSid should be short ID "1" instead of full UUID
|
||||||
expect(callArgs.ctx.MessageSid).toBe("1");
|
expect(callArgs.ctx.MessageSid).toBe("1");
|
||||||
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
|
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("resolves short ID back to UUID", async () => {
|
it("resolves short ID back to UUID", async () => {
|
||||||
@@ -2025,7 +1945,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
handle: { address: "+15551234567" },
|
handle: { address: "+15551234567" },
|
||||||
isGroup: false,
|
isGroup: false,
|
||||||
isFromMe: false,
|
isFromMe: false,
|
||||||
guid: "p:1/msg-uuid-12345",
|
guid: "msg-uuid-12345",
|
||||||
chatGuid: "iMessage;-;+15551234567",
|
chatGuid: "iMessage;-;+15551234567",
|
||||||
date: Date.now(),
|
date: Date.now(),
|
||||||
},
|
},
|
||||||
@@ -2038,7 +1958,7 @@ describe("BlueBubbles webhook monitor", () => {
|
|||||||
await flushAsync();
|
await flushAsync();
|
||||||
|
|
||||||
// The short ID "1" should resolve back to the full UUID
|
// The short ID "1" should resolve back to the full UUID
|
||||||
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
|
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns UUID unchanged when not in cache", () => {
|
it("returns UUID unchanged when not in cache", () => {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
|
|||||||
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
|
||||||
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
|
||||||
|
|
||||||
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
|
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
|
||||||
const blueBubblesShortIdToUuid = new Map<string, string>();
|
const blueBubblesShortIdToUuid = new Map<string, string>();
|
||||||
const blueBubblesUuidToShortId = new Map<string, string>();
|
const blueBubblesUuidToShortId = new Map<string, string>();
|
||||||
let blueBubblesShortIdCounter = 0;
|
let blueBubblesShortIdCounter = 0;
|
||||||
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
return { ...entry, shortId: "" };
|
return { ...entry, shortId: "" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if we already have a short ID for this GUID
|
// Check if we already have a short ID for this UUID
|
||||||
let shortId = blueBubblesUuidToShortId.get(messageId);
|
let shortId = blueBubblesUuidToShortId.get(messageId);
|
||||||
if (!shortId) {
|
if (!shortId) {
|
||||||
shortId = generateShortId();
|
shortId = generateShortId();
|
||||||
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
|
|||||||
blueBubblesUuidToShortId.set(messageId, shortId);
|
blueBubblesUuidToShortId.set(messageId, shortId);
|
||||||
}
|
}
|
||||||
|
|
||||||
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
|
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
|
||||||
|
|
||||||
// Refresh insertion order.
|
// Refresh insertion order.
|
||||||
blueBubblesReplyCacheByMessageId.delete(messageId);
|
blueBubblesReplyCacheByMessageId.delete(messageId);
|
||||||
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
|
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
|
||||||
* Returns the input unchanged if it's already a GUID or not found in the mapping.
|
* Returns the input unchanged if it's already a UUID or not found in the mapping.
|
||||||
*/
|
*/
|
||||||
export function resolveBlueBubblesMessageId(
|
export function resolveBlueBubblesMessageId(
|
||||||
shortOrUuid: string,
|
shortOrUuid: string,
|
||||||
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets the short ID for a message GUID, if one exists.
|
* Gets the short ID for a UUID, if one exists.
|
||||||
*/
|
*/
|
||||||
function getShortIdForUuid(uuid: string): string | undefined {
|
function getShortIdForUuid(uuid: string): string | undefined {
|
||||||
return blueBubblesUuidToShortId.get(uuid.trim());
|
return blueBubblesUuidToShortId.get(uuid.trim());
|
||||||
@@ -390,15 +390,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
|
|||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
|
const REPLY_BODY_TRUNCATE_LENGTH = 60;
|
||||||
function formatReplyTag(message: {
|
|
||||||
|
function formatReplyContext(message: {
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToShortId?: string;
|
replyToShortId?: string;
|
||||||
|
replyToBody?: string;
|
||||||
|
replyToSender?: string;
|
||||||
}): string | null {
|
}): string | null {
|
||||||
// Prefer short ID
|
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
|
||||||
const rawId = message.replyToShortId || message.replyToId;
|
// Prefer short ID for token savings
|
||||||
if (!rawId) return null;
|
const displayId = message.replyToShortId || message.replyToId;
|
||||||
return `[[reply_to:${rawId}]]`;
|
// 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]`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
|
||||||
@@ -615,10 +629,6 @@ type NormalizedWebhookMessage = {
|
|||||||
fromMe?: boolean;
|
fromMe?: boolean;
|
||||||
attachments?: BlueBubblesAttachment[];
|
attachments?: BlueBubblesAttachment[];
|
||||||
balloonBundleId?: string;
|
balloonBundleId?: string;
|
||||||
associatedMessageGuid?: string;
|
|
||||||
associatedMessageType?: number;
|
|
||||||
associatedMessageEmoji?: string;
|
|
||||||
isTapback?: boolean;
|
|
||||||
participants?: BlueBubblesParticipant[];
|
participants?: BlueBubblesParticipant[];
|
||||||
replyToId?: string;
|
replyToId?: string;
|
||||||
replyToBody?: string;
|
replyToBody?: string;
|
||||||
@@ -655,113 +665,6 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
|
|||||||
[3005, { emoji: "❓", action: "removed" }],
|
[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 {
|
function maskSecret(value: string): string {
|
||||||
if (value.length <= 6) return "***";
|
if (value.length <= 6) return "***";
|
||||||
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
return `${value.slice(0, 2)}***${value.slice(-2)}`;
|
||||||
@@ -902,25 +805,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
readString(message, "messageId") ??
|
readString(message, "messageId") ??
|
||||||
undefined;
|
undefined;
|
||||||
const balloonBundleId = readString(message, "balloonBundleId");
|
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 =
|
const timestampRaw =
|
||||||
readNumber(message, "date") ??
|
readNumber(message, "date") ??
|
||||||
@@ -951,10 +835,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
|
|||||||
fromMe,
|
fromMe,
|
||||||
attachments: extractAttachments(message),
|
attachments: extractAttachments(message),
|
||||||
balloonBundleId,
|
balloonBundleId,
|
||||||
associatedMessageGuid,
|
|
||||||
associatedMessageType,
|
|
||||||
associatedMessageEmoji,
|
|
||||||
isTapback,
|
|
||||||
participants: normalizedParticipants,
|
participants: normalizedParticipants,
|
||||||
replyToId: replyMetadata.replyToId,
|
replyToId: replyMetadata.replyToId,
|
||||||
replyToBody: replyMetadata.replyToBody,
|
replyToBody: replyMetadata.replyToBody,
|
||||||
@@ -976,13 +856,8 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
|
|||||||
if (!associatedGuid || associatedType === undefined) return null;
|
if (!associatedGuid || associatedType === undefined) return null;
|
||||||
|
|
||||||
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
const mapping = REACTION_TYPE_MAP.get(associatedType);
|
||||||
const associatedEmoji =
|
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
|
||||||
readString(message, "associatedMessageEmoji") ??
|
const action = mapping?.action ?? "added";
|
||||||
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 handleValue = message.handle ?? message.sender;
|
||||||
const handle =
|
const handle =
|
||||||
@@ -1247,21 +1122,7 @@ async function processMessage(
|
|||||||
const text = message.text.trim();
|
const text = message.text.trim();
|
||||||
const attachments = message.attachments ?? [];
|
const attachments = message.attachments ?? [];
|
||||||
const placeholder = buildMessagePlaceholder(message);
|
const placeholder = buildMessagePlaceholder(message);
|
||||||
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
|
const rawBody = text || placeholder;
|
||||||
// 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();
|
const cacheMessageId = message.messageId?.trim();
|
||||||
let messageShortId: string | undefined;
|
let messageShortId: string | undefined;
|
||||||
@@ -1588,11 +1449,7 @@ async function processMessage(
|
|||||||
let replyToSender = message.replyToSender;
|
let replyToSender = message.replyToSender;
|
||||||
let replyToShortId: string | undefined;
|
let replyToShortId: string | undefined;
|
||||||
|
|
||||||
if (isTapbackMessage && tapbackContext?.replyToId) {
|
if (replyToId && (!replyToBody || !replyToSender)) {
|
||||||
replyToId = tapbackContext.replyToId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replyToId) {
|
|
||||||
const cached = resolveReplyContextFromCache({
|
const cached = resolveReplyContextFromCache({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
replyToId,
|
replyToId,
|
||||||
@@ -1620,15 +1477,8 @@ async function processMessage(
|
|||||||
replyToShortId = getShortIdForUuid(replyToId);
|
replyToShortId = getShortIdForUuid(replyToId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use inline [[reply_to:N]] tag format
|
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
|
||||||
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
|
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
|
||||||
// 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 fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
|
||||||
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
|
||||||
const groupMembers = isGroup
|
const groupMembers = isGroup
|
||||||
@@ -2021,11 +1871,7 @@ async function processReaction(
|
|||||||
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
|
||||||
// Use short ID for token savings
|
// Use short ID for token savings
|
||||||
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
|
||||||
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
|
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
|
||||||
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, {
|
core.system.enqueueSystemEvent(text, {
|
||||||
sessionKey: route.sessionKey,
|
sessionKey: route.sessionKey,
|
||||||
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const TlonAuthorizationSchema = z.object({
|
export const TlonAuthorizationSchema = z.object({
|
||||||
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
|
channelRules: z.record(TlonChannelRuleSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TlonAccountSchema = z.object({
|
export const TlonAccountSchema = z.object({
|
||||||
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
|
|||||||
showModelSignature: z.boolean().optional(),
|
showModelSignature: z.boolean().optional(),
|
||||||
authorization: TlonAuthorizationSchema.optional(),
|
authorization: TlonAuthorizationSchema.optional(),
|
||||||
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
defaultAuthorizedShips: z.array(ShipSchema).optional(),
|
||||||
accounts: z.record(z.string(), TlonAccountSchema).optional(),
|
accounts: z.record(TlonAccountSchema).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { scot, da } from "@urbit/aura";
|
import { unixToDa, formatUd } from "@urbit/aura";
|
||||||
|
|
||||||
export type TlonPokeApi = {
|
export type TlonPokeApi = {
|
||||||
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
|
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) {
|
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
|
||||||
const story = [{ inline: [text] }];
|
const story = [{ inline: [text] }];
|
||||||
const sentAt = Date.now();
|
const sentAt = Date.now();
|
||||||
const idUd = scot('ud', da.fromUnix(sentAt));
|
const idUd = formatUd(unixToDa(sentAt));
|
||||||
const id = `${fromShip}/${idUd}`;
|
const id = `${fromShip}/${idUd}`;
|
||||||
|
|
||||||
const delta = {
|
const delta = {
|
||||||
|
|||||||
@@ -150,35 +150,4 @@ describe("exec approvals", () => {
|
|||||||
expect(result.details.status).toBe("completed");
|
expect(result.details.status).toBe("completed");
|
||||||
expect(calls).not.toContain("exec.approval.request");
|
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");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
@@ -791,7 +791,7 @@ export function createExecTool(
|
|||||||
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
|
||||||
const requestedSecurity = normalizeExecSecurity(params.security);
|
const requestedSecurity = normalizeExecSecurity(params.security);
|
||||||
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
|
||||||
if (elevatedRequested && elevatedMode === "full") {
|
if (elevatedRequested) {
|
||||||
security = "full";
|
security = "full";
|
||||||
}
|
}
|
||||||
const configuredAsk = defaults?.ask ?? "on-miss";
|
const configuredAsk = defaults?.ask ?? "on-miss";
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
import type { AssistantMessage } from "@mariozechner/pi-ai";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatAssistantErrorText } from "./pi-embedded-helpers.js";
|
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", () => {
|
describe("formatAssistantErrorText", () => {
|
||||||
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
const makeAssistantError = (errorMessage: string): AssistantMessage =>
|
||||||
({
|
({
|
||||||
@@ -13,16 +21,6 @@ describe("formatAssistantErrorText", () => {
|
|||||||
const msg = makeAssistantError("request_too_large");
|
const msg = makeAssistantError("request_too_large");
|
||||||
expect(formatAssistantErrorText(msg)).toContain("Context overflow");
|
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", () => {
|
it("returns a friendly message for Anthropic role ordering", () => {
|
||||||
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
|
const msg = makeAssistantError('messages: roles must alternate between "user" and "assistant"');
|
||||||
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
|
expect(formatAssistantErrorText(msg)).toContain("Message ordering conflict");
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { isContextOverflowError } from "./pi-embedded-helpers.js";
|
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", () => {
|
describe("isContextOverflowError", () => {
|
||||||
it("matches known overflow hints", () => {
|
it("matches known overflow hints", () => {
|
||||||
const samples = [
|
const samples = [
|
||||||
@@ -16,34 +24,7 @@ describe("isContextOverflowError", () => {
|
|||||||
expect(isContextOverflowError(sample)).toBe(true);
|
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", () => {
|
it("ignores unrelated errors", () => {
|
||||||
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
|
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,19 +7,12 @@ import type { FailoverReason } from "./types.js";
|
|||||||
export function isContextOverflowError(errorMessage?: string): boolean {
|
export function isContextOverflowError(errorMessage?: string): boolean {
|
||||||
if (!errorMessage) return false;
|
if (!errorMessage) return false;
|
||||||
const lower = errorMessage.toLowerCase();
|
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 (
|
return (
|
||||||
lower.includes("request_too_large") ||
|
lower.includes("request_too_large") ||
|
||||||
lower.includes("request exceeds the maximum size") ||
|
lower.includes("request exceeds the maximum size") ||
|
||||||
lower.includes("context length exceeded") ||
|
lower.includes("context length exceeded") ||
|
||||||
lower.includes("maximum context length") ||
|
lower.includes("maximum context length") ||
|
||||||
lower.includes("prompt is too long") ||
|
lower.includes("prompt is too long") ||
|
||||||
lower.includes("exceeds model context window") ||
|
|
||||||
(hasRequestSizeExceeds && hasContextWindow) ||
|
|
||||||
lower.includes("context overflow") ||
|
lower.includes("context overflow") ||
|
||||||
(lower.includes("413") && lower.includes("too large"))
|
(lower.includes("413") && lower.includes("too large"))
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,13 +68,12 @@ import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../d
|
|||||||
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
|
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
|
||||||
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
import { buildTtsSystemPromptHint } from "../../tts/tts.js";
|
||||||
|
|
||||||
export type CompactEmbeddedPiSessionParams = {
|
export async function compactEmbeddedPiSession(params: {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
sessionKey?: string;
|
sessionKey?: string;
|
||||||
messageChannel?: string;
|
messageChannel?: string;
|
||||||
messageProvider?: string;
|
messageProvider?: string;
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
authProfileId?: string;
|
|
||||||
/** Group id for channel-level tool policy resolution. */
|
/** Group id for channel-level tool policy resolution. */
|
||||||
groupId?: string | null;
|
groupId?: string | null;
|
||||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||||
@@ -98,366 +97,354 @@ export type CompactEmbeddedPiSessionParams = {
|
|||||||
enqueue?: typeof enqueueCommand;
|
enqueue?: typeof enqueueCommand;
|
||||||
extraSystemPrompt?: string;
|
extraSystemPrompt?: string;
|
||||||
ownerNumbers?: 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 sessionLane = resolveSessionLane(params.sessionKey?.trim() || params.sessionId);
|
||||||
const globalLane = resolveGlobalLane(params.lane);
|
const globalLane = resolveGlobalLane(params.lane);
|
||||||
const enqueueGlobal =
|
const enqueueGlobal =
|
||||||
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
params.enqueue ?? ((task, opts) => enqueueCommandInLane(globalLane, task, opts));
|
||||||
return enqueueCommandInLane(sessionLane, () =>
|
return enqueueCommandInLane(sessionLane, () =>
|
||||||
enqueueGlobal(async () => compactEmbeddedPiSessionDirect(params)),
|
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);
|
||||||
|
}
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,285 +0,0 @@
|
|||||||
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");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -42,7 +42,6 @@ import {
|
|||||||
} from "../pi-embedded-helpers.js";
|
} from "../pi-embedded-helpers.js";
|
||||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||||
|
|
||||||
import { compactEmbeddedPiSessionDirect } from "./compact.js";
|
|
||||||
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
import { resolveGlobalLane, resolveSessionLane } from "./lanes.js";
|
||||||
import { log } from "./logger.js";
|
import { log } from "./logger.js";
|
||||||
import { resolveModel } from "./model.js";
|
import { resolveModel } from "./model.js";
|
||||||
@@ -291,7 +290,6 @@ export async function runEmbeddedPiAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let overflowCompactionAttempted = false;
|
|
||||||
try {
|
try {
|
||||||
while (true) {
|
while (true) {
|
||||||
attemptedThinking.add(thinkLevel);
|
attemptedThinking.add(thinkLevel);
|
||||||
@@ -360,42 +358,9 @@ export async function runEmbeddedPiAgent(
|
|||||||
if (promptError && !aborted) {
|
if (promptError && !aborted) {
|
||||||
const errorText = describeUnknownError(promptError);
|
const errorText = describeUnknownError(promptError);
|
||||||
if (isContextOverflowError(errorText)) {
|
if (isContextOverflowError(errorText)) {
|
||||||
const isCompactionFailure = isCompactionFailureError(errorText);
|
const kind = isCompactionFailureError(errorText)
|
||||||
// Attempt auto-compaction on context overflow (not compaction_failure)
|
? "compaction_failure"
|
||||||
if (!isCompactionFailure && !overflowCompactionAttempted) {
|
: "context_overflow";
|
||||||
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 {
|
return {
|
||||||
payloads: [
|
payloads: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -164,13 +164,6 @@ function buildChatCommands(): ChatCommandDefinition[] {
|
|||||||
acceptsArgs: true,
|
acceptsArgs: true,
|
||||||
scope: "text",
|
scope: "text",
|
||||||
}),
|
}),
|
||||||
defineChatCommand({
|
|
||||||
key: "approve",
|
|
||||||
nativeName: "approve",
|
|
||||||
description: "Approve or deny exec requests.",
|
|
||||||
textAlias: "/approve",
|
|
||||||
acceptsArgs: true,
|
|
||||||
}),
|
|
||||||
defineChatCommand({
|
defineChatCommand({
|
||||||
key: "context",
|
key: "context",
|
||||||
nativeName: "context",
|
nativeName: "context",
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
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" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
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}.` },
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
} from "./commands-info.js";
|
} from "./commands-info.js";
|
||||||
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
import { handleAllowlistCommand } from "./commands-allowlist.js";
|
||||||
import { handleApproveCommand } from "./commands-approve.js";
|
|
||||||
import { handleSubagentsCommand } from "./commands-subagents.js";
|
import { handleSubagentsCommand } from "./commands-subagents.js";
|
||||||
import { handleModelsCommand } from "./commands-models.js";
|
import { handleModelsCommand } from "./commands-models.js";
|
||||||
import { handleTtsCommands } from "./commands-tts.js";
|
import { handleTtsCommands } from "./commands-tts.js";
|
||||||
@@ -46,7 +45,6 @@ const HANDLERS: CommandHandler[] = [
|
|||||||
handleCommandsListCommand,
|
handleCommandsListCommand,
|
||||||
handleStatusCommand,
|
handleStatusCommand,
|
||||||
handleAllowlistCommand,
|
handleAllowlistCommand,
|
||||||
handleApproveCommand,
|
|
||||||
handleContextCommand,
|
handleContextCommand,
|
||||||
handleWhoamiCommand,
|
handleWhoamiCommand,
|
||||||
handleSubagentsCommand,
|
handleSubagentsCommand,
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
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;
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
|
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
|
||||||
import type { ApprovalsConfig } from "./types.approvals.js";
|
|
||||||
import type { AuthConfig } from "./types.auth.js";
|
import type { AuthConfig } from "./types.auth.js";
|
||||||
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
|
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
|
||||||
import type { BrowserConfig } from "./types.browser.js";
|
import type { BrowserConfig } from "./types.browser.js";
|
||||||
@@ -85,7 +84,6 @@ export type ClawdbotConfig = {
|
|||||||
audio?: AudioConfig;
|
audio?: AudioConfig;
|
||||||
messages?: MessagesConfig;
|
messages?: MessagesConfig;
|
||||||
commands?: CommandsConfig;
|
commands?: CommandsConfig;
|
||||||
approvals?: ApprovalsConfig;
|
|
||||||
session?: SessionConfig;
|
session?: SessionConfig;
|
||||||
web?: WebConfig;
|
web?: WebConfig;
|
||||||
channels?: ChannelsConfig;
|
channels?: ChannelsConfig;
|
||||||
|
|||||||
@@ -72,17 +72,6 @@ export type DiscordActionConfig = {
|
|||||||
channels?: boolean;
|
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 = {
|
export type DiscordAccountConfig = {
|
||||||
/** Optional display name for this account (used in CLI/UI lists). */
|
/** Optional display name for this account (used in CLI/UI lists). */
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -135,8 +124,6 @@ export type DiscordAccountConfig = {
|
|||||||
guilds?: Record<string, DiscordGuildEntry>;
|
guilds?: Record<string, DiscordGuildEntry>;
|
||||||
/** Heartbeat visibility settings for this channel. */
|
/** Heartbeat visibility settings for this channel. */
|
||||||
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
heartbeat?: ChannelHeartbeatVisibilityConfig;
|
||||||
/** Exec approval forwarding configuration. */
|
|
||||||
execApprovals?: DiscordExecApprovalConfig;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DiscordConfig = {
|
export type DiscordConfig = {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
export * from "./types.agent-defaults.js";
|
export * from "./types.agent-defaults.js";
|
||||||
export * from "./types.agents.js";
|
export * from "./types.agents.js";
|
||||||
export * from "./types.approvals.js";
|
|
||||||
export * from "./types.auth.js";
|
export * from "./types.auth.js";
|
||||||
export * from "./types.base.js";
|
export * from "./types.base.js";
|
||||||
export * from "./types.browser.js";
|
export * from "./types.browser.js";
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
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();
|
|
||||||
@@ -244,15 +244,6 @@ export const DiscordAccountSchema = z
|
|||||||
dm: DiscordDmSchema.optional(),
|
dm: DiscordDmSchema.optional(),
|
||||||
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
|
||||||
heartbeat: ChannelHeartbeatVisibilitySchema,
|
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();
|
.strict();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
|
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 { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
|
||||||
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
|
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
|
||||||
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
|
||||||
@@ -221,7 +220,6 @@ export const ClawdbotSchema = z
|
|||||||
.optional(),
|
.optional(),
|
||||||
messages: MessagesSchema,
|
messages: MessagesSchema,
|
||||||
commands: CommandsSchema,
|
commands: CommandsSchema,
|
||||||
approvals: ApprovalsSchema,
|
|
||||||
session: SessionSchema,
|
session: SessionSchema,
|
||||||
cron: z
|
cron: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,549 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,6 @@ import {
|
|||||||
createDiscordCommandArgFallbackButton,
|
createDiscordCommandArgFallbackButton,
|
||||||
createDiscordNativeCommand,
|
createDiscordNativeCommand,
|
||||||
} from "./native-command.js";
|
} from "./native-command.js";
|
||||||
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
|
|
||||||
|
|
||||||
export type MonitorDiscordOpts = {
|
export type MonitorDiscordOpts = {
|
||||||
token?: string;
|
token?: string;
|
||||||
@@ -407,31 +406,6 @@ 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(
|
const client = new Client(
|
||||||
{
|
{
|
||||||
baseUrl: "http://localhost",
|
baseUrl: "http://localhost",
|
||||||
@@ -444,7 +418,14 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
{
|
{
|
||||||
commands,
|
commands,
|
||||||
listeners: [],
|
listeners: [],
|
||||||
components,
|
components: [
|
||||||
|
createDiscordCommandArgFallbackButton({
|
||||||
|
cfg,
|
||||||
|
discordConfig: discordCfg,
|
||||||
|
accountId: account.accountId,
|
||||||
|
sessionPrefix,
|
||||||
|
}),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
new GatewayPlugin({
|
new GatewayPlugin({
|
||||||
@@ -529,11 +510,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
|
|
||||||
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
|
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 gateway = client.getPlugin<GatewayPlugin>("gateway");
|
||||||
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
|
||||||
const stopGatewayLogging = attachDiscordGatewayLogging({
|
const stopGatewayLogging = attachDiscordGatewayLogging({
|
||||||
@@ -599,9 +575,6 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
|
|||||||
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
if (helloTimeoutId) clearTimeout(helloTimeoutId);
|
||||||
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
gatewayEmitter?.removeListener("debug", onGatewayDebug);
|
||||||
abortSignal?.removeEventListener("abort", onAbort);
|
abortSignal?.removeEventListener("abort", onAbort);
|
||||||
if (execApprovalsHandler) {
|
|
||||||
await execApprovalsHandler.stop();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
|
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 type { ExecApprovalManager } from "../exec-approval-manager.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
@@ -10,10 +9,7 @@ import {
|
|||||||
} from "../protocol/index.js";
|
} from "../protocol/index.js";
|
||||||
import type { GatewayRequestHandlers } from "./types.js";
|
import type { GatewayRequestHandlers } from "./types.js";
|
||||||
|
|
||||||
export function createExecApprovalHandlers(
|
export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers {
|
||||||
manager: ExecApprovalManager,
|
|
||||||
opts?: { forwarder?: ExecApprovalForwarder },
|
|
||||||
): GatewayRequestHandlers {
|
|
||||||
return {
|
return {
|
||||||
"exec.approval.request": async ({ params, respond, context }) => {
|
"exec.approval.request": async ({ params, respond, context }) => {
|
||||||
if (!validateExecApprovalRequestParams(params)) {
|
if (!validateExecApprovalRequestParams(params)) {
|
||||||
@@ -73,16 +69,6 @@ export function createExecApprovalHandlers(
|
|||||||
},
|
},
|
||||||
{ dropIfSlow: true },
|
{ 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;
|
const decision = await decisionPromise;
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
@@ -126,11 +112,6 @@ export function createExecApprovalHandlers(
|
|||||||
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
{ id: p.id, decision, resolvedBy, ts: Date.now() },
|
||||||
{ dropIfSlow: true },
|
{ 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);
|
respond(true, { ok: true }, undefined);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,7 +43,6 @@ import {
|
|||||||
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
||||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.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 type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||||
import { createChannelManager } from "./server-channels.js";
|
import { createChannelManager } from "./server-channels.js";
|
||||||
import { createAgentEventHandler } from "./server-chat.js";
|
import { createAgentEventHandler } from "./server-chat.js";
|
||||||
@@ -397,10 +396,7 @@ export async function startGatewayServer(
|
|||||||
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
|
||||||
|
|
||||||
const execApprovalManager = new ExecApprovalManager();
|
const execApprovalManager = new ExecApprovalManager();
|
||||||
const execApprovalForwarder = createExecApprovalForwarder();
|
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
|
||||||
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
|
|
||||||
forwarder: execApprovalForwarder,
|
|
||||||
});
|
|
||||||
|
|
||||||
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;
|
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;
|
||||||
|
|
||||||
|
|||||||
@@ -1,77 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,282 +0,0 @@
|
|||||||
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,7 +35,6 @@ import {
|
|||||||
} from "../config/sessions.js";
|
} from "../config/sessions.js";
|
||||||
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { peekSystemEvents } from "../infra/system-events.js";
|
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { getQueueSize } from "../process/command-queue.js";
|
import { getQueueSize } from "../process/command-queue.js";
|
||||||
import { CommandLane } from "../process/lanes.js";
|
import { CommandLane } from "../process/lanes.js";
|
||||||
@@ -89,14 +88,6 @@ export type HeartbeatSummary = {
|
|||||||
const DEFAULT_HEARTBEAT_TARGET = "last";
|
const DEFAULT_HEARTBEAT_TARGET = "last";
|
||||||
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
|
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 {
|
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
|
||||||
const trimmed = raw?.trim();
|
const trimmed = raw?.trim();
|
||||||
if (!trimmed || trimmed === "user") {
|
if (!trimmed || trimmed === "user") {
|
||||||
@@ -462,13 +453,11 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
|
|
||||||
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
|
// 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).
|
// 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 workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
|
||||||
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
|
||||||
try {
|
try {
|
||||||
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
|
||||||
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
|
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
|
||||||
emitHeartbeatEvent({
|
emitHeartbeatEvent({
|
||||||
status: "skipped",
|
status: "skipped",
|
||||||
reason: "empty-heartbeat-file",
|
reason: "empty-heartbeat-file",
|
||||||
@@ -494,20 +483,12 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
: { showOk: false, showAlerts: true, useIndicator: true };
|
: { showOk: false, showAlerts: true, useIndicator: true };
|
||||||
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
|
||||||
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
|
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 = {
|
const ctx = {
|
||||||
Body: prompt,
|
Body: prompt,
|
||||||
From: sender,
|
From: sender,
|
||||||
To: sender,
|
To: sender,
|
||||||
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
|
Provider: "heartbeat",
|
||||||
SessionKey: sessionKey,
|
SessionKey: sessionKey,
|
||||||
};
|
};
|
||||||
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
|
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
|
||||||
@@ -577,19 +558,7 @@ export async function runHeartbeatOnce(opts: {
|
|||||||
|
|
||||||
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
|
||||||
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
|
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
|
||||||
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
|
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
|
||||||
// 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) {
|
if (shouldSkipMain && reasoningPayloads.length === 0) {
|
||||||
await restoreHeartbeatUpdatedAt({
|
await restoreHeartbeatUpdatedAt({
|
||||||
storePath,
|
storePath,
|
||||||
|
|||||||
@@ -177,11 +177,13 @@ describe("createTelegramBot", () => {
|
|||||||
expect(throttlerSpy).toHaveBeenCalledTimes(1);
|
expect(throttlerSpy).toHaveBeenCalledTimes(1);
|
||||||
expect(useSpy).toHaveBeenCalledWith("throttler");
|
expect(useSpy).toHaveBeenCalledWith("throttler");
|
||||||
});
|
});
|
||||||
it("uses wrapped fetch when global fetch is available", () => {
|
it("forces native fetch only under Bun", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||||
const fetchSpy = vi.fn() as unknown as typeof fetch;
|
const fetchSpy = vi.fn() as unknown as typeof fetch;
|
||||||
globalThis.fetch = fetchSpy;
|
globalThis.fetch = fetchSpy;
|
||||||
try {
|
try {
|
||||||
|
(globalThis as { Bun?: unknown }).Bun = {};
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
expect(fetchImpl).toBeTypeOf("function");
|
expect(fetchImpl).toBeTypeOf("function");
|
||||||
@@ -192,6 +194,33 @@ describe("createTelegramBot", () => {
|
|||||||
expect(clientFetch).not.toBe(fetchSpy);
|
expect(clientFetch).not.toBe(fetchSpy);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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", () => {
|
it("passes timeoutSeconds even without a custom fetch", () => {
|
||||||
|
|||||||
@@ -309,11 +309,13 @@ describe("createTelegramBot", () => {
|
|||||||
expect(registered.some((command) => reserved.includes(command.command))).toBe(false);
|
expect(registered.some((command) => reserved.includes(command.command))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses wrapped fetch when global fetch is available", () => {
|
it("forces native fetch only under Bun", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
const originalFetch = globalThis.fetch;
|
||||||
|
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||||
const fetchSpy = vi.fn() as unknown as typeof fetch;
|
const fetchSpy = vi.fn() as unknown as typeof fetch;
|
||||||
globalThis.fetch = fetchSpy;
|
globalThis.fetch = fetchSpy;
|
||||||
try {
|
try {
|
||||||
|
(globalThis as { Bun?: unknown }).Bun = {};
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const fetchImpl = resolveTelegramFetch();
|
const fetchImpl = resolveTelegramFetch();
|
||||||
expect(fetchImpl).toBeTypeOf("function");
|
expect(fetchImpl).toBeTypeOf("function");
|
||||||
@@ -324,6 +326,34 @@ describe("createTelegramBot", () => {
|
|||||||
expect(clientFetch).not.toBe(fetchSpy);
|
expect(clientFetch).not.toBe(fetchSpy);
|
||||||
} finally {
|
} finally {
|
||||||
globalThis.fetch = originalFetch;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -117,7 +117,8 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
const telegramCfg = account.config;
|
const telegramCfg = account.config;
|
||||||
|
|
||||||
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
|
const fetchImpl = resolveTelegramFetch(opts.proxyFetch);
|
||||||
const shouldProvideFetch = Boolean(fetchImpl);
|
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
|
||||||
|
const shouldProvideFetch = Boolean(opts.proxyFetch) || isBun;
|
||||||
const timeoutSeconds =
|
const timeoutSeconds =
|
||||||
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds)
|
||||||
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
? Math.max(1, Math.floor(telegramCfg.timeoutSeconds))
|
||||||
|
|||||||
+28
-19
@@ -1,28 +1,37 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { resolveTelegramFetch } from "./fetch.js";
|
import { resolveTelegramFetch } from "./fetch.js";
|
||||||
|
|
||||||
describe("resolveTelegramFetch", () => {
|
describe("resolveTelegramFetch", () => {
|
||||||
const originalFetch = globalThis.fetch;
|
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;
|
||||||
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
const fetcher = resolveTelegramFetch(proxyFetch);
|
||||||
if (originalFetch) {
|
expect(fetcher).toBeTypeOf("function");
|
||||||
globalThis.fetch = originalFetch;
|
|
||||||
} else {
|
|
||||||
delete (globalThis as { fetch?: typeof fetch }).fetch;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns wrapped global fetch when available", () => {
|
let abortHandler: (() => void) | null = null;
|
||||||
const fetchMock = vi.fn(async () => ({}));
|
const fakeSignal = {
|
||||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
aborted: false,
|
||||||
const resolved = resolveTelegramFetch();
|
addEventListener: (event: string, handler: () => void) => {
|
||||||
expect(resolved).toBeTypeOf("function");
|
if (event === "abort") abortHandler = handler;
|
||||||
});
|
},
|
||||||
|
removeEventListener: (event: string, handler: () => void) => {
|
||||||
|
if (event === "abort" && abortHandler === handler) abortHandler = null;
|
||||||
|
},
|
||||||
|
} as AbortSignal;
|
||||||
|
|
||||||
it("prefers proxy fetch when provided", () => {
|
const promise = fetcher!("https://example.com", { signal: fakeSignal });
|
||||||
const fetchMock = vi.fn(async () => ({}));
|
expect(proxyFetch).toHaveBeenCalledOnce();
|
||||||
const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch);
|
expect(seenSignal).toBeInstanceOf(AbortSignal);
|
||||||
expect(resolved).toBeTypeOf("function");
|
expect(seenSignal).not.toBe(fakeSignal);
|
||||||
|
|
||||||
|
abortHandler?.();
|
||||||
|
expect(seenSignal?.aborted).toBe(true);
|
||||||
|
|
||||||
|
await promise;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
import { resolveFetch } from "../infra/fetch.js";
|
import { resolveFetch } from "../infra/fetch.js";
|
||||||
|
|
||||||
// Prefer wrapped fetch when available to normalize AbortSignal across runtimes.
|
// Bun-only: force native fetch to avoid grammY's Node shim under Bun.
|
||||||
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
|
export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined {
|
||||||
if (proxyFetch) return resolveFetch(proxyFetch);
|
if (proxyFetch) return resolveFetch(proxyFetch);
|
||||||
|
const isBun = "Bun" in globalThis || Boolean(process?.versions?.bun);
|
||||||
|
if (!isBun) return undefined;
|
||||||
const fetchImpl = resolveFetch();
|
const fetchImpl = resolveFetch();
|
||||||
if (!fetchImpl) {
|
if (!fetchImpl) {
|
||||||
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
throw new Error("fetch is not available; set channels.telegram.proxy in config");
|
||||||
|
|||||||
Reference in new issue
Block a user