mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 22:11:38 +08:00
Compare commits
2 Commits
feat/plugi
...
fix/consol
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a36dc285d6 | ||
|
|
704a3ba51c |
15
CHANGELOG.md
15
CHANGELOG.md
@@ -5,31 +5,22 @@ Docs: https://docs.clawd.bot
|
||||
## 2026.1.23 (Unreleased)
|
||||
|
||||
### Changes
|
||||
- Browser: add node-host proxy auto-routing for remote gateways (configurable per gateway/node).
|
||||
- Plugins: add optional llm-task JSON-only tool for workflows. (#1498) Thanks @vignesh07.
|
||||
- Plugins: add LLM-free plugin slash commands and include them in `/commands`. (#1558) Thanks @Glucksberg.
|
||||
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
|
||||
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
|
||||
- CLI: add `clawdbot system` for system events + heartbeat controls; remove standalone `wake`.
|
||||
- Agents: add Bedrock auto-discovery defaults + config overrides. (#1553) Thanks @fal3.
|
||||
- Docs: add cron vs heartbeat decision guide (with Lobster workflow notes). (#1533) Thanks @JustYannicc.
|
||||
- Docs: clarify HEARTBEAT.md empty file skips heartbeats, missing file still runs. (#1535) Thanks @JustYannicc.
|
||||
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
|
||||
- Tlon: add Urbit channel plugin (DMs, group mentions, thread replies). (#1544) Thanks @wca4a.
|
||||
- Channels: allow per-group tool allow/deny policies across built-in + plugin channels. (#1546) Thanks @adam91holt.
|
||||
|
||||
### Fixes
|
||||
- Agents: ignore IDENTITY.md template placeholders when parsing identity to avoid placeholder replies. (#1556)
|
||||
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
|
||||
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
|
||||
- Logging: guard console settings resolution to avoid recursion on config warnings. (#1555) Thanks @travisp.
|
||||
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
|
||||
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
|
||||
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.
|
||||
- Tailscale: retry serve/funnel with sudo only for permission errors and keep original failure details. (#1551) Thanks @sweepies.
|
||||
- Agents: add CLI log hint to "agent failed before reply" messages. (#1550) Thanks @sweepies.
|
||||
- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo.
|
||||
- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo.
|
||||
- Exec: honor tools.exec ask/security defaults for elevated approvals (avoid unwanted prompts).
|
||||
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
|
||||
- TUI: include Gateway slash commands in autocomplete and `/help`.
|
||||
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
|
||||
@@ -42,19 +33,15 @@ Docs: https://docs.clawd.bot
|
||||
- CLI: explain when auth profiles are excluded by auth.order in probe details.
|
||||
- CLI: drop the em dash when the banner tagline wraps to a second line.
|
||||
- CLI: inline auth probe errors in status rows to reduce wrapping.
|
||||
- Telegram: render markdown in media captions. (#1478)
|
||||
- Agents: honor enqueue overrides for embedded runs to avoid queue deadlocks in tests.
|
||||
- Agents: trigger model fallback when auth profiles are all in cooldown or unavailable. (#1522)
|
||||
- Daemon: use platform PATH delimiters when building minimal service paths.
|
||||
- Tests: skip embedded runner ordering assertion on Windows to avoid CI timeouts.
|
||||
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
|
||||
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
|
||||
- Media: only parse `MEDIA:` tags when they start the line to avoid stripping prose mentions. (#1206)
|
||||
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
|
||||
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
|
||||
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
|
||||
- MS Teams (plugin): remove `.default` suffix from Graph scopes to avoid double-appending. (#1507) Thanks @Evizero.
|
||||
- Browser: keep extension relay tabs controllable when the extension reuses a session id after switching tabs. (#1160)
|
||||
|
||||
## 2026.1.22
|
||||
|
||||
|
||||
@@ -385,7 +385,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -406,7 +404,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -482,9 +478,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -531,9 +521,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -556,9 +543,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
|
||||
@@ -385,7 +385,6 @@ public struct SendParams: Codable, Sendable {
|
||||
public let to: String
|
||||
public let message: String
|
||||
public let mediaurl: String?
|
||||
public let mediaurls: [String]?
|
||||
public let gifplayback: Bool?
|
||||
public let channel: String?
|
||||
public let accountid: String?
|
||||
@@ -396,7 +395,6 @@ public struct SendParams: Codable, Sendable {
|
||||
to: String,
|
||||
message: String,
|
||||
mediaurl: String?,
|
||||
mediaurls: [String]?,
|
||||
gifplayback: Bool?,
|
||||
channel: String?,
|
||||
accountid: String?,
|
||||
@@ -406,7 +404,6 @@ public struct SendParams: Codable, Sendable {
|
||||
self.to = to
|
||||
self.message = message
|
||||
self.mediaurl = mediaurl
|
||||
self.mediaurls = mediaurls
|
||||
self.gifplayback = gifplayback
|
||||
self.channel = channel
|
||||
self.accountid = accountid
|
||||
@@ -417,7 +414,6 @@ public struct SendParams: Codable, Sendable {
|
||||
case to
|
||||
case message
|
||||
case mediaurl = "mediaUrl"
|
||||
case mediaurls = "mediaUrls"
|
||||
case gifplayback = "gifPlayback"
|
||||
case channel
|
||||
case accountid = "accountId"
|
||||
@@ -482,9 +478,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
public let accountid: String?
|
||||
public let replyaccountid: String?
|
||||
public let threadid: String?
|
||||
public let groupid: String?
|
||||
public let groupchannel: String?
|
||||
public let groupspace: String?
|
||||
public let timeout: Int?
|
||||
public let lane: String?
|
||||
public let extrasystemprompt: String?
|
||||
@@ -507,9 +500,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
accountid: String?,
|
||||
replyaccountid: String?,
|
||||
threadid: String?,
|
||||
groupid: String?,
|
||||
groupchannel: String?,
|
||||
groupspace: String?,
|
||||
timeout: Int?,
|
||||
lane: String?,
|
||||
extrasystemprompt: String?,
|
||||
@@ -531,9 +521,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
self.accountid = accountid
|
||||
self.replyaccountid = replyaccountid
|
||||
self.threadid = threadid
|
||||
self.groupid = groupid
|
||||
self.groupchannel = groupchannel
|
||||
self.groupspace = groupspace
|
||||
self.timeout = timeout
|
||||
self.lane = lane
|
||||
self.extrasystemprompt = extrasystemprompt
|
||||
@@ -556,9 +543,6 @@ public struct AgentParams: Codable, Sendable {
|
||||
case accountid = "accountId"
|
||||
case replyaccountid = "replyAccountId"
|
||||
case threadid = "threadId"
|
||||
case groupid = "groupId"
|
||||
case groupchannel = "groupChannel"
|
||||
case groupspace = "groupSpace"
|
||||
case timeout
|
||||
case lane
|
||||
case extrasystemprompt = "extraSystemPrompt"
|
||||
|
||||
@@ -20,7 +20,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"gateway-daemon",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND:-lan}",
|
||||
"--port",
|
||||
|
||||
@@ -263,15 +263,15 @@ Run history:
|
||||
clawdbot cron runs --id <jobId> --limit 50
|
||||
```
|
||||
|
||||
Immediate system event without creating a job:
|
||||
Immediate wake without creating a job:
|
||||
```bash
|
||||
clawdbot system event --mode now --text "Next heartbeat: check battery."
|
||||
clawdbot wake --mode now --text "Next heartbeat: check battery."
|
||||
```
|
||||
|
||||
## Gateway API surface
|
||||
- `cron.list`, `cron.status`, `cron.add`, `cron.update`, `cron.remove`
|
||||
- `cron.run` (force or due), `cron.runs`
|
||||
For immediate system events without a job, use [`clawdbot system event`](/cli/system).
|
||||
- `wake` (enqueue system event + optional heartbeat)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -271,4 +271,4 @@ clawdbot cron add \
|
||||
|
||||
- [Heartbeat](/gateway/heartbeat) - full heartbeat configuration
|
||||
- [Cron jobs](/automation/cron-jobs) - full cron CLI and API reference
|
||||
- [System](/cli/system) - system events + heartbeat controls
|
||||
- [Wake](/cli/wake) - manual wake command
|
||||
@@ -44,7 +44,6 @@ clawdbot channels logout --channel whatsapp
|
||||
|
||||
- Run `clawdbot status --deep` for a broad probe.
|
||||
- Use `clawdbot doctor` for guided fixes.
|
||||
- `clawdbot channels list` prints `Claude: HTTP 403 ... user:profile` → usage snapshot needs the `user:profile` scope. Use `--no-usage`, or provide a claude.ai session key (`CLAUDE_WEB_SESSION_KEY` / `CLAUDE_WEB_COOKIE`), or re-auth via Claude Code CLI.
|
||||
|
||||
## Capabilities probe
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sessions`](/cli/sessions)
|
||||
- [`gateway`](/cli/gateway)
|
||||
- [`logs`](/cli/logs)
|
||||
- [`system`](/cli/system)
|
||||
- [`models`](/cli/models)
|
||||
- [`memory`](/cli/memory)
|
||||
- [`nodes`](/cli/nodes)
|
||||
@@ -39,6 +38,7 @@ This page describes the current CLI behavior. If commands change, update this do
|
||||
- [`sandbox`](/cli/sandbox)
|
||||
- [`tui`](/cli/tui)
|
||||
- [`browser`](/cli/browser)
|
||||
- [`wake`](/cli/wake)
|
||||
- [`cron`](/cli/cron)
|
||||
- [`dns`](/cli/dns)
|
||||
- [`docs`](/cli/docs)
|
||||
@@ -145,10 +145,6 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
restart
|
||||
run
|
||||
logs
|
||||
system
|
||||
event
|
||||
heartbeat last|enable|disable
|
||||
presence
|
||||
models
|
||||
list
|
||||
status
|
||||
@@ -164,6 +160,7 @@ clawdbot [--dev] [--profile <name>] <command>
|
||||
list
|
||||
recreate
|
||||
explain
|
||||
wake
|
||||
cron
|
||||
status
|
||||
list
|
||||
@@ -766,9 +763,9 @@ Options:
|
||||
- `set`: `--provider <name>`, `--agent <id>`, `<profileIds...>`
|
||||
- `clear`: `--provider <name>`, `--agent <id>`
|
||||
|
||||
## System
|
||||
## Cron + wake
|
||||
|
||||
### `system event`
|
||||
### `wake`
|
||||
Enqueue a system event and optionally trigger a heartbeat (Gateway RPC).
|
||||
|
||||
Required:
|
||||
@@ -779,21 +776,7 @@ Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `system heartbeat last|enable|disable`
|
||||
Heartbeat controls (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
### `system presence`
|
||||
List system presence entries (Gateway RPC).
|
||||
|
||||
Options:
|
||||
- `--json`
|
||||
- `--url`, `--token`, `--timeout`, `--expect-final`
|
||||
|
||||
## Cron
|
||||
### `cron`
|
||||
Manage scheduled jobs (Gateway RPC). See [/automation/cron-jobs](/automation/cron-jobs).
|
||||
|
||||
Subcommands:
|
||||
|
||||
@@ -23,24 +23,6 @@ Common use cases:
|
||||
Execution is still guarded by **exec approvals** and per‑agent allowlists on the
|
||||
node host, so you can keep command access scoped and explicit.
|
||||
|
||||
## Browser proxy (zero-config)
|
||||
|
||||
Node hosts automatically advertise a browser proxy if `browser.enabled` is not
|
||||
disabled on the node. This lets the agent use browser automation on that node
|
||||
without extra configuration.
|
||||
|
||||
Disable it on the node if needed:
|
||||
|
||||
```json5
|
||||
{
|
||||
nodeHost: {
|
||||
browserProxy: {
|
||||
enabled: false
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Run (foreground)
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot system` (system events, heartbeat, presence)"
|
||||
read_when:
|
||||
- You want to enqueue a system event without creating a cron job
|
||||
- You need to enable or disable heartbeats
|
||||
- You want to inspect system presence entries
|
||||
---
|
||||
|
||||
# `clawdbot system`
|
||||
|
||||
System-level helpers for the Gateway: enqueue system events, control heartbeats,
|
||||
and view presence.
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot system heartbeat enable
|
||||
clawdbot system heartbeat last
|
||||
clawdbot system presence
|
||||
```
|
||||
|
||||
## `system event`
|
||||
|
||||
Enqueue a system event on the **main** session. The next heartbeat will inject
|
||||
it as a `System:` line in the prompt. Use `--mode now` to trigger the heartbeat
|
||||
immediately; `next-heartbeat` waits for the next scheduled tick.
|
||||
|
||||
Flags:
|
||||
- `--text <text>`: required system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system heartbeat last|enable|disable`
|
||||
|
||||
Heartbeat controls:
|
||||
- `last`: show the last heartbeat event.
|
||||
- `enable`: turn heartbeats back on (use this if they were disabled).
|
||||
- `disable`: pause heartbeats.
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## `system presence`
|
||||
|
||||
List the current system presence entries the Gateway knows about (nodes,
|
||||
instances, and similar status lines).
|
||||
|
||||
Flags:
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- System events are ephemeral and not persisted across restarts.
|
||||
35
docs/cli/wake.md
Normal file
35
docs/cli/wake.md
Normal file
@@ -0,0 +1,35 @@
|
||||
---
|
||||
summary: "CLI reference for `clawdbot wake` (enqueue a system event and optionally trigger an immediate heartbeat)"
|
||||
read_when:
|
||||
- You want to “poke” a running Gateway to process a system event
|
||||
- You use `wake` with cron jobs or remote nodes
|
||||
---
|
||||
|
||||
# `clawdbot wake`
|
||||
|
||||
Enqueue a system event on the Gateway and optionally trigger an immediate heartbeat.
|
||||
|
||||
This is a lightweight “poke” for automation flows where you don’t want to run a full command, but you do want the Gateway to react quickly.
|
||||
|
||||
Related:
|
||||
- Cron jobs: [Cron](/cli/cron)
|
||||
- Gateway heartbeat: [Heartbeat](/gateway/heartbeat)
|
||||
|
||||
## Common commands
|
||||
|
||||
```bash
|
||||
clawdbot wake --text "sync"
|
||||
clawdbot wake --text "sync" --mode now
|
||||
```
|
||||
|
||||
## Flags
|
||||
|
||||
- `--text <text>`: system event text.
|
||||
- `--mode <mode>`: `now` or `next-heartbeat` (default).
|
||||
- `--json`: machine-readable output.
|
||||
|
||||
## Notes
|
||||
|
||||
- Requires a running Gateway reachable by your current config (local or remote).
|
||||
- If you’re using sandboxing, `wake` still targets the Gateway; sandboxing does not block the command itself.
|
||||
|
||||
@@ -850,12 +850,12 @@
|
||||
"cli/memory",
|
||||
"cli/models",
|
||||
"cli/logs",
|
||||
"cli/system",
|
||||
"cli/nodes",
|
||||
"cli/approvals",
|
||||
"cli/gateway",
|
||||
"cli/tui",
|
||||
"cli/voicecall",
|
||||
"cli/wake",
|
||||
"cli/cron",
|
||||
"cli/dns",
|
||||
"cli/docs",
|
||||
|
||||
@@ -162,10 +162,6 @@ If a `HEARTBEAT.md` file exists in the workspace, the default prompt tells the
|
||||
agent to read it. Think of it as your “heartbeat checklist”: small, stable, and
|
||||
safe to include every 30 minutes.
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Keep it tiny (short checklist or reminders) to avoid prompt bloat.
|
||||
|
||||
Example `HEARTBEAT.md`:
|
||||
@@ -199,7 +195,7 @@ Safety note: don’t put secrets (API keys, phone numbers, private tokens) into
|
||||
You can enqueue a system event and trigger an immediate heartbeat with:
|
||||
|
||||
```bash
|
||||
clawdbot system event --text "Check for urgent follow-ups" --mode now
|
||||
clawdbot wake --text "Check for urgent follow-ups" --mode now
|
||||
```
|
||||
|
||||
If multiple agents have `heartbeat` configured, a manual wake runs each of those
|
||||
|
||||
@@ -184,7 +184,7 @@ services:
|
||||
[
|
||||
"node",
|
||||
"dist/index.js",
|
||||
"gateway",
|
||||
"gateway-daemon",
|
||||
"--bind",
|
||||
"${CLAWDBOT_GATEWAY_BIND}",
|
||||
"--port",
|
||||
|
||||
@@ -62,7 +62,6 @@ Plugins can register:
|
||||
- Background services
|
||||
- Optional config validation
|
||||
- **Skills** (by listing `skills` directories in the plugin manifest)
|
||||
- **Auto-reply commands** (execute without invoking the AI agent)
|
||||
|
||||
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
|
||||
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
|
||||
@@ -495,66 +494,6 @@ export default function (api) {
|
||||
}
|
||||
```
|
||||
|
||||
### Register auto-reply commands
|
||||
|
||||
Plugins can register custom slash commands that execute **without invoking the
|
||||
AI agent**. This is useful for toggle commands, status checks, or quick actions
|
||||
that don't need LLM processing.
|
||||
|
||||
```ts
|
||||
export default function (api) {
|
||||
api.registerCommand({
|
||||
name: "mystatus",
|
||||
description: "Show plugin status",
|
||||
handler: (ctx) => ({
|
||||
text: `Plugin is running! Channel: ${ctx.channel}`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Command handler context:
|
||||
|
||||
- `senderId`: The sender's ID (if available)
|
||||
- `channel`: The channel where the command was sent
|
||||
- `isAuthorizedSender`: Whether the sender is an authorized user
|
||||
- `args`: Arguments passed after the command (if `acceptsArgs: true`)
|
||||
- `commandBody`: The full command text
|
||||
- `config`: The current Clawdbot config
|
||||
|
||||
Command options:
|
||||
|
||||
- `name`: Command name (without the leading `/`)
|
||||
- `description`: Help text shown in command lists
|
||||
- `acceptsArgs`: Whether the command accepts arguments (default: false). If false and arguments are provided, the command won't match and the message falls through to other handlers
|
||||
- `requireAuth`: Whether to require authorized sender (default: true)
|
||||
- `handler`: Function that returns `{ text: string }` (can be async)
|
||||
|
||||
Example with authorization and arguments:
|
||||
|
||||
```ts
|
||||
api.registerCommand({
|
||||
name: "setmode",
|
||||
description: "Set plugin mode",
|
||||
acceptsArgs: true,
|
||||
requireAuth: true,
|
||||
handler: async (ctx) => {
|
||||
const mode = ctx.args?.trim() || "default";
|
||||
await saveMode(mode);
|
||||
return { text: `Mode set to: ${mode}` };
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Plugin commands are processed **before** built-in commands and the AI agent
|
||||
- Commands are registered globally and work across all channels
|
||||
- Command names are case-insensitive (`/MyStatus` matches `/mystatus`)
|
||||
- Command names must start with a letter and contain only letters, numbers, hyphens, and underscores
|
||||
- Telegram native commands only allow `a-z0-9_` (max 32 chars). Use underscores (not hyphens) if you want a plugin command to appear in Telegram’s native command list.
|
||||
- Reserved command names (like `help`, `status`, `reset`, etc.) cannot be overridden by plugins
|
||||
- Duplicate command registration across plugins will fail with a diagnostic error
|
||||
|
||||
### Register background services
|
||||
|
||||
```ts
|
||||
|
||||
@@ -5,5 +5,4 @@ read_when:
|
||||
---
|
||||
# HEARTBEAT.md
|
||||
|
||||
# Keep this file empty (or with only comments) to skip heartbeat API calls.
|
||||
# Add tasks below when you want the agent to check something periodically.
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
|
||||
@@ -7,16 +7,11 @@ read_when:
|
||||
|
||||
*Fill this in during your first conversation. Make it yours.*
|
||||
|
||||
- **Name:**
|
||||
*(pick something you like)*
|
||||
- **Creature:**
|
||||
*(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:**
|
||||
*(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:**
|
||||
*(your signature — pick one that feels right)*
|
||||
- **Avatar:**
|
||||
*(workspace-relative path, http(s) URL, or data URI)*
|
||||
- **Name:** *(pick something you like)*
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:** *(your signature — pick one that feels right)*
|
||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -182,8 +182,6 @@ By default, Clawdbot runs a heartbeat every 30 minutes with the prompt:
|
||||
`Read HEARTBEAT.md if it exists (workspace context). Follow it strictly. Do not infer or repeat old tasks from prior chats. If nothing needs attention, reply HEARTBEAT_OK.`
|
||||
Set `agents.defaults.heartbeat.every: "0m"` to disable.
|
||||
|
||||
- If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
- If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
- If the agent replies with `HEARTBEAT_OK` (optionally with short padding; see `agents.defaults.heartbeat.ackMaxChars`), Clawdbot suppresses outbound delivery for that heartbeat.
|
||||
- Heartbeats run full agent turns — shorter intervals burn more tokens.
|
||||
|
||||
|
||||
@@ -971,10 +971,6 @@ Heartbeats run every **30m** by default. Tune or disable them:
|
||||
}
|
||||
```
|
||||
|
||||
If `HEARTBEAT.md` exists but is effectively empty (only blank lines and markdown
|
||||
headers like `# Heading`), Clawdbot skips the heartbeat run to save API calls.
|
||||
If the file is missing, the heartbeat still runs and the model decides what to do.
|
||||
|
||||
Per-agent overrides use `agents.list[].heartbeat`. Docs: [Heartbeat](/gateway/heartbeat).
|
||||
|
||||
### Do I need to add a “bot account” to a WhatsApp group?
|
||||
|
||||
@@ -166,19 +166,6 @@ Clawdbot preserves the auth when calling `/json/*` endpoints and when connecting
|
||||
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||
tokens instead of committing them to config files.
|
||||
|
||||
### Node browser proxy (zero-config default)
|
||||
|
||||
If you run a **node host** on the machine that has your browser, Clawdbot can
|
||||
auto-route browser tool calls to that node without any custom `controlUrl`
|
||||
setup. This is the default path for remote gateways.
|
||||
|
||||
Notes:
|
||||
- The node host exposes its local browser control server via a **proxy command**.
|
||||
- Profiles come from the node’s own `browser.profiles` config (same as local).
|
||||
- Disable if you don’t want it:
|
||||
- On the node: `nodeHost.browserProxy.enabled=false`
|
||||
- On the gateway: `gateway.nodes.browser.mode="off"`
|
||||
|
||||
### Browserless (hosted remote CDP)
|
||||
|
||||
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||
|
||||
@@ -12,7 +12,6 @@ Exec approvals are the **companion app / node host guardrail** for letting a san
|
||||
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
|
||||
commands are allowed only when policy + allowlist + (optional) user approval all agree.
|
||||
Exec approvals are **in addition** to tool policy and elevated gating (unless elevated is set to `full`, which skips approvals).
|
||||
Effective policy is the **stricter** of `tools.exec.*` and approvals defaults; if an approvals field is omitted, the `tools.exec` value is used.
|
||||
|
||||
If the companion app UI is **not available**, any request that requires a prompt is
|
||||
resolved by the **ask fallback** (default: deny).
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
normalizeAccountId,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
resolveBlueBubblesGroupRequireMention,
|
||||
resolveBlueBubblesGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
|
||||
@@ -63,7 +62,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveBlueBubblesGroupRequireMention,
|
||||
resolveToolPolicy: resolveBlueBubblesGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => ({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -21,7 +21,6 @@ const bluebubblesActionSchema = z
|
||||
|
||||
const bluebubblesGroupConfigSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const bluebubblesAccountSchema = z.object({
|
||||
|
||||
@@ -4,8 +4,6 @@ export type GroupPolicy = "open" | "disabled" | "allowlist";
|
||||
export type BlueBubblesGroupConfig = {
|
||||
/** If true, only respond in this group when mentioned. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this group. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
};
|
||||
|
||||
export type BlueBubblesAccountConfig = {
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
resolveDiscordAccount,
|
||||
resolveDefaultDiscordAccountId,
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelMessageActionAdapter,
|
||||
type ChannelPlugin,
|
||||
@@ -145,7 +144,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
resolveDefaultIMessageAccountId,
|
||||
resolveIMessageAccount,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
type ChannelPlugin,
|
||||
type ResolvedIMessageAccount,
|
||||
@@ -107,7 +106,6 @@ export const imessagePlugin: ChannelPlugin<ResolvedIMessageAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
targetResolver: {
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
|
||||
import { matrixMessageActions } from "./actions.js";
|
||||
import { MatrixConfigSchema } from "./config-schema.js";
|
||||
import { resolveMatrixGroupRequireMention, resolveMatrixGroupToolPolicy } from "./group-mentions.js";
|
||||
import { resolveMatrixGroupRequireMention } from "./group-mentions.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import {
|
||||
listMatrixAccountIds,
|
||||
@@ -167,7 +167,6 @@ export const matrixPlugin: ChannelPlugin<ResolvedMatrixAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveMatrixGroupRequireMention,
|
||||
resolveToolPolicy: resolveMatrixGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) =>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -26,7 +26,6 @@ const matrixRoomSchema = z
|
||||
enabled: z.boolean().optional(),
|
||||
allow: z.boolean().optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
autoReply: z.boolean().optional(),
|
||||
users: z.array(allowFromEntry).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChannelGroupContext, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
|
||||
|
||||
import { resolveMatrixRoomConfig } from "./matrix/monitor/rooms.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
@@ -32,30 +32,3 @@ export function resolveMatrixGroupRequireMention(params: ChannelGroupContext): b
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveMatrixGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const rawGroupId = params.groupId?.trim() ?? "";
|
||||
let roomId = rawGroupId;
|
||||
const lower = roomId.toLowerCase();
|
||||
if (lower.startsWith("matrix:")) {
|
||||
roomId = roomId.slice("matrix:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("channel:")) {
|
||||
roomId = roomId.slice("channel:".length).trim();
|
||||
}
|
||||
if (roomId.toLowerCase().startsWith("room:")) {
|
||||
roomId = roomId.slice("room:".length).trim();
|
||||
}
|
||||
const groupChannel = params.groupChannel?.trim() ?? "";
|
||||
const aliases = groupChannel ? [groupChannel] : [];
|
||||
const cfg = params.cfg as CoreConfig;
|
||||
const resolved = resolveMatrixRoomConfig({
|
||||
rooms: cfg.channels?.matrix?.groups ?? cfg.channels?.matrix?.rooms,
|
||||
roomId,
|
||||
aliases,
|
||||
name: groupChannel || undefined,
|
||||
}).config;
|
||||
return resolved?.tools;
|
||||
}
|
||||
|
||||
@@ -18,8 +18,6 @@ export type MatrixRoomConfig = {
|
||||
allow?: boolean;
|
||||
/** Require mentioning the bot to trigger replies. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If true, reply without mention requirements. */
|
||||
autoReply?: boolean;
|
||||
/** Optional allowlist for room senders (user IDs or localparts). */
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
import { msteamsOnboardingAdapter } from "./onboarding.js";
|
||||
import { msteamsOutbound } from "./outbound.js";
|
||||
import { probeMSTeams } from "./probe.js";
|
||||
import { resolveMSTeamsGroupToolPolicy } from "./policy.js";
|
||||
import {
|
||||
normalizeMSTeamsMessagingTarget,
|
||||
normalizeMSTeamsUserInput,
|
||||
@@ -78,9 +77,6 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount> = {
|
||||
hasRepliedRef,
|
||||
}),
|
||||
},
|
||||
groups: {
|
||||
resolveToolPolicy: resolveMSTeamsGroupToolPolicy,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.msteams"] },
|
||||
configSchema: buildChannelConfigSchema(MSTeamsConfigSchema),
|
||||
config: {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type {
|
||||
AllowlistMatch,
|
||||
ChannelGroupContext,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyConfig,
|
||||
MSTeamsChannelConfig,
|
||||
MSTeamsConfig,
|
||||
MSTeamsReplyStyle,
|
||||
@@ -88,50 +86,6 @@ export function resolveMSTeamsRouteConfig(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveMSTeamsGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg.channels?.msteams;
|
||||
if (!cfg) return undefined;
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const groupSpace = params.groupSpace?.trim();
|
||||
|
||||
const resolved = resolveMSTeamsRouteConfig({
|
||||
cfg,
|
||||
teamId: groupSpace,
|
||||
teamName: groupSpace,
|
||||
conversationId: groupId,
|
||||
channelName: groupChannel,
|
||||
});
|
||||
|
||||
if (resolved.channelConfig) {
|
||||
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
|
||||
}
|
||||
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
|
||||
|
||||
if (!groupId) return undefined;
|
||||
|
||||
const channelCandidates = buildChannelKeyCandidates(
|
||||
groupId,
|
||||
groupChannel,
|
||||
groupChannel ? normalizeChannelSlug(groupChannel) : undefined,
|
||||
);
|
||||
for (const teamConfig of Object.values(cfg.teams ?? {})) {
|
||||
const match = resolveChannelEntryMatchWithFallback({
|
||||
entries: teamConfig?.channels ?? {},
|
||||
keys: channelCandidates,
|
||||
wildcardKey: "*",
|
||||
normalizeKey: normalizeChannelSlug,
|
||||
});
|
||||
if (match.entry) {
|
||||
return match.entry.tools ?? teamConfig?.tools;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export type MSTeamsReplyPolicy = {
|
||||
requireMention: boolean;
|
||||
replyStyle: MSTeamsReplyStyle;
|
||||
|
||||
@@ -24,7 +24,6 @@ import { nextcloudTalkOnboardingAdapter } from "./onboarding.js";
|
||||
import { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
|
||||
|
||||
const meta = {
|
||||
id: "nextcloud-talk",
|
||||
@@ -160,7 +159,6 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
|
||||
|
||||
return true;
|
||||
},
|
||||
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
@@ -12,7 +11,6 @@ import { z } from "zod";
|
||||
export const NextcloudTalkRoomSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.string()).optional(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "clawdbot/plugin-sdk";
|
||||
import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
@@ -86,21 +86,6 @@ export function resolveNextcloudTalkRoomMatch(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } } };
|
||||
const roomToken = params.groupId?.trim();
|
||||
if (!roomToken) return undefined;
|
||||
const roomName = params.groupChannel?.trim() || undefined;
|
||||
const match = resolveNextcloudTalkRoomMatch({
|
||||
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
||||
roomToken,
|
||||
roomName,
|
||||
});
|
||||
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
}
|
||||
|
||||
export function resolveNextcloudTalkRequireMention(params: {
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
wildcardConfig?: NextcloudTalkRoomConfig;
|
||||
|
||||
@@ -7,8 +7,6 @@ import type {
|
||||
|
||||
export type NextcloudTalkRoomConfig = {
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this room. */
|
||||
tools?: { allow?: string[]; deny?: string[] };
|
||||
/** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this room. */
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
resolveSlackAccount,
|
||||
resolveSlackReplyToMode,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
buildSlackThreadingToolContext,
|
||||
setAccountEnabledInConfigSection,
|
||||
slackOnboardingAdapter,
|
||||
@@ -162,7 +161,6 @@ export const slackPlugin: ChannelPlugin<ResolvedSlackAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
resolveDefaultTelegramAccountId,
|
||||
resolveTelegramAccount,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
setAccountEnabledInConfigSection,
|
||||
telegramOnboardingAdapter,
|
||||
TelegramConfigSchema,
|
||||
@@ -155,7 +154,6 @@ export const telegramPlugin: ChannelPlugin<ResolvedTelegramAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
resolveDefaultWhatsAppAccountId,
|
||||
resolveWhatsAppAccount,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
resolveWhatsAppHeartbeatRecipients,
|
||||
whatsappOnboardingAdapter,
|
||||
WhatsAppConfigSchema,
|
||||
@@ -199,7 +198,6 @@ export const whatsappPlugin: ChannelPlugin<ResolvedWhatsAppAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
||||
resolveGroupIntroHint: () =>
|
||||
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
||||
},
|
||||
|
||||
@@ -2,10 +2,8 @@ import type {
|
||||
ChannelAccountSnapshot,
|
||||
ChannelDirectoryEntry,
|
||||
ChannelDock,
|
||||
ChannelGroupContext,
|
||||
ChannelPlugin,
|
||||
ClawdbotConfig,
|
||||
GroupToolPolicyConfig,
|
||||
} from "clawdbot/plugin-sdk";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
@@ -81,26 +79,6 @@ function mapGroup(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveZalouserGroupToolPolicy(
|
||||
params: ChannelGroupContext,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const account = resolveZalouserAccountSync({
|
||||
cfg: params.cfg as ClawdbotConfig,
|
||||
accountId: params.accountId ?? undefined,
|
||||
});
|
||||
const groups = account.config.groups ?? {};
|
||||
const groupId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel?.trim();
|
||||
const candidates = [groupId, groupChannel, "*"].filter(
|
||||
(value): value is string => Boolean(value),
|
||||
);
|
||||
for (const key of candidates) {
|
||||
const entry = groups[key];
|
||||
if (entry?.tools) return entry.tools;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export const zalouserDock: ChannelDock = {
|
||||
id: "zalouser",
|
||||
capabilities: {
|
||||
@@ -123,7 +101,6 @@ export const zalouserDock: ChannelDock = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
@@ -211,7 +188,6 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: () => true,
|
||||
resolveToolPolicy: resolveZalouserGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: () => "off",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MarkdownConfigSchema, ToolPolicySchema } from "clawdbot/plugin-sdk";
|
||||
import { MarkdownConfigSchema } from "clawdbot/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
@@ -6,7 +6,6 @@ const allowFromEntry = z.union([z.string(), z.number()]);
|
||||
const groupConfigSchema = z.object({
|
||||
allow: z.boolean().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
});
|
||||
|
||||
const zalouserAccountSchema = z.object({
|
||||
|
||||
@@ -75,7 +75,7 @@ export type ZalouserAccountConfig = {
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
messagePrefix?: string;
|
||||
};
|
||||
|
||||
@@ -87,7 +87,7 @@ export type ZalouserConfig = {
|
||||
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: "open" | "allowlist" | "disabled";
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean; tools?: { allow?: string[]; deny?: string[] } }>;
|
||||
groups?: Record<string, { allow?: boolean; enabled?: boolean }>;
|
||||
messagePrefix?: string;
|
||||
accounts?: Record<string, ZalouserAccountConfig>;
|
||||
};
|
||||
|
||||
@@ -129,25 +129,4 @@ describe("exec approvals", () => {
|
||||
expect(calls).toContain("node.invoke");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
|
||||
it("honors ask=off for elevated gateway exec without prompting", async () => {
|
||||
const { callGatewayTool } = await import("./tools/gateway.js");
|
||||
const calls: string[] = [];
|
||||
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
|
||||
calls.push(method);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
const { createExecTool } = await import("./bash-tools.exec.js");
|
||||
const tool = createExecTool({
|
||||
ask: "off",
|
||||
security: "full",
|
||||
approvalRunningNoticeMs: 0,
|
||||
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
|
||||
});
|
||||
|
||||
const result = await tool.execute("call3", { command: "echo ok", elevated: true });
|
||||
expect(result.details.status).toBe("completed");
|
||||
expect(calls).not.toContain("exec.approval.request");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -838,7 +838,10 @@ export function createExecTool(
|
||||
applyPathPrepend(env, defaultPathPrepend);
|
||||
|
||||
if (host === "node") {
|
||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
||||
const approvals = resolveExecApprovals(
|
||||
agentId,
|
||||
host === "node" ? { security: "allowlist" } : undefined,
|
||||
);
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
@@ -1109,7 +1112,7 @@ export function createExecTool(
|
||||
}
|
||||
|
||||
if (host === "gateway" && !bypassApprovals) {
|
||||
const approvals = resolveExecApprovals(agentId, { security, ask });
|
||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
|
||||
@@ -31,12 +31,6 @@ export function createClawdbotTools(options?: {
|
||||
agentTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
agentThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy inheritance. */
|
||||
agentGroupId?: string | null;
|
||||
/** Group channel label for channel-level tool policy inheritance. */
|
||||
agentGroupChannel?: string | null;
|
||||
/** Group space label for channel-level tool policy inheritance. */
|
||||
agentGroupSpace?: string | null;
|
||||
agentDir?: string;
|
||||
sandboxRoot?: string;
|
||||
workspaceDir?: string;
|
||||
@@ -120,9 +114,6 @@ export function createClawdbotTools(options?: {
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentTo: options?.agentTo,
|
||||
agentThreadId: options?.agentThreadId,
|
||||
agentGroupId: options?.agentGroupId,
|
||||
agentGroupChannel: options?.agentGroupChannel,
|
||||
agentGroupSpace: options?.agentGroupSpace,
|
||||
sandboxed: options?.sandboxed,
|
||||
}),
|
||||
createSessionStatusTool({
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { parseIdentityMarkdown } from "./identity-file.js";
|
||||
|
||||
describe("parseIdentityMarkdown", () => {
|
||||
it("ignores identity template placeholders", () => {
|
||||
const content = `
|
||||
# IDENTITY.md - Who Am I?
|
||||
|
||||
- **Name:** *(pick something you like)*
|
||||
- **Creature:** *(AI? robot? familiar? ghost in the machine? something weirder?)*
|
||||
- **Vibe:** *(how do you come across? sharp? warm? chaotic? calm?)*
|
||||
- **Emoji:** *(your signature - pick one that feels right)*
|
||||
- **Avatar:** *(workspace-relative path, http(s) URL, or data URI)*
|
||||
`;
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
|
||||
it("parses explicit identity values", () => {
|
||||
const content = `
|
||||
- **Name:** Samantha
|
||||
- **Creature:** Robot
|
||||
- **Vibe:** Warm
|
||||
- **Emoji:** :robot:
|
||||
- **Avatar:** avatars/clawd.png
|
||||
`;
|
||||
const parsed = parseIdentityMarkdown(content);
|
||||
expect(parsed).toEqual({
|
||||
name: "Samantha",
|
||||
creature: "Robot",
|
||||
vibe: "Warm",
|
||||
emoji: ":robot:",
|
||||
avatar: "avatars/clawd.png",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -12,30 +12,6 @@ export type AgentIdentityFile = {
|
||||
avatar?: string;
|
||||
};
|
||||
|
||||
const IDENTITY_PLACEHOLDER_VALUES = new Set([
|
||||
"pick something you like",
|
||||
"ai? robot? familiar? ghost in the machine? something weirder?",
|
||||
"how do you come across? sharp? warm? chaotic? calm?",
|
||||
"your signature - pick one that feels right",
|
||||
"workspace-relative path, http(s) url, or data uri",
|
||||
]);
|
||||
|
||||
function normalizeIdentityValue(value: string): string {
|
||||
let normalized = value.trim();
|
||||
normalized = normalized.replace(/^[*_]+|[*_]+$/g, "").trim();
|
||||
if (normalized.startsWith("(") && normalized.endsWith(")")) {
|
||||
normalized = normalized.slice(1, -1).trim();
|
||||
}
|
||||
normalized = normalized.replace(/[\u2013\u2014]/g, "-");
|
||||
normalized = normalized.replace(/\s+/g, " ").toLowerCase();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isIdentityPlaceholder(value: string): boolean {
|
||||
const normalized = normalizeIdentityValue(value);
|
||||
return IDENTITY_PLACEHOLDER_VALUES.has(normalized);
|
||||
}
|
||||
|
||||
export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
const identity: AgentIdentityFile = {};
|
||||
const lines = content.split(/\r?\n/);
|
||||
@@ -49,7 +25,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentityFile {
|
||||
.replace(/^[*_]+|[*_]+$/g, "")
|
||||
.trim();
|
||||
if (!value) continue;
|
||||
if (isIdentityPlaceholder(value)) continue;
|
||||
if (label === "name") identity.name = value;
|
||||
if (label === "emoji") identity.emoji = value;
|
||||
if (label === "creature") identity.creature = value;
|
||||
|
||||
@@ -63,12 +63,12 @@ const makeAttempt = (overrides: Partial<EmbeddedRunAttemptResult>): EmbeddedRunA
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotConfig =>
|
||||
const makeConfig = (): ClawdbotConfig =>
|
||||
({
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
fallbacks: opts?.fallbacks ?? [],
|
||||
fallbacks: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -76,7 +76,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
|
||||
providers: {
|
||||
openai: {
|
||||
api: "openai-responses",
|
||||
apiKey: opts?.apiKey ?? "sk-test",
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://example.com",
|
||||
models: [
|
||||
{
|
||||
@@ -94,13 +94,7 @@ const makeConfig = (opts?: { fallbacks?: string[]; apiKey?: string }): ClawdbotC
|
||||
},
|
||||
}) satisfies ClawdbotConfig;
|
||||
|
||||
const writeAuthStore = async (
|
||||
agentDir: string,
|
||||
opts?: {
|
||||
includeAnthropic?: boolean;
|
||||
usageStats?: Record<string, { lastUsed?: number; cooldownUntil?: number }>;
|
||||
},
|
||||
) => {
|
||||
const writeAuthStore = async (agentDir: string, opts?: { includeAnthropic?: boolean }) => {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
const payload = {
|
||||
version: 1,
|
||||
@@ -111,12 +105,10 @@ const writeAuthStore = async (
|
||||
? { "anthropic:default": { type: "api_key", provider: "anthropic", key: "sk-anth" } }
|
||||
: {}),
|
||||
},
|
||||
usageStats:
|
||||
opts?.usageStats ??
|
||||
({
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
} as Record<string, { lastUsed?: number }>),
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1 },
|
||||
"openai:p2": { lastUsed: 2 },
|
||||
},
|
||||
};
|
||||
await fs.writeFile(authPath, JSON.stringify(payload));
|
||||
};
|
||||
@@ -392,92 +384,6 @@ describe("runEmbeddedPiAgent auth profile rotation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("fails over when all profiles are in cooldown and fallbacks are configured", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const now = Date.now();
|
||||
vi.setSystemTime(now);
|
||||
|
||||
try {
|
||||
await writeAuthStore(agentDir, {
|
||||
usageStats: {
|
||||
"openai:p1": { lastUsed: 1, cooldownUntil: now + 60 * 60 * 1000 },
|
||||
"openai:p2": { lastUsed: 2, cooldownUntil: now + 60 * 60 * 1000 },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:cooldown-failover",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"] }),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:cooldown-failover",
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
name: "FailoverError",
|
||||
reason: "rate_limit",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
});
|
||||
|
||||
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails over when auth is unavailable and fallbacks are configured", async () => {
|
||||
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-agent-"));
|
||||
const workspaceDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-workspace-"));
|
||||
const previousOpenAiKey = process.env.OPENAI_API_KEY;
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
try {
|
||||
const authPath = path.join(agentDir, "auth-profiles.json");
|
||||
await fs.writeFile(authPath, JSON.stringify({ version: 1, profiles: {}, usageStats: {} }));
|
||||
|
||||
await expect(
|
||||
runEmbeddedPiAgent({
|
||||
sessionId: "session:test",
|
||||
sessionKey: "agent:test:auth-unavailable",
|
||||
sessionFile: path.join(workspaceDir, "session.jsonl"),
|
||||
workspaceDir,
|
||||
agentDir,
|
||||
config: makeConfig({ fallbacks: ["openai/mock-2"], apiKey: "" }),
|
||||
prompt: "hello",
|
||||
provider: "openai",
|
||||
model: "mock-1",
|
||||
authProfileIdSource: "auto",
|
||||
timeoutMs: 5_000,
|
||||
runId: "run:auth-unavailable",
|
||||
}),
|
||||
).rejects.toMatchObject({ name: "FailoverError", reason: "auth" });
|
||||
|
||||
expect(runEmbeddedAttemptMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (previousOpenAiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
} else {
|
||||
process.env.OPENAI_API_KEY = previousOpenAiKey;
|
||||
}
|
||||
await fs.rm(agentDir, { recursive: true, force: true });
|
||||
await fs.rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("skips profiles in cooldown when rotating after failure", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
|
||||
@@ -73,14 +73,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
messageChannel?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
agentDir?: string;
|
||||
@@ -215,10 +207,6 @@ export async function compactEmbeddedPiSession(params: {
|
||||
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,
|
||||
|
||||
@@ -38,7 +38,6 @@ import {
|
||||
isRateLimitAssistantError,
|
||||
isTimeoutErrorMessage,
|
||||
pickFallbackThinkingLevel,
|
||||
type FailoverReason,
|
||||
} from "../pi-embedded-helpers.js";
|
||||
import { normalizeUsage, type UsageLike } from "../usage.js";
|
||||
|
||||
@@ -93,8 +92,6 @@ export async function runEmbeddedPiAgent(
|
||||
const provider = (params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
|
||||
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
|
||||
const agentDir = params.agentDir ?? resolveClawdbotAgentDir();
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
await ensureClawdbotModelsJson(params.config, agentDir);
|
||||
|
||||
const { model, error, authStorage, modelRegistry } = resolveModel(
|
||||
@@ -168,42 +165,6 @@ export async function runEmbeddedPiAgent(
|
||||
let apiKeyInfo: ApiKeyInfo | null = null;
|
||||
let lastProfileId: string | undefined;
|
||||
|
||||
const resolveAuthProfileFailoverReason = (params: {
|
||||
allInCooldown: boolean;
|
||||
message: string;
|
||||
}): FailoverReason => {
|
||||
if (params.allInCooldown) return "rate_limit";
|
||||
const classified = classifyFailoverReason(params.message);
|
||||
return classified ?? "auth";
|
||||
};
|
||||
|
||||
const throwAuthProfileFailover = (params: {
|
||||
allInCooldown: boolean;
|
||||
message?: string;
|
||||
error?: unknown;
|
||||
}): never => {
|
||||
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
|
||||
const message =
|
||||
params.message?.trim() ||
|
||||
(params.error ? describeUnknownError(params.error).trim() : "") ||
|
||||
fallbackMessage;
|
||||
const reason = resolveAuthProfileFailoverReason({
|
||||
allInCooldown: params.allInCooldown,
|
||||
message,
|
||||
});
|
||||
if (fallbackConfigured) {
|
||||
throw new FailoverError(message, {
|
||||
reason,
|
||||
provider,
|
||||
model: modelId,
|
||||
status: resolveFailoverStatus(reason),
|
||||
cause: params.error,
|
||||
});
|
||||
}
|
||||
if (params.error instanceof Error) throw params.error;
|
||||
throw new Error(message);
|
||||
};
|
||||
|
||||
const resolveApiKeyForCandidate = async (candidate?: string) => {
|
||||
return getApiKeyForModel({
|
||||
model,
|
||||
@@ -277,17 +238,14 @@ export async function runEmbeddedPiAgent(
|
||||
break;
|
||||
}
|
||||
if (profileIndex >= profileCandidates.length) {
|
||||
throwAuthProfileFailover({ allInCooldown: true });
|
||||
throw new Error(
|
||||
`No available auth profile for ${provider} (all in cooldown or unavailable).`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof FailoverError) throw err;
|
||||
if (profileCandidates[profileIndex] === lockedProfileId) {
|
||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
||||
}
|
||||
if (profileCandidates[profileIndex] === lockedProfileId) throw err;
|
||||
const advanced = await advanceAuthProfile();
|
||||
if (!advanced) {
|
||||
throwAuthProfileFailover({ allInCooldown: false, error: err });
|
||||
}
|
||||
if (!advanced) throw err;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -306,10 +264,6 @@ export async function runEmbeddedPiAgent(
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentThreadTs: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
@@ -435,7 +389,9 @@ export async function runEmbeddedPiAgent(
|
||||
}
|
||||
// FIX: Throw FailoverError for prompt errors when fallbacks configured
|
||||
// This enables model fallback for quota/rate limit errors during prompt submission
|
||||
if (fallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||
const promptFallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
if (promptFallbackConfigured && isFailoverErrorMessage(errorText)) {
|
||||
throw new FailoverError(errorText, {
|
||||
reason: promptFailoverReason ?? "unknown",
|
||||
provider,
|
||||
@@ -459,6 +415,8 @@ export async function runEmbeddedPiAgent(
|
||||
continue;
|
||||
}
|
||||
|
||||
const fallbackConfigured =
|
||||
(params.config?.agents?.defaults?.model?.fallbacks?.length ?? 0) > 0;
|
||||
const authFailure = isAuthAssistantError(lastAssistant);
|
||||
const rateLimitFailure = isRateLimitAssistantError(lastAssistant);
|
||||
const failoverFailure = isFailoverAssistantError(lastAssistant);
|
||||
|
||||
@@ -208,10 +208,6 @@ export async function runEmbeddedAttempt(
|
||||
agentAccountId: params.agentAccountId,
|
||||
messageTo: params.messageTo,
|
||||
messageThreadId: params.messageThreadId,
|
||||
groupId: params.groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
spawnedBy: params.spawnedBy,
|
||||
sessionKey: params.sessionKey ?? params.sessionId,
|
||||
agentDir,
|
||||
workspaceDir: effectiveWorkspace,
|
||||
|
||||
@@ -27,14 +27,6 @@ export type RunEmbeddedPiAgentParams = {
|
||||
messageTo?: string;
|
||||
/** Thread/topic identifier for routing replies to the originating thread. */
|
||||
messageThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
/** Current channel ID for auto-threading (Slack). */
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
|
||||
@@ -23,14 +23,6 @@ export type EmbeddedRunAttemptParams = {
|
||||
agentAccountId?: string;
|
||||
messageTo?: string;
|
||||
messageThreadId?: string | number;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
|
||||
@@ -43,7 +43,7 @@ export function abortEmbeddedPiRun(sessionId: string): boolean {
|
||||
diag.debug(`abort failed: sessionId=${sessionId} reason=no_active_run`);
|
||||
return false;
|
||||
}
|
||||
diag.debug(`aborting run: sessionId=${sessionId}`);
|
||||
diag.info(`aborting run: sessionId=${sessionId}`);
|
||||
handle.abort();
|
||||
return true;
|
||||
}
|
||||
@@ -110,7 +110,7 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
|
||||
reason: wasActive ? "run_replaced" : "run_started",
|
||||
});
|
||||
if (!sessionId.startsWith("probe-")) {
|
||||
diag.debug(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueu
|
||||
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
|
||||
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
|
||||
if (!sessionId.startsWith("probe-")) {
|
||||
diag.debug(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
|
||||
}
|
||||
notifyEmbeddedRunEnded(sessionId);
|
||||
} else {
|
||||
|
||||
@@ -231,95 +231,6 @@ describe("Agent-specific tool filtering", () => {
|
||||
expect(familyToolNames).not.toContain("apply_patch");
|
||||
});
|
||||
|
||||
it("should apply group tool policy overrides (group-specific beats wildcard)", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
"*": {
|
||||
tools: { allow: ["read"] },
|
||||
},
|
||||
trusted: {
|
||||
tools: { allow: ["read", "exec"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const trustedTools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:trusted",
|
||||
messageProvider: "whatsapp",
|
||||
workspaceDir: "/tmp/test-group-trusted",
|
||||
agentDir: "/tmp/agent-group",
|
||||
});
|
||||
const trustedNames = trustedTools.map((t) => t.name);
|
||||
expect(trustedNames).toContain("read");
|
||||
expect(trustedNames).toContain("exec");
|
||||
|
||||
const defaultTools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:whatsapp:group:unknown",
|
||||
messageProvider: "whatsapp",
|
||||
workspaceDir: "/tmp/test-group-default",
|
||||
agentDir: "/tmp/agent-group",
|
||||
});
|
||||
const defaultNames = defaultTools.map((t) => t.name);
|
||||
expect(defaultNames).toContain("read");
|
||||
expect(defaultNames).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should resolve telegram group tool policy for topic session keys", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
telegram: {
|
||||
groups: {
|
||||
"123": {
|
||||
tools: { allow: ["read"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:telegram:group:123:topic:456",
|
||||
messageProvider: "telegram",
|
||||
workspaceDir: "/tmp/test-telegram-topic",
|
||||
agentDir: "/tmp/agent-telegram",
|
||||
});
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain("read");
|
||||
expect(names).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should inherit group tool policy for subagents from spawnedBy session keys", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
channels: {
|
||||
whatsapp: {
|
||||
groups: {
|
||||
trusted: {
|
||||
tools: { allow: ["read"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const tools = createClawdbotCodingTools({
|
||||
config: cfg,
|
||||
sessionKey: "agent:main:subagent:test",
|
||||
spawnedBy: "agent:main:whatsapp:group:trusted",
|
||||
workspaceDir: "/tmp/test-subagent-group",
|
||||
agentDir: "/tmp/agent-subagent",
|
||||
});
|
||||
const names = tools.map((t) => t.name);
|
||||
expect(names).toContain("read");
|
||||
expect(names).not.toContain("exec");
|
||||
});
|
||||
|
||||
it("should apply global tool policy before agent-specific policy", () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
tools: {
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { getChannelDock } from "../channels/dock.js";
|
||||
import { resolveChannelGroupToolsPolicy } from "../config/group-policy.js";
|
||||
import { resolveAgentConfig, resolveAgentIdFromSessionKey } from "./agent-scope.js";
|
||||
import type { AnyAgentTool } from "./pi-tools.types.js";
|
||||
import type { SandboxToolPolicy } from "./sandbox.js";
|
||||
import { expandToolGroups, normalizeToolName } from "./tool-policy.js";
|
||||
import { normalizeMessageChannel } from "../utils/message-channel.js";
|
||||
import { resolveThreadParentSessionKey } from "../sessions/session-key-utils.js";
|
||||
|
||||
type CompiledPattern =
|
||||
| { kind: "all" }
|
||||
@@ -112,26 +108,6 @@ function normalizeProviderKey(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function resolveGroupContextFromSessionKey(sessionKey?: string | null): {
|
||||
channel?: string;
|
||||
groupId?: string;
|
||||
} {
|
||||
const raw = (sessionKey ?? "").trim();
|
||||
if (!raw) return {};
|
||||
const base = resolveThreadParentSessionKey(raw) ?? raw;
|
||||
const parts = base.split(":").filter(Boolean);
|
||||
let body = parts[0] === "agent" ? parts.slice(2) : parts;
|
||||
if (body[0] === "subagent") {
|
||||
body = body.slice(1);
|
||||
}
|
||||
if (body.length < 3) return {};
|
||||
const [channel, kind, ...rest] = body;
|
||||
if (kind !== "group" && kind !== "channel") return {};
|
||||
const groupId = rest.join(":").trim();
|
||||
if (!groupId) return {};
|
||||
return { channel: channel.trim().toLowerCase(), groupId };
|
||||
}
|
||||
|
||||
function resolveProviderToolPolicy(params: {
|
||||
byProvider?: Record<string, ToolPolicyConfig>;
|
||||
modelProvider?: string;
|
||||
@@ -198,47 +174,6 @@ export function resolveEffectiveToolPolicy(params: {
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveGroupToolPolicy(params: {
|
||||
config?: ClawdbotConfig;
|
||||
sessionKey?: string;
|
||||
spawnedBy?: string | null;
|
||||
messageProvider?: string;
|
||||
groupId?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
accountId?: string | null;
|
||||
}): SandboxToolPolicy | undefined {
|
||||
if (!params.config) return undefined;
|
||||
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
|
||||
const spawnedContext = resolveGroupContextFromSessionKey(params.spawnedBy);
|
||||
const groupId = params.groupId ?? sessionContext.groupId ?? spawnedContext.groupId;
|
||||
if (!groupId) return undefined;
|
||||
const channelRaw = params.messageProvider ?? sessionContext.channel ?? spawnedContext.channel;
|
||||
const channel = normalizeMessageChannel(channelRaw);
|
||||
if (!channel) return undefined;
|
||||
let dock;
|
||||
try {
|
||||
dock = getChannelDock(channel);
|
||||
} catch {
|
||||
dock = undefined;
|
||||
}
|
||||
const toolsConfig =
|
||||
dock?.groups?.resolveToolPolicy?.({
|
||||
cfg: params.config,
|
||||
groupId,
|
||||
groupChannel: params.groupChannel,
|
||||
groupSpace: params.groupSpace,
|
||||
accountId: params.accountId,
|
||||
}) ??
|
||||
resolveChannelGroupToolsPolicy({
|
||||
cfg: params.config,
|
||||
channel,
|
||||
groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
return pickToolPolicy(toolsConfig);
|
||||
}
|
||||
|
||||
export function isToolAllowedByPolicies(
|
||||
name: string,
|
||||
policies: Array<SandboxToolPolicy | undefined>,
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
filterToolsByPolicy,
|
||||
isToolAllowedByPolicies,
|
||||
resolveEffectiveToolPolicy,
|
||||
resolveGroupToolPolicy,
|
||||
resolveSubagentToolPolicy,
|
||||
} from "./pi-tools.policy.js";
|
||||
import {
|
||||
@@ -129,14 +128,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
currentChannelId?: string;
|
||||
/** Current thread timestamp for auto-threading (Slack). */
|
||||
currentThreadTs?: string;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label (e.g. #general) for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label (e.g. guild/team id) for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent group policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
/** Reply-to mode for Slack auto-threading. */
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
/** Mutable ref to track if a reply was sent (for "first" mode). */
|
||||
@@ -160,16 +151,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
modelProvider: options?.modelProvider,
|
||||
modelId: options?.modelId,
|
||||
});
|
||||
const groupPolicy = resolveGroupToolPolicy({
|
||||
config: options?.config,
|
||||
sessionKey: options?.sessionKey,
|
||||
spawnedBy: options?.spawnedBy,
|
||||
messageProvider: options?.messageProvider,
|
||||
groupId: options?.groupId,
|
||||
groupChannel: options?.groupChannel,
|
||||
groupSpace: options?.groupSpace,
|
||||
accountId: options?.agentAccountId,
|
||||
});
|
||||
const profilePolicy = resolveToolProfilePolicy(profile);
|
||||
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
|
||||
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
|
||||
@@ -184,7 +165,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
sandbox?.tools,
|
||||
subagentPolicy,
|
||||
]);
|
||||
@@ -293,9 +273,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
agentAccountId: options?.agentAccountId,
|
||||
agentTo: options?.messageTo,
|
||||
agentThreadId: options?.messageThreadId,
|
||||
agentGroupId: options?.groupId ?? null,
|
||||
agentGroupChannel: options?.groupChannel ?? null,
|
||||
agentGroupSpace: options?.groupSpace ?? null,
|
||||
agentDir: options?.agentDir,
|
||||
sandboxRoot,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
@@ -308,7 +285,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
globalProviderPolicy,
|
||||
agentPolicy,
|
||||
agentProviderPolicy,
|
||||
groupPolicy,
|
||||
sandbox?.tools,
|
||||
subagentPolicy,
|
||||
]),
|
||||
@@ -347,10 +323,6 @@ export function createClawdbotCodingTools(options?: {
|
||||
stripPluginOnlyAllowlist(agentProviderPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const groupPolicyExpanded = expandPolicyWithPluginGroups(
|
||||
stripPluginOnlyAllowlist(groupPolicy, pluginGroups),
|
||||
pluginGroups,
|
||||
);
|
||||
const sandboxPolicyExpanded = expandPolicyWithPluginGroups(sandbox?.tools, pluginGroups);
|
||||
const subagentPolicyExpanded = expandPolicyWithPluginGroups(subagentPolicy, pluginGroups);
|
||||
|
||||
@@ -372,12 +344,9 @@ export function createClawdbotCodingTools(options?: {
|
||||
const agentProviderFiltered = agentProviderExpanded
|
||||
? filterToolsByPolicy(agentFiltered, agentProviderExpanded)
|
||||
: agentFiltered;
|
||||
const groupFiltered = groupPolicyExpanded
|
||||
? filterToolsByPolicy(agentProviderFiltered, groupPolicyExpanded)
|
||||
: agentProviderFiltered;
|
||||
const sandboxed = sandboxPolicyExpanded
|
||||
? filterToolsByPolicy(groupFiltered, sandboxPolicyExpanded)
|
||||
: groupFiltered;
|
||||
? filterToolsByPolicy(agentProviderFiltered, sandboxPolicyExpanded)
|
||||
: agentProviderFiltered;
|
||||
const subagentFiltered = subagentPolicyExpanded
|
||||
? filterToolsByPolicy(sandboxed, subagentPolicyExpanded)
|
||||
: sandboxed;
|
||||
|
||||
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
||||
"act",
|
||||
] as const;
|
||||
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
|
||||
const BROWSER_TARGETS = ["sandbox", "host", "custom"] as const;
|
||||
|
||||
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
|
||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||
@@ -84,7 +84,6 @@ const BrowserActSchema = Type.Object({
|
||||
export const BrowserToolSchema = Type.Object({
|
||||
action: stringEnum(BROWSER_TOOL_ACTIONS),
|
||||
target: optionalStringEnum(BROWSER_TARGETS),
|
||||
node: Type.Optional(Type.String()),
|
||||
profile: Type.Optional(Type.String()),
|
||||
controlUrl: Type.Optional(Type.String()),
|
||||
targetUrl: Type.Optional(Type.String()),
|
||||
|
||||
@@ -49,25 +49,6 @@ const browserConfigMocks = vi.hoisted(() => ({
|
||||
}));
|
||||
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||
|
||||
const nodesUtilsMocks = vi.hoisted(() => ({
|
||||
listNodes: vi.fn(async () => []),
|
||||
}));
|
||||
vi.mock("./nodes-utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./nodes-utils.js")>("./nodes-utils.js");
|
||||
return {
|
||||
...actual,
|
||||
listNodes: nodesUtilsMocks.listNodes,
|
||||
};
|
||||
});
|
||||
|
||||
const gatewayMocks = vi.hoisted(() => ({
|
||||
callGatewayTool: vi.fn(async () => ({
|
||||
ok: true,
|
||||
payload: { result: { ok: true, running: true } },
|
||||
})),
|
||||
}));
|
||||
vi.mock("./gateway.js", () => gatewayMocks);
|
||||
|
||||
const configMocks = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({ browser: {} })),
|
||||
}));
|
||||
@@ -91,7 +72,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
configMocks.loadConfig.mockReturnValue({ browser: {} });
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("applies the default ai snapshot limit", async () => {
|
||||
@@ -195,70 +175,6 @@ describe("browser tool snapshot maxChars", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("routes to node proxy when target=node", async () => {
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||
{
|
||||
nodeId: "node-1",
|
||||
displayName: "Browser Node",
|
||||
connected: true,
|
||||
caps: ["browser"],
|
||||
commands: ["browser.proxy"],
|
||||
},
|
||||
]);
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "status", target: "node" });
|
||||
|
||||
expect(gatewayMocks.callGatewayTool).toHaveBeenCalledWith(
|
||||
"node.invoke",
|
||||
{ timeoutMs: 20000 },
|
||||
expect.objectContaining({
|
||||
nodeId: "node-1",
|
||||
command: "browser.proxy",
|
||||
}),
|
||||
);
|
||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps sandbox control url when node proxy is available", async () => {
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||
{
|
||||
nodeId: "node-1",
|
||||
displayName: "Browser Node",
|
||||
connected: true,
|
||||
caps: ["browser"],
|
||||
commands: ["browser.proxy"],
|
||||
},
|
||||
]);
|
||||
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
|
||||
await tool.execute?.(null, { action: "status" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:9999",
|
||||
expect.objectContaining({ profile: undefined }),
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps chrome profile on host when node proxy is available", async () => {
|
||||
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||
{
|
||||
nodeId: "node-1",
|
||||
displayName: "Browser Node",
|
||||
connected: true,
|
||||
caps: ["browser"],
|
||||
commands: ["browser.proxy"],
|
||||
},
|
||||
]);
|
||||
const tool = createBrowserTool();
|
||||
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
||||
|
||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:18791",
|
||||
expect.objectContaining({ profile: "chrome" }),
|
||||
);
|
||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser tool snapshot labels", () => {
|
||||
|
||||
@@ -18,173 +18,11 @@ import {
|
||||
browserPdfSave,
|
||||
browserScreenshotAction,
|
||||
} from "../../browser/client-actions.js";
|
||||
import crypto from "node:crypto";
|
||||
|
||||
import { resolveBrowserConfig } from "../../browser/config.js";
|
||||
import { DEFAULT_AI_SNAPSHOT_MAX_CHARS } from "../../browser/constants.js";
|
||||
import { loadConfig } from "../../config/config.js";
|
||||
import { saveMediaBuffer } from "../../media/store.js";
|
||||
import { listNodes, resolveNodeIdFromList, type NodeListNode } from "./nodes-utils.js";
|
||||
import { BrowserToolSchema } from "./browser-tool.schema.js";
|
||||
import { type AnyAgentTool, imageResultFromFile, jsonResult, readStringParam } from "./common.js";
|
||||
import { callGatewayTool } from "./gateway.js";
|
||||
|
||||
type BrowserProxyFile = {
|
||||
path: string;
|
||||
base64: string;
|
||||
mimeType?: string;
|
||||
};
|
||||
|
||||
type BrowserProxyResult = {
|
||||
result: unknown;
|
||||
files?: BrowserProxyFile[];
|
||||
};
|
||||
|
||||
const DEFAULT_BROWSER_PROXY_TIMEOUT_MS = 20_000;
|
||||
|
||||
type BrowserNodeTarget = {
|
||||
nodeId: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
function isBrowserNode(node: NodeListNode) {
|
||||
const caps = Array.isArray(node.caps) ? node.caps : [];
|
||||
const commands = Array.isArray(node.commands) ? node.commands : [];
|
||||
return caps.includes("browser") || commands.includes("browser.proxy");
|
||||
}
|
||||
|
||||
async function resolveBrowserNodeTarget(params: {
|
||||
requestedNode?: string;
|
||||
target?: "sandbox" | "host" | "custom" | "node";
|
||||
controlUrl?: string;
|
||||
defaultControlUrl?: string;
|
||||
}): Promise<BrowserNodeTarget | null> {
|
||||
const cfg = loadConfig();
|
||||
const policy = cfg.gateway?.nodes?.browser;
|
||||
const mode = policy?.mode ?? "auto";
|
||||
if (mode === "off") {
|
||||
if (params.target === "node" || params.requestedNode) {
|
||||
throw new Error("Node browser proxy is disabled (gateway.nodes.browser.mode=off).");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
}
|
||||
if (params.controlUrl?.trim()) return null;
|
||||
if (params.target && params.target !== "node") return null;
|
||||
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nodes = await listNodes({});
|
||||
const browserNodes = nodes.filter((node) => node.connected && isBrowserNode(node));
|
||||
if (browserNodes.length === 0) {
|
||||
if (params.target === "node" || params.requestedNode) {
|
||||
throw new Error("No connected browser-capable nodes.");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const requested = params.requestedNode?.trim() || policy?.node?.trim();
|
||||
if (requested) {
|
||||
const nodeId = resolveNodeIdFromList(browserNodes, requested, false);
|
||||
const node = browserNodes.find((entry) => entry.nodeId === nodeId);
|
||||
return { nodeId, label: node?.displayName ?? node?.remoteIp ?? nodeId };
|
||||
}
|
||||
|
||||
if (params.target === "node") {
|
||||
if (browserNodes.length === 1) {
|
||||
const node = browserNodes[0]!;
|
||||
return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId };
|
||||
}
|
||||
throw new Error(
|
||||
`Multiple browser-capable nodes connected (${browserNodes.length}). Set gateway.nodes.browser.node or pass node=<id>.`,
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "manual") return null;
|
||||
|
||||
if (browserNodes.length === 1) {
|
||||
const node = browserNodes[0]!;
|
||||
return { nodeId: node.nodeId, label: node.displayName ?? node.remoteIp ?? node.nodeId };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function callBrowserProxy(params: {
|
||||
nodeId: string;
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
}): Promise<BrowserProxyResult> {
|
||||
const gatewayTimeoutMs =
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? Math.max(1, Math.floor(params.timeoutMs))
|
||||
: DEFAULT_BROWSER_PROXY_TIMEOUT_MS;
|
||||
const payload = (await callGatewayTool(
|
||||
"node.invoke",
|
||||
{ timeoutMs: gatewayTimeoutMs },
|
||||
{
|
||||
nodeId: params.nodeId,
|
||||
command: "browser.proxy",
|
||||
params: {
|
||||
method: params.method,
|
||||
path: params.path,
|
||||
query: params.query,
|
||||
body: params.body,
|
||||
timeoutMs: params.timeoutMs,
|
||||
profile: params.profile,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
},
|
||||
)) as {
|
||||
ok?: boolean;
|
||||
payload?: BrowserProxyResult;
|
||||
payloadJSON?: string | null;
|
||||
};
|
||||
const parsed =
|
||||
payload?.payload ??
|
||||
(typeof payload?.payloadJSON === "string" && payload.payloadJSON
|
||||
? (JSON.parse(payload.payloadJSON) as BrowserProxyResult)
|
||||
: null);
|
||||
if (!parsed || typeof parsed !== "object") {
|
||||
throw new Error("browser proxy failed");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function persistProxyFiles(files: BrowserProxyFile[] | undefined) {
|
||||
if (!files || files.length === 0) return new Map<string, string>();
|
||||
const mapping = new Map<string, string>();
|
||||
for (const file of files) {
|
||||
const buffer = Buffer.from(file.base64, "base64");
|
||||
const saved = await saveMediaBuffer(buffer, file.mimeType, "browser", buffer.byteLength);
|
||||
mapping.set(file.path, saved.path);
|
||||
}
|
||||
return mapping;
|
||||
}
|
||||
|
||||
function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
||||
if (!result || typeof result !== "object") return;
|
||||
const obj = result as Record<string, unknown>;
|
||||
if (typeof obj.path === "string" && mapping.has(obj.path)) {
|
||||
obj.path = mapping.get(obj.path);
|
||||
}
|
||||
if (typeof obj.imagePath === "string" && mapping.has(obj.imagePath)) {
|
||||
obj.imagePath = mapping.get(obj.imagePath);
|
||||
}
|
||||
const download = obj.download;
|
||||
if (download && typeof download === "object") {
|
||||
const d = download as Record<string, unknown>;
|
||||
if (typeof d.path === "string" && mapping.has(d.path)) {
|
||||
d.path = mapping.get(d.path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveBrowserBaseUrl(params: {
|
||||
target?: "sandbox" | "host" | "custom";
|
||||
@@ -289,12 +127,11 @@ export function createBrowserTool(opts?: {
|
||||
"Control the browser via Clawdbot's browser control server (status/start/stop/profiles/tabs/open/snapshot/screenshot/actions).",
|
||||
'Profiles: use profile="chrome" for Chrome extension relay takeover (your existing Chrome tabs). Use profile="clawd" for the isolated clawd-managed browser.',
|
||||
'If the user mentions the Chrome extension / Browser Relay / toolbar button / “attach tab”, ALWAYS use profile="chrome" (do not ask which profile).',
|
||||
'When a node-hosted browser proxy is available, the tool may auto-route to it. Pin a node with node=<id|name> or target="node".',
|
||||
"Chrome extension relay needs an attached tab: user must click the Clawdbot Browser Relay toolbar icon on the tab (badge ON). If no tab is connected, ask them to attach it.",
|
||||
"When using refs from snapshot (e.g. e12), keep the same tab: prefer passing targetId from the snapshot response into subsequent actions (act/click/type/etc).",
|
||||
'For stable, self-resolving refs across calls, use snapshot with refs="aria" (Playwright aria-ref ids). Default refs="role" are role+name-based.',
|
||||
"Use snapshot+act for UI automation. Avoid act:wait by default; use only in exceptional cases when no reliable UI state exists.",
|
||||
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
|
||||
`target selects browser location (sandbox|host|custom). Default: ${targetDefault}.`,
|
||||
"controlUrl implies target=custom (remote control server).",
|
||||
hostHint,
|
||||
allowlistHint,
|
||||
@@ -305,184 +142,49 @@ export function createBrowserTool(opts?: {
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const controlUrl = readStringParam(params, "controlUrl");
|
||||
const profile = readStringParam(params, "profile");
|
||||
const requestedNode = readStringParam(params, "node");
|
||||
let target = readStringParam(params, "target") as
|
||||
| "sandbox"
|
||||
| "host"
|
||||
| "custom"
|
||||
| "node"
|
||||
| undefined;
|
||||
|
||||
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
|
||||
throw new Error('controlUrl is not supported with target="node".');
|
||||
}
|
||||
if (target === "custom" && requestedNode) {
|
||||
throw new Error('node is not supported with target="custom".');
|
||||
}
|
||||
|
||||
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
|
||||
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||
let target = readStringParam(params, "target") as "sandbox" | "host" | "custom" | undefined;
|
||||
if (profile === "chrome" && !target && !controlUrl?.trim()) {
|
||||
// Chrome extension relay takeover is a host Chrome feature; default to host even in sandboxed sessions.
|
||||
target = "host";
|
||||
}
|
||||
|
||||
const nodeTarget = await resolveBrowserNodeTarget({
|
||||
requestedNode: requestedNode ?? undefined,
|
||||
const baseUrl = resolveBrowserBaseUrl({
|
||||
target,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
allowHostControl: opts?.allowHostControl,
|
||||
allowedControlUrls: opts?.allowedControlUrls,
|
||||
allowedControlHosts: opts?.allowedControlHosts,
|
||||
allowedControlPorts: opts?.allowedControlPorts,
|
||||
});
|
||||
|
||||
const resolvedTarget = target === "node" ? undefined : target;
|
||||
const baseUrl = nodeTarget
|
||||
? ""
|
||||
: resolveBrowserBaseUrl({
|
||||
target: resolvedTarget,
|
||||
controlUrl,
|
||||
defaultControlUrl: opts?.defaultControlUrl,
|
||||
allowHostControl: opts?.allowHostControl,
|
||||
allowedControlUrls: opts?.allowedControlUrls,
|
||||
allowedControlHosts: opts?.allowedControlHosts,
|
||||
allowedControlPorts: opts?.allowedControlPorts,
|
||||
});
|
||||
|
||||
const proxyRequest = nodeTarget
|
||||
? async (opts: {
|
||||
method: string;
|
||||
path: string;
|
||||
query?: Record<string, string | number | boolean | undefined>;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
profile?: string;
|
||||
}) => {
|
||||
const proxy = await callBrowserProxy({
|
||||
nodeId: nodeTarget.nodeId,
|
||||
method: opts.method,
|
||||
path: opts.path,
|
||||
query: opts.query,
|
||||
body: opts.body,
|
||||
timeoutMs: opts.timeoutMs,
|
||||
profile: opts.profile,
|
||||
});
|
||||
const mapping = await persistProxyFiles(proxy.files);
|
||||
applyProxyPaths(proxy.result, mapping);
|
||||
return proxy.result;
|
||||
}
|
||||
: null;
|
||||
|
||||
switch (action) {
|
||||
case "status":
|
||||
if (proxyRequest) {
|
||||
return jsonResult(
|
||||
await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/",
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||
case "start":
|
||||
if (proxyRequest) {
|
||||
await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/start",
|
||||
profile,
|
||||
});
|
||||
return jsonResult(
|
||||
await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/",
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await browserStart(baseUrl, { profile });
|
||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||
case "stop":
|
||||
if (proxyRequest) {
|
||||
await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/stop",
|
||||
profile,
|
||||
});
|
||||
return jsonResult(
|
||||
await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/",
|
||||
profile,
|
||||
}),
|
||||
);
|
||||
}
|
||||
await browserStop(baseUrl, { profile });
|
||||
return jsonResult(await browserStatus(baseUrl, { profile }));
|
||||
case "profiles":
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/profiles",
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult({ profiles: await browserProfiles(baseUrl) });
|
||||
case "tabs":
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/tabs",
|
||||
profile,
|
||||
});
|
||||
const tabs = (result as { tabs?: unknown[] }).tabs ?? [];
|
||||
return jsonResult({ tabs });
|
||||
}
|
||||
return jsonResult({ tabs: await browserTabs(baseUrl, { profile }) });
|
||||
case "open": {
|
||||
const targetUrl = readStringParam(params, "targetUrl", {
|
||||
required: true,
|
||||
});
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/tabs/open",
|
||||
profile,
|
||||
body: { url: targetUrl },
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult(await browserOpenTab(baseUrl, targetUrl, { profile }));
|
||||
}
|
||||
case "focus": {
|
||||
const targetId = readStringParam(params, "targetId", {
|
||||
required: true,
|
||||
});
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/tabs/focus",
|
||||
profile,
|
||||
body: { targetId },
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
await browserFocusTab(baseUrl, targetId, { profile });
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "close": {
|
||||
const targetId = readStringParam(params, "targetId");
|
||||
if (proxyRequest) {
|
||||
const result = targetId
|
||||
? await proxyRequest({
|
||||
method: "DELETE",
|
||||
path: `/tabs/${encodeURIComponent(targetId)}`,
|
||||
profile,
|
||||
})
|
||||
: await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
profile,
|
||||
body: { kind: "close" },
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
if (targetId) await browserCloseTab(baseUrl, targetId, { profile });
|
||||
else await browserAct(baseUrl, { kind: "close" }, { profile });
|
||||
return jsonResult({ ok: true });
|
||||
@@ -530,41 +232,21 @@ export function createBrowserTool(opts?: {
|
||||
: undefined;
|
||||
const selector = typeof params.selector === "string" ? params.selector.trim() : undefined;
|
||||
const frame = typeof params.frame === "string" ? params.frame.trim() : undefined;
|
||||
const snapshot = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/snapshot",
|
||||
profile,
|
||||
query: {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
},
|
||||
})) as Awaited<ReturnType<typeof browserSnapshot>>)
|
||||
: await browserSnapshot(baseUrl, {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
profile,
|
||||
});
|
||||
const snapshot = await browserSnapshot(baseUrl, {
|
||||
format,
|
||||
targetId,
|
||||
limit,
|
||||
...(typeof resolvedMaxChars === "number" ? { maxChars: resolvedMaxChars } : {}),
|
||||
refs,
|
||||
interactive,
|
||||
compact,
|
||||
depth,
|
||||
selector,
|
||||
frame,
|
||||
labels,
|
||||
mode,
|
||||
profile,
|
||||
});
|
||||
if (snapshot.format === "ai") {
|
||||
if (labels && snapshot.imagePath) {
|
||||
return await imageResultFromFile({
|
||||
@@ -587,27 +269,14 @@ export function createBrowserTool(opts?: {
|
||||
const ref = readStringParam(params, "ref");
|
||||
const element = readStringParam(params, "element");
|
||||
const type = params.type === "jpeg" ? "jpeg" : "png";
|
||||
const result = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/screenshot",
|
||||
profile,
|
||||
body: {
|
||||
targetId,
|
||||
fullPage,
|
||||
ref,
|
||||
element,
|
||||
type,
|
||||
},
|
||||
})) as Awaited<ReturnType<typeof browserScreenshotAction>>)
|
||||
: await browserScreenshotAction(baseUrl, {
|
||||
targetId,
|
||||
fullPage,
|
||||
ref,
|
||||
element,
|
||||
type,
|
||||
profile,
|
||||
});
|
||||
const result = await browserScreenshotAction(baseUrl, {
|
||||
targetId,
|
||||
fullPage,
|
||||
ref,
|
||||
element,
|
||||
type,
|
||||
profile,
|
||||
});
|
||||
return await imageResultFromFile({
|
||||
label: "browser:screenshot",
|
||||
path: result.path,
|
||||
@@ -619,18 +288,6 @@ export function createBrowserTool(opts?: {
|
||||
required: true,
|
||||
});
|
||||
const targetId = readStringParam(params, "targetId");
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/navigate",
|
||||
profile,
|
||||
body: {
|
||||
url: targetUrl,
|
||||
targetId,
|
||||
},
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult(
|
||||
await browserNavigate(baseUrl, {
|
||||
url: targetUrl,
|
||||
@@ -642,30 +299,11 @@ export function createBrowserTool(opts?: {
|
||||
case "console": {
|
||||
const level = typeof params.level === "string" ? params.level.trim() : undefined;
|
||||
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/console",
|
||||
profile,
|
||||
query: {
|
||||
level,
|
||||
targetId,
|
||||
},
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult(await browserConsoleMessages(baseUrl, { level, targetId, profile }));
|
||||
}
|
||||
case "pdf": {
|
||||
const targetId = typeof params.targetId === "string" ? params.targetId.trim() : undefined;
|
||||
const result = proxyRequest
|
||||
? ((await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/pdf",
|
||||
profile,
|
||||
body: { targetId },
|
||||
})) as Awaited<ReturnType<typeof browserPdfSave>>)
|
||||
: await browserPdfSave(baseUrl, { targetId, profile });
|
||||
const result = await browserPdfSave(baseUrl, { targetId, profile });
|
||||
return {
|
||||
content: [{ type: "text", text: `FILE:${result.path}` }],
|
||||
details: result,
|
||||
@@ -682,22 +320,6 @@ export function createBrowserTool(opts?: {
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? params.timeoutMs
|
||||
: undefined;
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/file-chooser",
|
||||
profile,
|
||||
body: {
|
||||
paths,
|
||||
ref,
|
||||
inputRef,
|
||||
element,
|
||||
targetId,
|
||||
timeoutMs,
|
||||
},
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult(
|
||||
await browserArmFileChooser(baseUrl, {
|
||||
paths,
|
||||
@@ -718,20 +340,6 @@ export function createBrowserTool(opts?: {
|
||||
typeof params.timeoutMs === "number" && Number.isFinite(params.timeoutMs)
|
||||
? params.timeoutMs
|
||||
: undefined;
|
||||
if (proxyRequest) {
|
||||
const result = await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/hooks/dialog",
|
||||
profile,
|
||||
body: {
|
||||
accept,
|
||||
promptText,
|
||||
targetId,
|
||||
timeoutMs,
|
||||
},
|
||||
});
|
||||
return jsonResult(result);
|
||||
}
|
||||
return jsonResult(
|
||||
await browserArmDialog(baseUrl, {
|
||||
accept,
|
||||
@@ -748,29 +356,14 @@ export function createBrowserTool(opts?: {
|
||||
throw new Error("request required");
|
||||
}
|
||||
try {
|
||||
const result = proxyRequest
|
||||
? await proxyRequest({
|
||||
method: "POST",
|
||||
path: "/act",
|
||||
profile,
|
||||
body: request,
|
||||
})
|
||||
: await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
|
||||
profile,
|
||||
});
|
||||
const result = await browserAct(baseUrl, request as Parameters<typeof browserAct>[1], {
|
||||
profile,
|
||||
});
|
||||
return jsonResult(result);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("404:") && msg.includes("tab not found") && profile === "chrome") {
|
||||
const tabs = proxyRequest
|
||||
? ((
|
||||
(await proxyRequest({
|
||||
method: "GET",
|
||||
path: "/tabs",
|
||||
profile,
|
||||
})) as { tabs?: unknown[] }
|
||||
).tabs ?? [])
|
||||
: await browserTabs(baseUrl, { profile }).catch(() => []);
|
||||
const tabs = await browserTabs(baseUrl, { profile }).catch(() => []);
|
||||
if (!tabs.length) {
|
||||
throw new Error(
|
||||
"No Chrome tabs are attached via the Clawdbot Browser Relay extension. Click the toolbar icon on the tab you want to control (badge ON), then retry.",
|
||||
|
||||
@@ -63,9 +63,6 @@ export function createSessionsSpawnTool(opts?: {
|
||||
agentAccountId?: string;
|
||||
agentTo?: string;
|
||||
agentThreadId?: string | number;
|
||||
agentGroupId?: string | null;
|
||||
agentGroupChannel?: string | null;
|
||||
agentGroupSpace?: string | null;
|
||||
sandboxed?: boolean;
|
||||
}): AnyAgentTool {
|
||||
return {
|
||||
@@ -156,7 +153,7 @@ export function createSessionsSpawnTool(opts?: {
|
||||
}
|
||||
}
|
||||
const childSessionKey = `agent:${targetAgentId}:subagent:${crypto.randomUUID()}`;
|
||||
const spawnedByKey = requesterInternalKey;
|
||||
const shouldPatchSpawnedBy = opts?.sandboxed === true;
|
||||
const targetAgentConfig = resolveAgentConfig(cfg, targetAgentId);
|
||||
const resolvedModel =
|
||||
normalizeModelSelection(modelOverride) ??
|
||||
@@ -222,10 +219,7 @@ export function createSessionsSpawnTool(opts?: {
|
||||
thinking: thinkingOverride,
|
||||
timeout: runTimeoutSeconds > 0 ? runTimeoutSeconds : undefined,
|
||||
label: label || undefined,
|
||||
spawnedBy: spawnedByKey,
|
||||
groupId: opts?.agentGroupId ?? undefined,
|
||||
groupChannel: opts?.agentGroupChannel ?? undefined,
|
||||
groupSpace: opts?.agentGroupSpace ?? undefined,
|
||||
spawnedBy: shouldPatchSpawnedBy ? requesterInternalKey : undefined,
|
||||
},
|
||||
timeoutMs: 10_000,
|
||||
})) as { runId?: string };
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
listChatCommandsForConfig,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
normalizeNativeCommandSpecsForSurface,
|
||||
normalizeCommandBody,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
@@ -16,18 +15,15 @@ import {
|
||||
shouldHandleTextCommands,
|
||||
} from "./commands-registry.js";
|
||||
import type { ChatCommandDefinition } from "./commands-registry.types.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry([]));
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
describe("commands registry", () => {
|
||||
@@ -46,20 +42,6 @@ describe("commands registry", () => {
|
||||
expect(specs.find((spec) => spec.name === "compact")).toBeFalsy();
|
||||
});
|
||||
|
||||
it("normalizes telegram native command specs", () => {
|
||||
const specs = [
|
||||
{ name: "OK", description: "Ok", acceptsArgs: false },
|
||||
{ name: "bad-name", description: "Bad", acceptsArgs: false },
|
||||
{ name: "fine_name", description: "Fine", acceptsArgs: false },
|
||||
{ name: "ok", description: "Dup", acceptsArgs: false },
|
||||
];
|
||||
const normalized = normalizeNativeCommandSpecsForSurface({
|
||||
surface: "telegram",
|
||||
specs,
|
||||
});
|
||||
expect(normalized.map((spec) => spec.name)).toEqual(["ok", "fine_name"]);
|
||||
});
|
||||
|
||||
it("filters commands based on config flags", () => {
|
||||
const disabled = listChatCommandsForConfig({
|
||||
commands: { config: false, debug: false },
|
||||
@@ -103,19 +85,6 @@ describe("commands registry", () => {
|
||||
expect(native.find((spec) => spec.name === "demo_skill")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("includes plugin commands in native specs", () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "plugstatus",
|
||||
description: "Plugin status",
|
||||
handler: () => ({ text: "ok" }),
|
||||
});
|
||||
const native = listNativeCommandSpecsForConfig(
|
||||
{ commands: { config: false, debug: false, native: true } },
|
||||
{ skillCommands: [] },
|
||||
);
|
||||
expect(native.find((spec) => spec.name === "plugstatus")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("detects known text commands", () => {
|
||||
const detection = getCommandDetection();
|
||||
expect(detection.exact.has("/commands")).toBe(true);
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import type { ClawdbotConfig } from "../config/types.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import { getChatCommands, getNativeCommandSurfaces } from "./commands-registry.data.js";
|
||||
import { getPluginCommandSpecs } from "../plugins/commands.js";
|
||||
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../agents/defaults.js";
|
||||
import { resolveConfiguredModelRef } from "../agents/model-selection.js";
|
||||
import {
|
||||
normalizeTelegramCommandName,
|
||||
TELEGRAM_COMMAND_NAME_PATTERN,
|
||||
} from "../config/telegram-custom-commands.js";
|
||||
import type {
|
||||
ChatCommandDefinition,
|
||||
CommandArgChoiceContext,
|
||||
@@ -113,7 +108,7 @@ export function listChatCommandsForConfig(
|
||||
export function listNativeCommandSpecs(params?: {
|
||||
skillCommands?: SkillCommandSpec[];
|
||||
}): NativeCommandSpec[] {
|
||||
const base = listChatCommands({ skillCommands: params?.skillCommands })
|
||||
return listChatCommands({ skillCommands: params?.skillCommands })
|
||||
.filter((command) => command.scope !== "text" && command.nativeName)
|
||||
.map((command) => ({
|
||||
name: command.nativeName ?? command.key,
|
||||
@@ -121,18 +116,13 @@ export function listNativeCommandSpecs(params?: {
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}));
|
||||
const pluginSpecs = getPluginCommandSpecs();
|
||||
if (pluginSpecs.length === 0) return base;
|
||||
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
|
||||
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
|
||||
return extras.length > 0 ? [...base, ...extras] : base;
|
||||
}
|
||||
|
||||
export function listNativeCommandSpecsForConfig(
|
||||
cfg: ClawdbotConfig,
|
||||
params?: { skillCommands?: SkillCommandSpec[] },
|
||||
): NativeCommandSpec[] {
|
||||
const base = listChatCommandsForConfig(cfg, params)
|
||||
return listChatCommandsForConfig(cfg, params)
|
||||
.filter((command) => command.scope !== "text" && command.nativeName)
|
||||
.map((command) => ({
|
||||
name: command.nativeName ?? command.key,
|
||||
@@ -140,42 +130,6 @@ export function listNativeCommandSpecsForConfig(
|
||||
acceptsArgs: Boolean(command.acceptsArgs),
|
||||
args: command.args,
|
||||
}));
|
||||
const pluginSpecs = getPluginCommandSpecs();
|
||||
if (pluginSpecs.length === 0) return base;
|
||||
const seen = new Set(base.map((spec) => spec.name.toLowerCase()));
|
||||
const extras = pluginSpecs.filter((spec) => !seen.has(spec.name.toLowerCase()));
|
||||
return extras.length > 0 ? [...base, ...extras] : base;
|
||||
}
|
||||
|
||||
function normalizeNativeCommandNameForSurface(name: string, surface: string): string | null {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return null;
|
||||
if (surface === "telegram") {
|
||||
const normalized = normalizeTelegramCommandName(trimmed);
|
||||
if (!normalized) return null;
|
||||
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) return null;
|
||||
return normalized;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export function normalizeNativeCommandSpecsForSurface(params: {
|
||||
surface: string;
|
||||
specs: NativeCommandSpec[];
|
||||
}): NativeCommandSpec[] {
|
||||
const surface = params.surface.toLowerCase();
|
||||
if (!surface) return params.specs;
|
||||
const normalized: NativeCommandSpec[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const spec of params.specs) {
|
||||
const normalizedName = normalizeNativeCommandNameForSurface(spec.name, surface);
|
||||
if (!normalizedName) continue;
|
||||
const key = normalizedName.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
normalized.push(normalizedName === spec.name ? spec : { ...spec, name: normalizedName });
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function findCommandByNativeName(name: string): ChatCommandDefinition | undefined {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
DEFAULT_HEARTBEAT_ACK_MAX_CHARS,
|
||||
isHeartbeatContentEffectivelyEmpty,
|
||||
stripHeartbeatToken,
|
||||
} from "./heartbeat.js";
|
||||
import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "./heartbeat.js";
|
||||
import { HEARTBEAT_TOKEN } from "./tokens.js";
|
||||
|
||||
describe("stripHeartbeatToken", () => {
|
||||
@@ -109,76 +105,3 @@ describe("stripHeartbeatToken", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHeartbeatContentEffectivelyEmpty", () => {
|
||||
it("returns false for undefined/null (missing file should not skip)", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(undefined)).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(null)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for empty string", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for whitespace only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\n\n\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty(" \n \n ")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("\t\t")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for header-only content", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for comments only", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# Header\n# Another comment")).toBe(true);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("## Subheader\n### Another")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for default template content (header + comment)", () => {
|
||||
const defaultTemplate = `# HEARTBEAT.md
|
||||
|
||||
Keep this file empty unless you want a tiny checklist. Keep it small.
|
||||
`;
|
||||
// Note: The template has actual text content, so it's NOT effectively empty
|
||||
expect(isHeartbeatContentEffectivelyEmpty(defaultTemplate)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns true for header with only empty lines", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n\n\n")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when actionable content exists", () => {
|
||||
expect(isHeartbeatContentEffectivelyEmpty("- Check email")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("# HEARTBEAT.md\n- Task 1")).toBe(false);
|
||||
expect(isHeartbeatContentEffectivelyEmpty("Remind me to call mom")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for content with tasks after header", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
|
||||
- Task 1
|
||||
- Task 2
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for mixed content with non-comment text", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Tasks
|
||||
Check the server logs
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(false);
|
||||
});
|
||||
|
||||
it("treats markdown headers as comments (effectively empty)", () => {
|
||||
const content = `# HEARTBEAT.md
|
||||
## Section 1
|
||||
### Subsection
|
||||
`;
|
||||
expect(isHeartbeatContentEffectivelyEmpty(content)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,38 +7,6 @@ export const HEARTBEAT_PROMPT =
|
||||
export const DEFAULT_HEARTBEAT_EVERY = "30m";
|
||||
export const DEFAULT_HEARTBEAT_ACK_MAX_CHARS = 300;
|
||||
|
||||
/**
|
||||
* Check if HEARTBEAT.md content is "effectively empty" - meaning it has no actionable tasks.
|
||||
* This allows skipping heartbeat API calls when no tasks are configured.
|
||||
*
|
||||
* A file is considered effectively empty if it contains only:
|
||||
* - Whitespace
|
||||
* - Comment lines (lines starting with #)
|
||||
* - Empty lines
|
||||
*
|
||||
* Note: A missing file returns false (not effectively empty) so the LLM can still
|
||||
* decide what to do. This function is only for when the file exists but has no content.
|
||||
*/
|
||||
export function isHeartbeatContentEffectivelyEmpty(content: string | undefined | null): boolean {
|
||||
if (content === undefined || content === null) return false;
|
||||
if (typeof content !== "string") return false;
|
||||
|
||||
const lines = content.split("\n");
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
// Skip empty lines
|
||||
if (!trimmed) continue;
|
||||
// Skip markdown header lines (# followed by space or EOL, ## etc)
|
||||
// This intentionally does NOT skip lines like "#TODO" or "#hashtag" which might be content
|
||||
// (Those aren't valid markdown headers - ATX headers require space after #)
|
||||
if (/^#+(\s|$)/.test(trimmed)) continue;
|
||||
// Found a non-empty, non-comment line - there's actionable content
|
||||
return false;
|
||||
}
|
||||
// All lines were either empty or comments
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveHeartbeatPrompt(raw?: string): string {
|
||||
const trimmed = typeof raw === "string" ? raw.trim() : "";
|
||||
return trimmed || HEARTBEAT_PROMPT;
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
} from "../../agents/pi-embedded-helpers.js";
|
||||
import {
|
||||
resolveAgentIdFromSessionKey,
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionTranscriptPath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
@@ -215,10 +214,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
agentAccountId: params.sessionCtx.AccountId,
|
||||
messageTo: params.sessionCtx.OriginatingTo ?? params.sessionCtx.To,
|
||||
messageThreadId: params.sessionCtx.MessageThreadId ?? undefined,
|
||||
groupId: resolveGroupSessionKey(params.sessionCtx)?.id,
|
||||
groupChannel:
|
||||
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
|
||||
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||
// Provider threading context for tool auto-injection
|
||||
...buildThreadingToolContext({
|
||||
sessionCtx: params.sessionCtx,
|
||||
|
||||
@@ -67,10 +67,6 @@ export const handleCompactCommand: CommandHandler = async (params) => {
|
||||
sessionId,
|
||||
sessionKey: params.sessionKey,
|
||||
messageChannel: params.command.channel,
|
||||
groupId: params.sessionEntry.groupId,
|
||||
groupChannel: params.sessionEntry.groupChannel,
|
||||
groupSpace: params.sessionEntry.space,
|
||||
spawnedBy: params.sessionEntry.spawnedBy,
|
||||
sessionFile: resolveSessionFilePath(sessionId, params.sessionEntry),
|
||||
workspaceDir: params.workspaceDir,
|
||||
config: params.cfg,
|
||||
|
||||
@@ -81,10 +81,6 @@ async function resolveContextReport(
|
||||
workspaceDir,
|
||||
sessionKey: params.sessionKey,
|
||||
messageProvider: params.command.channel,
|
||||
groupId: params.sessionEntry?.groupId ?? undefined,
|
||||
groupChannel: params.sessionEntry?.groupChannel ?? undefined,
|
||||
groupSpace: params.sessionEntry?.space ?? undefined,
|
||||
spawnedBy: params.sessionEntry?.spawnedBy ?? undefined,
|
||||
modelProvider: params.provider,
|
||||
modelId: params.model,
|
||||
});
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
handleStopCommand,
|
||||
handleUsageCommand,
|
||||
} from "./commands-session.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
import type {
|
||||
CommandHandler,
|
||||
CommandHandlerResult,
|
||||
@@ -32,8 +31,6 @@ import type {
|
||||
} from "./commands-types.js";
|
||||
|
||||
const HANDLERS: CommandHandler[] = [
|
||||
// Plugin commands are processed first, before built-in commands
|
||||
handlePluginCommand,
|
||||
handleBashCommand,
|
||||
handleActivationCommand,
|
||||
handleSendPolicyCommand,
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../../plugins/commands.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
import { handlePluginCommand } from "./commands-plugin.js";
|
||||
|
||||
describe("handlePluginCommand", () => {
|
||||
beforeEach(() => {
|
||||
clearPluginCommands();
|
||||
});
|
||||
|
||||
it("skips plugin commands when text commands are disabled", async () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "ping",
|
||||
description: "Ping",
|
||||
handler: () => ({ text: "pong" }),
|
||||
});
|
||||
|
||||
const params = {
|
||||
command: {
|
||||
commandBodyNormalized: "/ping",
|
||||
senderId: "user-1",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
},
|
||||
cfg: {} as ClawdbotConfig,
|
||||
} as HandleCommandsParams;
|
||||
|
||||
const result = await handlePluginCommand(params, false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("executes plugin commands when text commands are enabled", async () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "ping",
|
||||
description: "Ping",
|
||||
handler: () => ({ text: "pong" }),
|
||||
});
|
||||
|
||||
const params = {
|
||||
command: {
|
||||
commandBodyNormalized: "/ping",
|
||||
senderId: "user-1",
|
||||
channel: "test",
|
||||
isAuthorizedSender: true,
|
||||
},
|
||||
cfg: {} as ClawdbotConfig,
|
||||
} as HandleCommandsParams;
|
||||
|
||||
const result = await handlePluginCommand(params, true);
|
||||
expect(result?.reply?.text).toBe("pong");
|
||||
});
|
||||
});
|
||||
@@ -1,42 +0,0 @@
|
||||
/**
|
||||
* Plugin Command Handler
|
||||
*
|
||||
* Handles commands registered by plugins, bypassing the LLM agent.
|
||||
* This handler is called before built-in command handlers.
|
||||
*/
|
||||
|
||||
import { matchPluginCommand, executePluginCommand } from "../../plugins/commands.js";
|
||||
import type { CommandHandler, CommandHandlerResult } from "./commands-types.js";
|
||||
|
||||
/**
|
||||
* Handle plugin-registered commands.
|
||||
* Returns a result if a plugin command was matched and executed,
|
||||
* or null to continue to the next handler.
|
||||
*/
|
||||
export const handlePluginCommand: CommandHandler = async (
|
||||
params,
|
||||
allowTextCommands,
|
||||
): Promise<CommandHandlerResult | null> => {
|
||||
if (!allowTextCommands) return null;
|
||||
const { command, cfg } = params;
|
||||
|
||||
// Try to match a plugin command
|
||||
const match = matchPluginCommand(command.commandBodyNormalized);
|
||||
if (!match) return null;
|
||||
|
||||
// Execute the plugin command (always returns a result)
|
||||
const result = await executePluginCommand({
|
||||
command: match.command,
|
||||
args: match.args,
|
||||
senderId: command.senderId,
|
||||
channel: command.channel,
|
||||
isAuthorizedSender: command.isAuthorizedSender,
|
||||
commandBody: command.commandBodyNormalized,
|
||||
config: cfg,
|
||||
});
|
||||
|
||||
return {
|
||||
shouldContinue: false,
|
||||
reply: { text: result.text },
|
||||
};
|
||||
};
|
||||
@@ -147,9 +147,6 @@ export function createFollowupRunner(params: {
|
||||
agentAccountId: queued.run.agentAccountId,
|
||||
messageTo: queued.originatingTo,
|
||||
messageThreadId: queued.originatingThreadId,
|
||||
groupId: queued.run.groupId,
|
||||
groupChannel: queued.run.groupChannel,
|
||||
groupSpace: queued.run.groupSpace,
|
||||
sessionFile: queued.run.sessionFile,
|
||||
workspaceDir: queued.run.workspaceDir,
|
||||
config: queued.run.config,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { resolveSessionAuthProfileOverride } from "../../agents/auth-profiles/se
|
||||
import type { ExecToolDefaults } from "../../agents/bash-tools.js";
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveGroupSessionKey,
|
||||
resolveSessionFilePath,
|
||||
type SessionEntry,
|
||||
updateSessionStore,
|
||||
@@ -367,9 +366,6 @@ export async function runPreparedReply(
|
||||
sessionKey,
|
||||
messageProvider: sessionCtx.Provider?.trim().toLowerCase() || undefined,
|
||||
agentAccountId: sessionCtx.AccountId,
|
||||
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
|
||||
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
|
||||
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
|
||||
@@ -48,9 +48,6 @@ export type FollowupRun = {
|
||||
sessionKey?: string;
|
||||
messageProvider?: string;
|
||||
agentAccountId?: string;
|
||||
groupId?: string;
|
||||
groupChannel?: string;
|
||||
groupSpace?: string;
|
||||
sessionFile: string;
|
||||
workspaceDir: string;
|
||||
config: ClawdbotConfig;
|
||||
|
||||
@@ -4,11 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { normalizeTestText } from "../../test/helpers/normalize-text.js";
|
||||
import { withTempHome } from "../../test/helpers/temp-home.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { clearPluginCommands, registerPluginCommand } from "../plugins/commands.js";
|
||||
import { buildCommandsMessage, buildHelpMessage, buildStatusMessage } from "./status.js";
|
||||
|
||||
afterEach(() => {
|
||||
clearPluginCommands();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -425,19 +423,6 @@ describe("buildCommandsMessage", () => {
|
||||
);
|
||||
expect(text).toContain("/demo_skill - Demo skill");
|
||||
});
|
||||
|
||||
it("includes plugin commands when registered", () => {
|
||||
registerPluginCommand("plugin-core", {
|
||||
name: "plugstatus",
|
||||
description: "Plugin status",
|
||||
handler: () => ({ text: "ok" }),
|
||||
});
|
||||
const text = buildCommandsMessage({
|
||||
commands: { config: false, debug: false },
|
||||
} as ClawdbotConfig);
|
||||
expect(text).toContain("🔌 Plugin commands");
|
||||
expect(text).toContain("/plugstatus - Plugin status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHelpMessage", () => {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
} from "../utils/usage-format.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import { listChatCommands, listChatCommandsForConfig } from "./commands-registry.js";
|
||||
import { listPluginCommands } from "../plugins/commands.js";
|
||||
import type { SkillCommandSpec } from "../agents/skills.js";
|
||||
import type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel } from "./thinking.js";
|
||||
import type { MediaUnderstandingDecision } from "../media-understanding/types.js";
|
||||
@@ -443,12 +442,5 @@ export function buildCommandsMessage(
|
||||
const scopeLabel = command.scope === "text" ? " (text-only)" : "";
|
||||
lines.push(`${primary}${aliasLabel}${scopeLabel} - ${command.description}`);
|
||||
}
|
||||
const pluginCommands = listPluginCommands();
|
||||
if (pluginCommands.length > 0) {
|
||||
lines.push("🔌 Plugin commands");
|
||||
for (const command of pluginCommands) {
|
||||
lines.push(`/${command.name} - ${command.description}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -247,71 +247,4 @@ describe("chrome extension relay server", () => {
|
||||
cdp.close();
|
||||
ext.close();
|
||||
}, 15_000);
|
||||
|
||||
it("rebroadcasts attach when a session id is reused for a new target", async () => {
|
||||
const port = await getFreePort();
|
||||
cdpUrl = `http://127.0.0.1:${port}`;
|
||||
await ensureChromeExtensionRelayServer({ cdpUrl });
|
||||
|
||||
const ext = new WebSocket(`ws://127.0.0.1:${port}/extension`);
|
||||
await waitForOpen(ext);
|
||||
|
||||
const cdp = new WebSocket(`ws://127.0.0.1:${port}/cdp`);
|
||||
await waitForOpen(cdp);
|
||||
const q = createMessageQueue(cdp);
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: "shared-session",
|
||||
targetInfo: {
|
||||
targetId: "t1",
|
||||
type: "page",
|
||||
title: "First",
|
||||
url: "https://example.com",
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const first = JSON.parse(await q.next()) as { method?: string; params?: unknown };
|
||||
expect(first.method).toBe("Target.attachedToTarget");
|
||||
expect(JSON.stringify(first.params ?? {})).toContain("t1");
|
||||
|
||||
ext.send(
|
||||
JSON.stringify({
|
||||
method: "forwardCDPEvent",
|
||||
params: {
|
||||
method: "Target.attachedToTarget",
|
||||
params: {
|
||||
sessionId: "shared-session",
|
||||
targetInfo: {
|
||||
targetId: "t2",
|
||||
type: "page",
|
||||
title: "Second",
|
||||
url: "https://example.org",
|
||||
},
|
||||
waitingForDebugger: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const received: Array<{ method?: string; params?: unknown }> = [];
|
||||
received.push(JSON.parse(await q.next()) as never);
|
||||
received.push(JSON.parse(await q.next()) as never);
|
||||
|
||||
const detached = received.find((m) => m.method === "Target.detachedFromTarget");
|
||||
const attached = received.find((m) => m.method === "Target.attachedToTarget");
|
||||
expect(JSON.stringify(detached?.params ?? {})).toContain("t1");
|
||||
expect(JSON.stringify(attached?.params ?? {})).toContain("t2");
|
||||
|
||||
cdp.close();
|
||||
ext.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -477,23 +477,13 @@ export async function ensureChromeExtensionRelayServer(opts: {
|
||||
const targetType = attached?.targetInfo?.type ?? "page";
|
||||
if (targetType !== "page") return;
|
||||
if (attached?.sessionId && attached?.targetInfo?.targetId) {
|
||||
const prev = connectedTargets.get(attached.sessionId);
|
||||
const nextTargetId = attached.targetInfo.targetId;
|
||||
const prevTargetId = prev?.targetId;
|
||||
const changedTarget = Boolean(prev && prevTargetId && prevTargetId !== nextTargetId);
|
||||
const already = connectedTargets.has(attached.sessionId);
|
||||
connectedTargets.set(attached.sessionId, {
|
||||
sessionId: attached.sessionId,
|
||||
targetId: nextTargetId,
|
||||
targetId: attached.targetInfo.targetId,
|
||||
targetInfo: attached.targetInfo,
|
||||
});
|
||||
if (changedTarget && prevTargetId) {
|
||||
broadcastToCdpClients({
|
||||
method: "Target.detachedFromTarget",
|
||||
params: { sessionId: attached.sessionId, targetId: prevTargetId },
|
||||
sessionId: attached.sessionId,
|
||||
});
|
||||
}
|
||||
if (!prev || changedTarget) {
|
||||
if (!already) {
|
||||
broadcastToCdpClients({ method, params, sessionId });
|
||||
}
|
||||
return;
|
||||
|
||||
@@ -11,15 +11,10 @@ import { normalizeWhatsAppTarget } from "../whatsapp/normalize.js";
|
||||
import { requireActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import {
|
||||
resolveDiscordGroupRequireMention,
|
||||
resolveDiscordGroupToolPolicy,
|
||||
resolveIMessageGroupRequireMention,
|
||||
resolveIMessageGroupToolPolicy,
|
||||
resolveSlackGroupRequireMention,
|
||||
resolveSlackGroupToolPolicy,
|
||||
resolveTelegramGroupRequireMention,
|
||||
resolveTelegramGroupToolPolicy,
|
||||
resolveWhatsAppGroupRequireMention,
|
||||
resolveWhatsAppGroupToolPolicy,
|
||||
} from "./plugins/group-mentions.js";
|
||||
import type {
|
||||
ChannelCapabilities,
|
||||
@@ -108,7 +103,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveTelegramGroupRequireMention,
|
||||
resolveToolPolicy: resolveTelegramGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg }) => cfg.channels?.telegram?.replyToMode ?? "first",
|
||||
@@ -147,7 +141,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveWhatsAppGroupRequireMention,
|
||||
resolveToolPolicy: resolveWhatsAppGroupToolPolicy,
|
||||
resolveGroupIntroHint: () =>
|
||||
"WhatsApp IDs: SenderId is the participant JID; [message_id: ...] is the message id for reactions (use SenderId as participant).",
|
||||
},
|
||||
@@ -196,7 +189,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveDiscordGroupRequireMention,
|
||||
resolveToolPolicy: resolveDiscordGroupToolPolicy,
|
||||
},
|
||||
mentions: {
|
||||
stripPatterns: () => ["<@!?\\d+>"],
|
||||
@@ -230,7 +222,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveSlackGroupRequireMention,
|
||||
resolveToolPolicy: resolveSlackGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
resolveReplyToMode: ({ cfg, accountId, chatType }) =>
|
||||
@@ -293,7 +284,6 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: resolveIMessageGroupRequireMention,
|
||||
resolveToolPolicy: resolveIMessageGroupToolPolicy,
|
||||
},
|
||||
threading: {
|
||||
buildToolContext: ({ context, hasRepliedRef }) => {
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import {
|
||||
resolveChannelGroupRequireMention,
|
||||
resolveChannelGroupToolsPolicy,
|
||||
} from "../../config/group-policy.js";
|
||||
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
|
||||
import type { DiscordConfig } from "../../config/types.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import { resolveSlackAccount } from "../../slack/accounts.js";
|
||||
|
||||
type GroupMentionParams = {
|
||||
@@ -196,103 +192,3 @@ export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveTelegramGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const { chatId } = parseTelegramGroupId(params.groupId);
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "telegram",
|
||||
groupId: chatId ?? params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveWhatsAppGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "whatsapp",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveIMessageGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "imessage",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveDiscordGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const guildEntry = resolveDiscordGuildEntry(
|
||||
params.cfg.channels?.discord?.guilds,
|
||||
params.groupSpace,
|
||||
);
|
||||
const channelEntries = guildEntry?.channels;
|
||||
if (channelEntries && Object.keys(channelEntries).length > 0) {
|
||||
const groupChannel = params.groupChannel;
|
||||
const channelSlug = normalizeDiscordSlug(groupChannel);
|
||||
const entry =
|
||||
(params.groupId ? channelEntries[params.groupId] : undefined) ??
|
||||
(channelSlug
|
||||
? (channelEntries[channelSlug] ?? channelEntries[`#${channelSlug}`])
|
||||
: undefined) ??
|
||||
(groupChannel ? channelEntries[normalizeDiscordSlug(groupChannel)] : undefined);
|
||||
if (entry?.tools) return entry.tools;
|
||||
}
|
||||
if (guildEntry?.tools) return guildEntry.tools;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveSlackGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
const account = resolveSlackAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const channels = account.channels ?? {};
|
||||
const keys = Object.keys(channels);
|
||||
if (keys.length === 0) return undefined;
|
||||
const channelId = params.groupId?.trim();
|
||||
const groupChannel = params.groupChannel;
|
||||
const channelName = groupChannel?.replace(/^#/, "");
|
||||
const normalizedName = normalizeSlackSlug(channelName);
|
||||
const candidates = [
|
||||
channelId ?? "",
|
||||
channelName ? `#${channelName}` : "",
|
||||
channelName ?? "",
|
||||
normalizedName,
|
||||
].filter(Boolean);
|
||||
let matched: { tools?: GroupToolPolicyConfig } | undefined;
|
||||
for (const candidate of candidates) {
|
||||
if (candidate && channels[candidate]) {
|
||||
matched = channels[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
const resolved = matched ?? channels["*"];
|
||||
if (resolved?.tools) return resolved.tools;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function resolveBlueBubblesGroupToolPolicy(
|
||||
params: GroupMentionParams,
|
||||
): GroupToolPolicyConfig | undefined {
|
||||
return resolveChannelGroupToolsPolicy({
|
||||
cfg: params.cfg,
|
||||
channel: "bluebubbles",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import type { ClawdbotConfig } from "../../config/config.js";
|
||||
import type { GroupToolPolicyConfig } from "../../config/types.tools.js";
|
||||
import type { OutboundDeliveryResult, OutboundSendDeps } from "../../infra/outbound/deliver.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type {
|
||||
@@ -66,7 +65,6 @@ export type ChannelConfigAdapter<ResolvedAccount> = {
|
||||
export type ChannelGroupAdapter = {
|
||||
resolveRequireMention?: (params: ChannelGroupContext) => boolean | undefined;
|
||||
resolveGroupIntroHint?: (params: ChannelGroupContext) => string | undefined;
|
||||
resolveToolPolicy?: (params: ChannelGroupContext) => GroupToolPolicyConfig | undefined;
|
||||
};
|
||||
|
||||
export type ChannelOutboundContext = {
|
||||
|
||||
@@ -8,8 +8,11 @@ import {
|
||||
} from "./register.cron-add.js";
|
||||
import { registerCronEditCommand } from "./register.cron-edit.js";
|
||||
import { registerCronSimpleCommands } from "./register.cron-simple.js";
|
||||
import { registerWakeCommand } from "./register.wake.js";
|
||||
|
||||
export function registerCronCli(program: Command) {
|
||||
registerWakeCommand(program);
|
||||
|
||||
const cron = program
|
||||
.command("cron")
|
||||
.description("Manage cron jobs (via Gateway)")
|
||||
|
||||
37
src/cli/cron-cli/register.wake.ts
Normal file
37
src/cli/cron-cli/register.wake.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import type { Command } from "commander";
|
||||
import { danger } from "../../globals.js";
|
||||
import { defaultRuntime } from "../../runtime.js";
|
||||
import { formatDocsLink } from "../../terminal/links.js";
|
||||
import { theme } from "../../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "../gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "../gateway-rpc.js";
|
||||
|
||||
export function registerWakeCommand(program: Command) {
|
||||
addGatewayClientOptions(
|
||||
program
|
||||
.command("wake")
|
||||
.description("Enqueue a system event and optionally trigger an immediate heartbeat")
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option("--mode <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
|
||||
.option("--json", "Output JSON", false),
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
() => `\n${theme.muted("Docs:")} ${formatDocsLink("/cli/wake", "docs.clawd.bot/cli/wake")}\n`,
|
||||
)
|
||||
.action(async (opts: GatewayRpcOpts & { text?: string; mode?: string }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli(
|
||||
"wake",
|
||||
opts,
|
||||
{ mode: opts.mode, text: opts.text },
|
||||
{ expectFinal: false },
|
||||
);
|
||||
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
else defaultRuntime.log("ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -248,7 +248,7 @@ export function registerNodesInvokeCommands(nodes: Command) {
|
||||
const approvals = resolveExecApprovalsFromFile({
|
||||
file: approvalsFile as ExecApprovalsFile,
|
||||
agentId,
|
||||
overrides: { security, ask },
|
||||
overrides: { security: "allowlist" },
|
||||
});
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
|
||||
@@ -60,14 +60,6 @@ const entries: SubCliEntry[] = [
|
||||
mod.registerLogsCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "system",
|
||||
description: "System events, heartbeat, and presence",
|
||||
register: async (program) => {
|
||||
const mod = await import("../system-cli.js");
|
||||
mod.registerSystemCli(program);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "models",
|
||||
description: "Model configuration",
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import type { Command } from "commander";
|
||||
|
||||
import { danger } from "../globals.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { formatDocsLink } from "../terminal/links.js";
|
||||
import { theme } from "../terminal/theme.js";
|
||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
||||
import { addGatewayClientOptions, callGatewayFromCli } from "./gateway-rpc.js";
|
||||
|
||||
type SystemEventOpts = GatewayRpcOpts & { text?: string; mode?: string; json?: boolean };
|
||||
|
||||
const normalizeWakeMode = (raw: unknown) => {
|
||||
const mode = typeof raw === "string" ? raw.trim() : "";
|
||||
if (!mode) return "next-heartbeat" as const;
|
||||
if (mode === "now" || mode === "next-heartbeat") return mode;
|
||||
throw new Error("--mode must be now or next-heartbeat");
|
||||
};
|
||||
|
||||
export function registerSystemCli(program: Command) {
|
||||
const system = program
|
||||
.command("system")
|
||||
.description("System tools (events, heartbeat, presence)")
|
||||
.addHelpText(
|
||||
"after",
|
||||
() =>
|
||||
`\n${theme.muted("Docs:")} ${formatDocsLink("/cli/system", "docs.clawd.bot/cli/system")}\n`,
|
||||
);
|
||||
|
||||
addGatewayClientOptions(
|
||||
system
|
||||
.command("event")
|
||||
.description("Enqueue a system event and optionally trigger a heartbeat")
|
||||
.requiredOption("--text <text>", "System event text")
|
||||
.option("--mode <mode>", "Wake mode (now|next-heartbeat)", "next-heartbeat")
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: SystemEventOpts) => {
|
||||
try {
|
||||
const text = typeof opts.text === "string" ? opts.text.trim() : "";
|
||||
if (!text) throw new Error("--text is required");
|
||||
const mode = normalizeWakeMode(opts.mode);
|
||||
const result = await callGatewayFromCli("wake", opts, { mode, text }, { expectFinal: false });
|
||||
if (opts.json) defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
else defaultRuntime.log("ok");
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
const heartbeat = system.command("heartbeat").description("Heartbeat controls");
|
||||
|
||||
addGatewayClientOptions(
|
||||
heartbeat
|
||||
.command("last")
|
||||
.description("Show the last heartbeat event")
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli("last-heartbeat", opts, undefined, {
|
||||
expectFinal: false,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
heartbeat
|
||||
.command("enable")
|
||||
.description("Enable heartbeats")
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli(
|
||||
"set-heartbeats",
|
||||
opts,
|
||||
{ enabled: true },
|
||||
{ expectFinal: false },
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
heartbeat
|
||||
.command("disable")
|
||||
.description("Disable heartbeats")
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli(
|
||||
"set-heartbeats",
|
||||
opts,
|
||||
{ enabled: false },
|
||||
{ expectFinal: false },
|
||||
);
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
addGatewayClientOptions(
|
||||
system
|
||||
.command("presence")
|
||||
.description("List system presence entries")
|
||||
.option("--json", "Output JSON", false),
|
||||
).action(async (opts: GatewayRpcOpts & { json?: boolean }) => {
|
||||
try {
|
||||
const result = await callGatewayFromCli("system-presence", opts, undefined, {
|
||||
expectFinal: false,
|
||||
});
|
||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||
} catch (err) {
|
||||
defaultRuntime.error(danger(String(err)));
|
||||
defaultRuntime.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -377,7 +377,6 @@ export async function agentCommand(
|
||||
runContext.messageChannel,
|
||||
opts.replyChannel ?? opts.channel,
|
||||
);
|
||||
const spawnedBy = opts.spawnedBy ?? sessionEntry?.spawnedBy;
|
||||
const fallbackResult = await runWithModelFallback({
|
||||
cfg,
|
||||
provider,
|
||||
@@ -413,10 +412,6 @@ export async function agentCommand(
|
||||
agentAccountId: runContext.accountId,
|
||||
messageTo: opts.replyTo ?? opts.to,
|
||||
messageThreadId: opts.threadId,
|
||||
groupId: runContext.groupId,
|
||||
groupChannel: runContext.groupChannel,
|
||||
groupSpace: runContext.groupSpace,
|
||||
spawnedBy,
|
||||
currentChannelId: runContext.currentChannelId,
|
||||
currentThreadTs: runContext.currentThreadTs,
|
||||
replyToMode: runContext.replyToMode,
|
||||
|
||||
@@ -14,15 +14,6 @@ export function resolveAgentRunContext(opts: AgentCommandOpts): AgentRunContext
|
||||
const normalizedAccountId = normalizeAccountId(merged.accountId ?? opts.accountId);
|
||||
if (normalizedAccountId) merged.accountId = normalizedAccountId;
|
||||
|
||||
const groupId = (merged.groupId ?? opts.groupId)?.toString().trim();
|
||||
if (groupId) merged.groupId = groupId;
|
||||
|
||||
const groupChannel = (merged.groupChannel ?? opts.groupChannel)?.toString().trim();
|
||||
if (groupChannel) merged.groupChannel = groupChannel;
|
||||
|
||||
const groupSpace = (merged.groupSpace ?? opts.groupSpace)?.toString().trim();
|
||||
if (groupSpace) merged.groupSpace = groupSpace;
|
||||
|
||||
if (
|
||||
merged.currentThreadTs == null &&
|
||||
opts.threadId != null &&
|
||||
|
||||
@@ -17,9 +17,6 @@ export type AgentStreamParams = {
|
||||
export type AgentRunContext = {
|
||||
messageChannel?: string;
|
||||
accountId?: string;
|
||||
groupId?: string | null;
|
||||
groupChannel?: string | null;
|
||||
groupSpace?: string | null;
|
||||
currentChannelId?: string;
|
||||
currentThreadTs?: string;
|
||||
replyToMode?: "off" | "first" | "all";
|
||||
@@ -58,14 +55,6 @@ export type AgentCommandOpts = {
|
||||
accountId?: string;
|
||||
/** Context for embedded run routing (channel/account/thread). */
|
||||
runContext?: AgentRunContext;
|
||||
/** Group id for channel-level tool policy resolution. */
|
||||
groupId?: string | null;
|
||||
/** Group channel label for channel-level tool policy resolution. */
|
||||
groupChannel?: string | null;
|
||||
/** Group space label for channel-level tool policy resolution. */
|
||||
groupSpace?: string | null;
|
||||
/** Parent session key for subagent policy inheritance. */
|
||||
spawnedBy?: string | null;
|
||||
deliveryTargetMode?: ChannelOutboundTargetMode;
|
||||
bestEffortDeliver?: boolean;
|
||||
abortSignal?: AbortSignal;
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import type { ChannelId } from "../channels/plugins/types.js";
|
||||
import { normalizeAccountId } from "../routing/session-key.js";
|
||||
import type { ClawdbotConfig } from "./config.js";
|
||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type GroupPolicyChannel = ChannelId;
|
||||
|
||||
export type ChannelGroupConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
};
|
||||
|
||||
export type ChannelGroupPolicy = {
|
||||
@@ -93,15 +91,3 @@ export function resolveChannelGroupRequireMention(params: {
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveChannelGroupToolsPolicy(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
channel: GroupPolicyChannel;
|
||||
groupId?: string | null;
|
||||
accountId?: string | null;
|
||||
}): GroupToolPolicyConfig | undefined {
|
||||
const { groupConfig, defaultConfig } = resolveChannelGroupPolicy(params);
|
||||
if (groupConfig?.tools) return groupConfig.tools;
|
||||
if (defaultConfig?.tools) return defaultConfig.tools;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -50,7 +50,6 @@ const GROUP_LABELS: Record<string, string> = {
|
||||
diagnostics: "Diagnostics",
|
||||
logging: "Logging",
|
||||
gateway: "Gateway",
|
||||
nodeHost: "Node Host",
|
||||
agents: "Agents",
|
||||
tools: "Tools",
|
||||
bindings: "Bindings",
|
||||
@@ -77,7 +76,6 @@ const GROUP_ORDER: Record<string, number> = {
|
||||
update: 25,
|
||||
diagnostics: 27,
|
||||
gateway: 30,
|
||||
nodeHost: 35,
|
||||
agents: 40,
|
||||
tools: 50,
|
||||
bindings: 55,
|
||||
@@ -195,12 +193,8 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"gateway.http.endpoints.chatCompletions.enabled": "OpenAI Chat Completions Endpoint",
|
||||
"gateway.reload.mode": "Config Reload Mode",
|
||||
"gateway.reload.debounceMs": "Config Reload Debounce (ms)",
|
||||
"gateway.nodes.browser.mode": "Gateway Node Browser Mode",
|
||||
"gateway.nodes.browser.node": "Gateway Node Browser Pin",
|
||||
"gateway.nodes.allowCommands": "Gateway Node Allowlist (Extra Commands)",
|
||||
"gateway.nodes.denyCommands": "Gateway Node Denylist",
|
||||
"nodeHost.browserProxy.enabled": "Node Browser Proxy Enabled",
|
||||
"nodeHost.browserProxy.allowProfiles": "Node Browser Proxy Allowed Profiles",
|
||||
"skills.load.watch": "Watch Skills",
|
||||
"skills.load.watchDebounceMs": "Skills Watch Debounce (ms)",
|
||||
"agents.defaults.workspace": "Workspace",
|
||||
@@ -372,16 +366,10 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"Enable the OpenAI-compatible `POST /v1/chat/completions` endpoint (default: false).",
|
||||
"gateway.reload.mode": 'Hot reload strategy for config changes ("hybrid" recommended).',
|
||||
"gateway.reload.debounceMs": "Debounce window (ms) before applying config changes.",
|
||||
"gateway.nodes.browser.mode":
|
||||
'Node browser routing ("auto" = pick single connected browser node, "manual" = require node param, "off" = disable).',
|
||||
"gateway.nodes.browser.node": "Pin browser routing to a specific node id or name (optional).",
|
||||
"gateway.nodes.allowCommands":
|
||||
"Extra node.invoke commands to allow beyond the gateway defaults (array of command strings).",
|
||||
"gateway.nodes.denyCommands":
|
||||
"Commands to block even if present in node claims or default allowlist.",
|
||||
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
|
||||
"nodeHost.browserProxy.allowProfiles":
|
||||
"Optional allowlist of browser profile names exposed via the node proxy.",
|
||||
"diagnostics.cacheTrace.enabled":
|
||||
"Log cache trace snapshots for embedded agent runs (default: false).",
|
||||
"diagnostics.cacheTrace.filePath":
|
||||
|
||||
@@ -256,22 +256,6 @@ describe("sessions", () => {
|
||||
expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
|
||||
});
|
||||
|
||||
it("recovers from array-backed session stores", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
await fs.writeFile(storePath, "[]", "utf-8");
|
||||
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store["agent:main:main"] = { sessionId: "sess-1", updatedAt: 1 };
|
||||
});
|
||||
|
||||
const store = loadSessionStore(storePath);
|
||||
expect(store["agent:main:main"]?.sessionId).toBe("sess-1");
|
||||
|
||||
const raw = await fs.readFile(storePath, "utf-8");
|
||||
expect(raw.trim().startsWith("{")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes last route fields on write", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
|
||||
const storePath = path.join(dir, "sessions.json");
|
||||
|
||||
@@ -29,10 +29,6 @@ type SessionStoreCacheEntry = {
|
||||
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
|
||||
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
|
||||
|
||||
function isSessionStoreRecord(value: unknown): value is Record<string, SessionEntry> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function getSessionStoreTtl(): number {
|
||||
return resolveCacheTtlMs({
|
||||
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
|
||||
@@ -119,7 +115,7 @@ export function loadSessionStore(
|
||||
try {
|
||||
const raw = fs.readFileSync(storePath, "utf-8");
|
||||
const parsed = JSON5.parse(raw);
|
||||
if (isSessionStoreRecord(parsed)) {
|
||||
if (parsed && typeof parsed === "object") {
|
||||
store = parsed as Record<string, SessionEntry>;
|
||||
}
|
||||
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;
|
||||
|
||||
@@ -18,7 +18,6 @@ import type {
|
||||
MessagesConfig,
|
||||
} from "./types.messages.js";
|
||||
import type { ModelsConfig } from "./types.models.js";
|
||||
import type { NodeHostConfig } from "./types.node-host.js";
|
||||
import type { PluginsConfig } from "./types.plugins.js";
|
||||
import type { SkillsConfig } from "./types.skills.js";
|
||||
import type { ToolsConfig } from "./types.tools.js";
|
||||
@@ -76,7 +75,6 @@ export type ClawdbotConfig = {
|
||||
skills?: SkillsConfig;
|
||||
plugins?: PluginsConfig;
|
||||
models?: ModelsConfig;
|
||||
nodeHost?: NodeHostConfig;
|
||||
agents?: AgentsConfig;
|
||||
tools?: ToolsConfig;
|
||||
bindings?: AgentBinding[];
|
||||
|
||||
@@ -7,7 +7,6 @@ import type {
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type DiscordDmConfig = {
|
||||
/** If false, ignore all incoming Discord DMs. Default: true. */
|
||||
@@ -25,8 +24,6 @@ export type DiscordDmConfig = {
|
||||
export type DiscordGuildChannelConfig = {
|
||||
allow?: boolean;
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
/** If specified, only load these skills for this channel. Omit = all skills; empty = no skills. */
|
||||
skills?: string[];
|
||||
/** If false, disable the bot for this channel. */
|
||||
@@ -42,8 +39,6 @@ export type DiscordReactionNotificationMode = "off" | "own" | "all" | "allowlist
|
||||
export type DiscordGuildEntry = {
|
||||
slug?: string;
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this guild (used when channel override is missing). */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
/** Reaction notification mode (off|own|all|allowlist). Default: own. */
|
||||
reactionNotifications?: DiscordReactionNotificationMode;
|
||||
users?: Array<string | number>;
|
||||
|
||||
@@ -175,13 +175,6 @@ export type GatewayHttpConfig = {
|
||||
};
|
||||
|
||||
export type GatewayNodesConfig = {
|
||||
/** Browser routing policy for node-hosted browser proxies. */
|
||||
browser?: {
|
||||
/** Routing mode (default: auto). */
|
||||
mode?: "auto" | "manual" | "off";
|
||||
/** Pin to a specific node id/name (optional). */
|
||||
node?: string;
|
||||
};
|
||||
/** Additional node.invoke commands to allow on the gateway. */
|
||||
allowCommands?: string[];
|
||||
/** Commands to deny even if they appear in the defaults or node claims. */
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type IMessageAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
@@ -60,7 +59,6 @@ export type IMessageAccountConfig = {
|
||||
string,
|
||||
{
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
}
|
||||
>;
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import type {
|
||||
MarkdownConfig,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig } from "./types.messages.js";
|
||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type MSTeamsWebhookConfig = {
|
||||
/** Port for the webhook server. Default: 3978. */
|
||||
@@ -21,8 +20,6 @@ export type MSTeamsReplyStyle = "thread" | "top-level";
|
||||
export type MSTeamsChannelConfig = {
|
||||
/** Require @mention to respond. Default: true. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
/** Reply style: "thread" replies to the message, "top-level" posts a new message. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
};
|
||||
@@ -31,8 +28,6 @@ export type MSTeamsChannelConfig = {
|
||||
export type MSTeamsTeamConfig = {
|
||||
/** Default requireMention for channels in this team. */
|
||||
requireMention?: boolean;
|
||||
/** Default tool policy for channels in this team. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
/** Default reply style for channels in this team. */
|
||||
replyStyle?: MSTeamsReplyStyle;
|
||||
/** Per-channel overrides. Key is conversation ID (e.g., "19:...@thread.tacv2"). */
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
export type NodeHostBrowserProxyConfig = {
|
||||
/** Enable the browser proxy on the node host (default: true). */
|
||||
enabled?: boolean;
|
||||
/** Optional allowlist of profile names exposed via the proxy. */
|
||||
allowProfiles?: string[];
|
||||
};
|
||||
|
||||
export type NodeHostConfig = {
|
||||
/** Browser proxy settings for node hosts. */
|
||||
browserProxy?: NodeHostBrowserProxyConfig;
|
||||
};
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
ReplyToMode,
|
||||
} from "./types.base.js";
|
||||
import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js";
|
||||
import type { GroupToolPolicyConfig } from "./types.tools.js";
|
||||
|
||||
export type SlackDmConfig = {
|
||||
/** If false, ignore all incoming Slack DMs. Default: true. */
|
||||
@@ -30,8 +29,6 @@ export type SlackChannelConfig = {
|
||||
allow?: boolean;
|
||||
/** Require mentioning the bot to trigger replies. */
|
||||
requireMention?: boolean;
|
||||
/** Optional tool policy overrides for this channel. */
|
||||
tools?: GroupToolPolicyConfig;
|
||||
/** Allow bot-authored messages to trigger replies (default: false). */
|
||||
allowBots?: boolean;
|
||||
/** Allowlist of users that can invoke the bot in this channel. */
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user