Compare commits

..
Author SHA1 Message Date
Shadow 9aed6ac49a fix: centralize telegram api error logging (#2492) (thanks @altryne) 2026-01-26 20:30:29 -06:00
198 changed files with 22656 additions and 5197 deletions

No files matched your search

-2
View File
@@ -40,8 +40,6 @@ apps/ios/*.xcfilelist
# Vendor build artifacts
vendor/a2ui/renderers/lit/dist/
src/canvas-host/a2ui/*.bundle.js
src/canvas-host/a2ui/*.map
.bundle.hash
# fastlane (iOS)
+2 -12
View File
@@ -6,9 +6,6 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
- Tools: add per-sender group tool policies and fix precedence. (#1757) Thanks @adam91holt.
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk.
@@ -38,10 +35,7 @@ Status: unreleased.
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
- macOS: avoid crash when rendering code blocks by bumping Textual to 0.3.1. (#2033) Thanks @garricn.
- Browser: fall back to URL matching for extension relay target resolution. (#1999) Thanks @jonit-dev.
- Browser: route browser control via gateway/node; remove standalone browser control command and control URL config.
- Browser: route `browser.request` via node proxies when available; honor proxy timeouts; derive browser ports from `gateway.port`.
- Update: ignore dist/control-ui for dirty checks and restore after ui builds. (#1976) Thanks @Glucksberg.
- Build: bundle A2UI assets during build and stop tracking generated bundles. (#2455) Thanks @0oAstro.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
@@ -53,7 +47,6 @@ Status: unreleased.
- Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc.
- Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma.
- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21.
- Telegram: add sticker receive/send with vision caching. (#2548) Thanks @longjos.
- Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918.
- Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999.
- macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal.
@@ -62,8 +55,6 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
- Memory Search: keep auto provider model defaults and only include remote when configured. (#2576) Thanks @papago2355.
- macOS: auto-scroll to bottom when sending a new message while scrolled up. (#2471) Thanks @kennyklee.
- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
@@ -74,7 +65,6 @@ Status: unreleased.
- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481.
- Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Agents: skip cooldowned providers during model failover. (#2143) Thanks @YiWang24.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
@@ -713,7 +703,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
### Highlights
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
- Browser control: Chrome extension relay takeover mode + remote browser control support.
- Browser control: Chrome extension relay takeover mode + remote browser control via `clawdbot browser serve`.
- Plugins: channel plugins (gateway HTTP hooks) + Zalo plugin + onboarding install flow. (#854) — thanks @longmaba.
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
@@ -731,7 +721,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control (standalone server + token auth).
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
+1
View File
@@ -384,6 +384,7 @@ Browser control (optional):
{
browser: {
enabled: true,
controlUrl: "http://127.0.0.1:18791",
color: "#FF4500"
}
}
-4
View File
@@ -13,10 +13,6 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
- `https://docs.clawd.bot/gateway/security`
### Web Interface Safety
Clawdbot's web interface is intended for local use only. Do **not** bind it to the public internet; it is not hardened for public exposure.
## Runtime Requirements
### Node.js Version
@@ -83,10 +83,7 @@ enum CommandResolver {
"/usr/bin",
"/bin",
]
#if DEBUG
// Dev-only convenience. Avoid project-local PATH hijacking in release builds.
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
#endif
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
if !clawdbotPaths.isEmpty {
extras.insert(contentsOf: clawdbotPaths, at: 1)
@@ -192,13 +189,9 @@ enum CommandResolver {
}
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
#if DEBUG
let root = projectRoot ?? self.projectRoot()
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
#else
return nil
#endif
}
static func nodeCliPath() -> String? {
@@ -13,7 +13,6 @@ public struct ClawdbotChatView: View {
@State private var showSessions = false
@State private var hasPerformedInitialScroll = false
@State private var isPinnedToBottom = true
@State private var lastUserMessageID: UUID?
private let showsSessionSwitcher: Bool
private let style: Style
private let markdownVariant: ChatMarkdownVariant
@@ -133,28 +132,8 @@ public struct ClawdbotChatView: View {
self.hasPerformedInitialScroll = false
self.isPinnedToBottom = true
}
.onChange(of: self.viewModel.isSending) { _, isSending in
// Scroll to bottom when user sends a message, even if scrolled up.
guard isSending, self.hasPerformedInitialScroll else { return }
self.isPinnedToBottom = true
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
}
.onChange(of: self.viewModel.messages.count) { _, _ in
guard self.hasPerformedInitialScroll else { return }
if let lastMessage = self.viewModel.messages.last,
lastMessage.role.lowercased() == "user",
lastMessage.id != self.lastUserMessageID {
self.lastUserMessageID = lastMessage.id
self.isPinnedToBottom = true
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
return
}
guard self.isPinnedToBottom else { return }
guard self.hasPerformedInitialScroll, self.isPinnedToBottom else { return }
withAnimation(.snappy(duration: 0.22)) {
self.scrollPosition = self.scrollerBottomID
}
@@ -39,7 +39,7 @@ export default defineConfig({
output: {
file: outputFile,
format: "esm",
codeSplitting: false,
inlineDynamicImports: true,
sourcemap: false,
},
});
+2 -1
View File
@@ -168,7 +168,8 @@
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or node host), then click the toolbar button again.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
+15
View File
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css">
</head>
<body>
<clawdbot-app></clawdbot-app>
</body>
</html>
-1
View File
@@ -218,7 +218,6 @@ Prefer `chat_guid` for stable routing:
## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
- Keep the API password and webhook endpoint secret (treat them like credentials).
- Localhost trust means a same-host reverse proxy can unintentionally bypass the password. If you proxy the gateway, require auth at the proxy and configure `gateway.trustedProxies`. See [Gateway security](/gateway/security#reverse-proxy-configuration).
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
## Troubleshooting
-4
View File
@@ -298,12 +298,8 @@ ack reaction after the bot replies.
- `guilds."*"`: default per-guild settings applied when no explicit entry exists.
- `guilds.<id>.slug`: optional friendly slug used for display names.
- `guilds.<id>.users`: optional per-guild user allowlist (ids or names).
- `guilds.<id>.tools`: optional per-guild tool policy overrides (`allow`/`deny`/`alsoAllow`) used when the channel override is missing.
- `guilds.<id>.toolsBySender`: optional per-sender tool policy overrides at the guild level (applies when the channel override is missing; `"*"` wildcard supported).
- `guilds.<id>.channels.<channel>.allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `guilds.<id>.channels.<channel>.requireMention`: mention gating for the channel.
- `guilds.<id>.channels.<channel>.tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
- `guilds.<id>.channels.<channel>.toolsBySender`: optional per-sender tool policy overrides within the channel (`"*"` wildcard supported).
- `guilds.<id>.channels.<channel>.users`: optional per-channel user allowlist.
- `guilds.<id>.channels.<channel>.skills`: skill filter (omit = all skills, empty = none).
- `guilds.<id>.channels.<channel>.systemPrompt`: extra system prompt for the channel (combined with channel topic).
-4
View File
@@ -421,12 +421,8 @@ Key settings (see `/gateway/configuration` for shared channel patterns):
- `channels.msteams.replyStyle`: `thread | top-level` (see [Reply Style](#reply-style-threads-vs-posts)).
- `channels.msteams.teams.<teamId>.replyStyle`: per-team override.
- `channels.msteams.teams.<teamId>.requireMention`: per-team override.
- `channels.msteams.teams.<teamId>.tools`: default per-team tool policy overrides (`allow`/`deny`/`alsoAllow`) used when a channel override is missing.
- `channels.msteams.teams.<teamId>.toolsBySender`: default per-team per-sender tool policy overrides (`"*"` wildcard supported).
- `channels.msteams.teams.<teamId>.channels.<conversationId>.replyStyle`: per-channel override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.requireMention`: per-channel override.
- `channels.msteams.teams.<teamId>.channels.<conversationId>.tools`: per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
- `channels.msteams.teams.<teamId>.channels.<conversationId>.toolsBySender`: per-channel per-sender tool policy overrides (`"*"` wildcard supported).
- `channels.msteams.sharePointSiteId`: SharePoint site ID for file uploads in group chats/channels (see [Sending files in group chats](#sending-files-in-group-chats)).
## Routing & Sessions
-2
View File
@@ -464,8 +464,6 @@ For fine-grained control, use these tags in agent responses:
Channel options (`channels.slack.channels.<id>` or `channels.slack.channels.<name>`):
- `allow`: allow/deny the channel when `groupPolicy="allowlist"`.
- `requireMention`: mention gating for the channel.
- `tools`: optional per-channel tool policy overrides (`allow`/`deny`/`alsoAllow`).
- `toolsBySender`: optional per-sender tool policy overrides within the channel (keys are sender ids/@handles/emails; `"*"` wildcard supported).
- `allowBots`: allow bot-authored messages in this channel (default: false).
- `users`: optional per-channel user allowlist.
- `skills`: skill filter (omit = all skills, empty = none).
+1 -129
View File
@@ -383,133 +383,6 @@ For message tool sends, set `asVoice: true` with a voice-compatible audio `media
}
```
## Stickers
Clawdbot supports receiving and sending Telegram stickers with intelligent caching.
### Receiving stickers
When a user sends a sticker, Clawdbot handles it based on the sticker type:
- **Static stickers (WEBP):** Downloaded and processed through vision. The sticker appears as a `<media:sticker>` placeholder in the message content.
- **Animated stickers (TGS):** Skipped (Lottie format not supported for processing).
- **Video stickers (WEBM):** Skipped (video format not supported for processing).
Template context field available when receiving stickers:
- `Sticker` — object with:
- `emoji` — emoji associated with the sticker
- `setName` — name of the sticker set
- `fileId` — Telegram file ID (send the same sticker back)
- `fileUniqueId` — stable ID for cache lookup
- `cachedDescription` — cached vision description when available
### Sticker cache
Stickers are processed through the AI's vision capabilities to generate descriptions. Since the same stickers are often sent repeatedly, Clawdbot caches these descriptions to avoid redundant API calls.
**How it works:**
1. **First encounter:** The sticker image is sent to the AI for vision analysis. The AI generates a description (e.g., "A cartoon cat waving enthusiastically").
2. **Cache storage:** The description is saved along with the sticker's file ID, emoji, and set name.
3. **Subsequent encounters:** When the same sticker is seen again, the cached description is used directly. The image is not sent to the AI.
**Cache location:** `~/.clawdbot/telegram/sticker-cache.json`
**Cache entry format:**
```json
{
"fileId": "CAACAgIAAxkBAAI...",
"fileUniqueId": "AgADBAADb6cxG2Y",
"emoji": "👋",
"setName": "CoolCats",
"description": "A cartoon cat waving enthusiastically",
"cachedAt": "2026-01-15T10:30:00.000Z"
}
```
**Benefits:**
- Reduces API costs by avoiding repeated vision calls for the same sticker
- Faster response times for cached stickers (no vision processing delay)
- Enables sticker search functionality based on cached descriptions
The cache is populated automatically as stickers are received. There is no manual cache management required.
### Sending stickers
The agent can send and search stickers using the `sticker` and `sticker-search` actions. These are disabled by default and must be enabled in config:
```json5
{
channels: {
telegram: {
actions: {
sticker: true
}
}
}
}
```
**Send a sticker:**
```json5
{
"action": "sticker",
"channel": "telegram",
"to": "123456789",
"fileId": "CAACAgIAAxkBAAI..."
}
```
Parameters:
- `fileId` (required) — the Telegram file ID of the sticker. Obtain this from `Sticker.fileId` when receiving a sticker, or from a `sticker-search` result.
- `replyTo` (optional) — message ID to reply to.
- `threadId` (optional) — message thread ID for forum topics.
**Search for stickers:**
The agent can search cached stickers by description, emoji, or set name:
```json5
{
"action": "sticker-search",
"channel": "telegram",
"query": "cat waving",
"limit": 5
}
```
Returns matching stickers from the cache:
```json5
{
"ok": true,
"count": 2,
"stickers": [
{
"fileId": "CAACAgIAAxkBAAI...",
"emoji": "👋",
"description": "A cartoon cat waving enthusiastically",
"setName": "CoolCats"
}
]
}
```
The search uses fuzzy matching across description text, emoji characters, and set names.
**Example with threading:**
```json5
{
"action": "sticker",
"channel": "telegram",
"to": "-1001234567890",
"fileId": "CAACAgIAAxkBAAI...",
"replyTo": 42,
"threadId": 123
}
```
## Streaming (drafts)
Telegram can stream **draft bubbles** while the agent is generating a response.
Clawdbot uses Bot API `sendMessageDraft` (not real messages) and then sends the
@@ -547,7 +420,7 @@ Outbound Telegram API calls retry on transient network/429 errors with exponenti
- Tool: `telegram` with `react` action (`chatId`, `messageId`, `emoji`).
- Tool: `telegram` with `deleteMessage` action (`chatId`, `messageId`).
- Reaction removal semantics: see [/tools/reactions](/tools/reactions).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled), and `channels.telegram.actions.sticker` (default: disabled).
- Tool gating: `channels.telegram.actions.reactions`, `channels.telegram.actions.sendMessage`, `channels.telegram.actions.deleteMessage` (default: enabled).
## Reaction notifications
@@ -664,7 +537,6 @@ Provider options:
- `channels.telegram.actions.reactions`: gate Telegram tool reactions.
- `channels.telegram.actions.sendMessage`: gate Telegram tool message sends.
- `channels.telegram.actions.deleteMessage`: gate Telegram tool message deletes.
- `channels.telegram.actions.sticker`: gate Telegram sticker actions — send and search (default: false).
- `channels.telegram.reactionNotifications`: `off | own | all` — control which reactions trigger system events (default: `own` when not set).
- `channels.telegram.reactionLevel`: `off | ack | minimal | extensive` — control agent's reaction capability (default: `minimal` when not set).
+12 -10
View File
@@ -1,8 +1,8 @@
---
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay)"
summary: "CLI reference for `clawdbot browser` (profiles, tabs, actions, extension relay, remote serve)"
read_when:
- You use `clawdbot browser` and want examples for common tasks
- You want to control a browser running on another machine via a node host
- You want to control a remote browser via `browser.controlUrl`
- You want to use the Chrome extension relay (attach/detach via toolbar button)
---
@@ -16,10 +16,8 @@ Related:
## Common flags
- `--url <gatewayWsUrl>`: Gateway WebSocket URL (defaults to config).
- `--token <token>`: Gateway token (if required).
- `--timeout <ms>`: request timeout (ms).
- `--browser-profile <name>`: choose a browser profile (default from config).
- `--url <controlUrl>`: override `browser.controlUrl` for this command invocation.
- `--browser-profile <name>`: choose a browser profile (default comes from config).
- `--json`: machine-readable output (where supported).
## Quick start (local)
@@ -95,10 +93,14 @@ Then Chrome → `chrome://extensions` → enable “Developer mode” → “Loa
Full guide: [Chrome extension](/tools/chrome-extension)
## Remote browser control (node host proxy)
## Remote browser control (`clawdbot browser serve`)
If the Gateway runs on a different machine than the browser, run a **node host** on the machine that has Chrome/Brave/Edge/Chromium. The Gateway will proxy browser actions to that node (no separate browser control server required).
If the Gateway runs on a different machine than the browser, run a standalone browser control server on the machine that runs Chrome:
Use `gateway.nodes.browser.mode` to control auto-routing and `gateway.nodes.browser.node` to pin a specific node if multiple are connected.
```bash
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
```
Security + remote setup: [Browser tool](/tools/browser), [Remote access](/gateway/remote), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
Then point the Gateway at it using `browser.controlUrl` + `browser.controlToken` (or `CLAWDBOT_BROWSER_CONTROL_TOKEN`).
Security + TLS best-practices: [Browser tool](/tools/browser), [Tailscale](/gateway/tailscale), [Security](/gateway/security)
+2 -1
View File
@@ -859,8 +859,9 @@ Location:
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
Common options:
- `--url`, `--token`, `--timeout`, `--json`
- `--url <controlUrl>`
- `--browser-profile <name>`
- `--json`
Manage:
- `browser status`
-36
View File
@@ -232,42 +232,6 @@ Notes:
- Discord defaults live in `channels.discord.guilds."*"` (overridable per guild/channel).
- Group history context is wrapped uniformly across channels and is **pending-only** (messages skipped due to mention gating); use `messages.groupChat.historyLimit` for the global default and `channels.<channel>.historyLimit` (or `channels.<channel>.accounts.*.historyLimit`) for overrides. Set `0` to disable.
## Group/channel tool restrictions (optional)
Some channel configs support restricting which tools are available **inside a specific group/room/channel**.
- `tools`: allow/deny tools for the whole group.
- `toolsBySender`: per-sender overrides within the group (keys are sender IDs/usernames/emails/phone numbers depending on the channel). Use `"*"` as a wildcard.
Resolution order (most specific wins):
1) group/channel `toolsBySender` match
2) group/channel `tools`
3) default (`"*"`) `toolsBySender` match
4) default (`"*"`) `tools`
Example (Telegram):
```json5
{
channels: {
telegram: {
groups: {
"*": { tools: { deny: ["exec"] } },
"-1001234567890": {
tools: { deny: ["exec", "read", "write"] },
toolsBySender: {
"123456789": { alsoAllow: ["exec"] }
}
}
}
}
}
}
```
Notes:
- Group/channel tool restrictions are applied in addition to global/agent tool policy (deny still wins).
- Some channels use different nesting for rooms/channels (e.g., Discord `guilds.*.channels.*`, Slack `channels.*`, MS Teams `teams.*.channels.*`).
## Group allowlists
When `channels.whatsapp.groups`, `channels.telegram.groups`, or `channels.imessage.groups` is configured, the keys act as a group allowlist. Use `"*"` to allow all groups while still setting default mention behavior.
-1
View File
@@ -956,7 +956,6 @@
"gateway/doctor",
"gateway/logging",
"gateway/security",
"security/formal-verification",
"gateway/sandbox-vs-tool-policy-vs-elevated",
"gateway/sandboxing",
"gateway/troubleshooting",
+4 -5
View File
@@ -2759,7 +2759,7 @@ Example:
### `browser` (clawd-managed browser)
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control service.
Clawdbot can start a **dedicated, isolated** Chrome/Brave/Edge/Chromium instance for clawd and expose a small loopback control server.
Profiles can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
profiles are attach-only (start/stop/reset are disabled).
@@ -2768,9 +2768,8 @@ scheme/host for profiles that only set `cdpPort`.
Defaults:
- enabled: `true`
- evaluateEnabled: `true` (set `false` to disable `act:evaluate` and `wait --fn`)
- control service: loopback only (port derived from `gateway.port`, default `18791`)
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
- profile color: `#FF4500` (lobster-orange)
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
@@ -2779,7 +2778,7 @@ Defaults:
{
browser: {
enabled: true,
evaluateEnabled: true,
controlUrl: "http://127.0.0.1:18791",
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
defaultProfile: "chrome",
profiles: {
+2 -2
View File
@@ -83,13 +83,13 @@ Defaults (can be overridden via env/flags/config):
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
- browser control service port = `19003` (derived: `gateway.port+2`, loopback only)
- `browser.controlUrl=http://127.0.0.1:19003` (derived: `gateway.port+2`)
- `canvasHost.port=19005` (derived: `gateway.port+4`)
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
Derived ports (rules of thumb):
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
- browser control service port = base + 2 (loopback only)
- `browser.controlUrl port = base + 2` (or `CLAWDBOT_BROWSER_CONTROL_URL` / config override)
- `canvasHost.port = base + 4` (or `CLAWDBOT_CANVAS_HOST_PORT` / config override)
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
+3 -3
View File
@@ -73,7 +73,7 @@ clawdbot --profile rescue gateway install
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
- browser control service port = base + 2 (loopback only)
- `browser.controlUrl port = base + 2`
- `canvasHost.port = base + 4`
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
@@ -81,8 +81,8 @@ If you override any of these in config or env, you must keep them unique per ins
## Browser/CDP notes (common footgun)
- Do **not** pin `browser.cdpUrl` to the same values on multiple instances.
- Each instance needs its own browser control port and CDP range (derived from its gateway port).
- Do **not** pin `browser.controlUrl` or `browser.cdpUrl` to the same values on multiple instances.
- Each instance needs its own browser control port and CDP range.
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
+1 -1
View File
@@ -117,6 +117,6 @@ Short version: **keep the Gateway loopback-only** unless you’re sure you need
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
Set it to `false` if you want tokens/passwords instead.
- Treat browser control like operator access: tailnet-only + deliberate node pairing.
- Treat `browser.controlUrl` like an admin API: tailnet-only + token auth.
Deep dive: [Security](/gateway/security).
@@ -1,12 +0,0 @@
---
title: Formal Verification (Security Models)
summary: Redirect to the canonical Formal Verification page.
permalink: /gateway/security/formal-verification/
---
This page moved to: [/security/formal-verification/](/security/formal-verification/)
<script>
// Best-effort client-side redirect for Mintlify/Next.
window.location.replace("/security/formal-verification/");
</script>
+21 -18
View File
@@ -7,8 +7,6 @@ read_when:
## Quick check: `clawdbot security audit`
See also: [Formal Verification (Security Models)](/security/formal-verification/)
Run this regularly (especially after changing config or exposing network surfaces):
```bash
@@ -38,7 +36,7 @@ Start with the smallest access that still works, then widen it as you gain confi
- **Inbound access** (DM policies, group policies, allowlists): can strangers trigger the bot?
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel).
- **Browser control exposure** (remote nodes, relay ports, remote CDP endpoints).
- **Browser control exposure** (remote controlUrl without token, HTTP, token reuse).
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
- **Plugins** (extensions exist without an explicit allowlist).
- **Model hygiene** (warn when configured models look legacy; not a hard block).
@@ -63,7 +61,7 @@ When the audit prints findings, treat this as a priority order:
1. **Anything “open” + tools enabled**: lock down DMs/groups first (pairing/allowlists), then tighten tool policy/sandboxing.
2. **Public network exposure** (LAN bind, Funnel, missing auth): fix immediately.
3. **Browser control remote exposure**: treat it like operator access (tailnet-only, pair nodes deliberately, avoid public exposure).
3. **Browser control remote exposure**: treat it like a remote admin API (token required; HTTPS/tailnet-only).
4. **Permissions**: make sure state/config/credentials/auth are not group/world-readable.
5. **Plugins/extensions**: only load what you explicitly trust.
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
@@ -279,7 +277,7 @@ Assume “compromised” means: someone got into a room that can trigger the bot
- Lock down inbound surfaces (DM policy, group allowlists, mention gating).
2. **Rotate secrets**
- Rotate `gateway.auth` token/password.
- Rotate `hooks.token` (if used) and revoke any suspicious node pairings.
- Rotate `browser.controlToken` and `hooks.token` (if used).
- Revoke/rotate model provider credentials (API keys / OAuth).
3. **Review artifacts**
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
@@ -432,19 +430,26 @@ Trusted proxies:
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control via node host (recommended)
### 0.6.1) Browser control server over Tailscale (recommended)
If your Gateway is remote but the browser runs on another machine, run a **node host**
on the browser machine and let the Gateway proxy browser actions (see [Browser tool](/tools/browser)).
Treat node pairing like admin access.
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
Recommended pattern:
- Keep the Gateway and node host on the same tailnet (Tailscale).
- Pair the node intentionally; disable browser proxy routing if you don’t need it.
```bash
# on the machine that runs Chrome
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then on the Gateway, set:
- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
Avoid:
- Exposing relay/control ports over LAN or public Internet.
- Tailscale Funnel for browser control endpoints (public exposure).
- `--bind 0.0.0.0` (LAN-visible surface)
- Tailscale Funnel for browser control endpoints (public exposure)
### 0.7) Secrets on disk (what’s sensitive)
@@ -572,15 +577,13 @@ If that browser profile already contains logged-in sessions, the model can
access those accounts and data. Treat browser profiles as **sensitive state**:
- Prefer a dedicated profile for the agent (the default `clawd` profile).
- Avoid pointing the agent at your personal daily-driver profile.
- `act:evaluate` and `wait --fn` run arbitrary JavaScript in the page context.
Prompt injection can steer the model into calling them. If you do not need
them, set `browser.evaluateEnabled=false` (see [Configuration](/gateway/configuration#browser-clawd-managed-browser)).
- Keep host browser control disabled for sandboxed agents unless you trust them.
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
- Keep the Gateway and node hosts tailnet-only; avoid exposing relay/control ports to LAN or public Internet.
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
- Prefer env vars for the token (`CLAWDBOT_BROWSER_CONTROL_TOKEN`) instead of storing it in config on disk.
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
## Per-agent access profiles (multi-agent)
+27 -5
View File
@@ -100,13 +100,35 @@ clawdbot gateway --tailscale funnel --auth password
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
the same Gateway WS endpoint, so Serve can work for node access.
## Browser control (remote Gateway + local browser)
## Browser control server (remote Gateway + local browser)
If you run the Gateway on one machine but want to drive a browser on another machine,
run a **node host** on the browser machine and keep both on the same tailnet.
The Gateway will proxy browser actions to the node; no separate control server or Serve URL needed.
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
and publish it through Tailscale **Serve** (tailnet-only):
Avoid Funnel for browser control; treat node pairing like operator access.
```bash
# on the machine that runs Chrome
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then point the Gateway config at the HTTPS URL:
```json5
{
browser: {
enabled: true,
controlUrl: "https://<magicdns>/"
}
}
```
And authenticate from the Gateway with the same token (prefer env):
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
## Tailscale prerequisites + limits
+3 -4
View File
@@ -1093,10 +1093,9 @@ clawdbot browser extension path
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
Full guide (including remote Gateway + security notes): [Chrome extension](/tools/chrome-extension)
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need anything extra.
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
## Sandboxing and memory
@@ -1480,7 +1479,7 @@ setup is an always‑on host plus your laptop as a node.
- **Safer execution controls.** `system.run` is gated by node allowlists/approvals on that laptop.
- **More device tools.** Nodes expose `canvas`, `camera`, and `screen` in addition to `system.run`.
- **Local browser automation.** Keep the Gateway on a VPS, but run Chrome locally and relay control
with the Chrome extension + a node host on the laptop.
with the Chrome extension + `clawdbot browser serve`.
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
device automation.
-107
View File
@@ -1,107 +0,0 @@
---
title: Formal Verification (Security Models)
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
permalink: /security/formal-verification/
---
# Formal Verification (Security Models)
This page tracks Clawdbot’s **formal security models** (TLA+/TLC today; more as needed).
**Goal (north star):** provide a machine-checked argument that Clawdbot enforces its
intended security policy (authorization, session isolation, tool gating, and
misconfiguration safety), under explicit assumptions.
**What this is (today):** an executable, attacker-driven **security regression suite**:
- Each claim has a runnable model-check over a finite state space.
- Many claims have a paired **negative model** that produces a counterexample trace for a realistic bug class.
**What this is not (yet):** a proof that “Clawdbot is secure in all respects” or that the full TypeScript implementation is correct.
## Where the models live
Models are maintained in a separate repo: [vignesh07/clawdbot-formal-models](https://github.com/vignesh07/clawdbot-formal-models).
## Important caveats
- These are **models**, not the full TypeScript implementation. Drift between model and code is possible.
- Results are bounded by the state space explored by TLC; “green” does not imply security beyond the modeled assumptions and bounds.
- Some claims rely on explicit environmental assumptions (e.g., correct deployment, correct configuration inputs).
## Reproducing results
Today, results are reproduced by cloning the models repo locally and running TLC (see below). A future iteration could offer:
- CI-run models with public artifacts (counterexample traces, run logs)
- a hosted “run this model” workflow for small, bounded checks
Getting started:
```bash
git clone https://github.com/vignesh07/clawdbot-formal-models
cd clawdbot-formal-models
# Java 11+ required (TLC runs on the JVM).
# The repo vendors a pinned `tla2tools.jar` (TLA+ tools) and provides `bin/tlc` + Make targets.
make <target>
```
### Gateway exposure and open gateway misconfiguration
**Claim:** binding beyond loopback without auth can make remote compromise possible / increases exposure; token/password blocks unauth attackers (per the model assumptions).
- Green runs:
- `make gateway-exposure-v2`
- `make gateway-exposure-v2-protected`
- Red (expected):
- `make gateway-exposure-v2-negative`
See also: `docs/gateway-exposure-matrix.md` in the models repo.
### Nodes.run pipeline (highest-risk capability)
**Claim:** `nodes.run` requires (a) node command allowlist plus declared commands and (b) live approval when configured; approvals are tokenized to prevent replay (in the model).
- Green runs:
- `make nodes-pipeline`
- `make approvals-token`
- Red (expected):
- `make nodes-pipeline-negative`
- `make approvals-token-negative`
### Pairing store (DM gating)
**Claim:** pairing requests respect TTL and pending-request caps.
- Green runs:
- `make pairing`
- `make pairing-cap`
- Red (expected):
- `make pairing-negative`
- `make pairing-cap-negative`
### Ingress gating (mentions + control-command bypass)
**Claim:** in group contexts requiring mention, an unauthorized “control command” cannot bypass mention gating.
- Green:
- `make ingress-gating`
- Red (expected):
- `make ingress-gating-negative`
### Routing/session-key isolation
**Claim:** DMs from distinct peers do not collapse into the same session unless explicitly linked/configured.
- Green:
- `make routing-isolation`
- Red (expected):
- `make routing-isolation-negative`
## Roadmap
Next models to deepen fidelity:
- Pairing store concurrency/locking/idempotency
- Provider-specific ingress preflight modeling
- Routing identity-links + dmScope variants + binding precedence
- Gateway auth conformance (proxy/tailscale specifics)
-1
View File
@@ -175,7 +175,6 @@ clawdbot onboard --install-daemon
```
If you don’t have a global install yet, run the onboarding step via `pnpm clawdbot ...` from the repo.
`pnpm build` also bundles A2UI assets; if you need to run just that step, use `pnpm canvas:a2ui:bundle`.
Gateway (from this repo):
+136 -30
View File
@@ -1,5 +1,5 @@
---
summary: "Integrated browser control service + action commands"
summary: "Integrated browser control server + action commands"
read_when:
- Adding agent-controlled browser automation
- Debugging why clawd is interfering with your own Chrome
@@ -10,7 +10,7 @@ read_when:
Clawdbot can run a **dedicated Chrome/Brave/Edge/Chromium profile** that the agent controls.
It is isolated from your personal browser and is managed through a small local
control service inside the Gateway (loopback only).
control server.
Beginner view:
- Think of it as a **separate, agent-only browser**.
@@ -57,7 +57,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
{
browser: {
enabled: true, // default: true
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://127.0.0.1:18792", // defaults to controlUrl + 1
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
defaultProfile: "chrome",
@@ -76,11 +77,10 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
```
Notes:
- The browser control service binds to loopback on a port derived from `gateway.port`
(default: `18791`, which is gateway + 2). The relay uses the next port (`18792`).
- `controlUrl` defaults to `http://127.0.0.1:18791`.
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
the derived browser ports shift to stay in the same “family”.
- `cdpUrl` defaults to the relay port when unset.
the default browser ports shift to stay in the same “family” (control = gateway + 2).
- `cdpUrl` defaults to `controlUrl + 1` when unset.
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket reachability checks.
- `attachOnly: true` means “never launch a local browser; only attach if it is already running.”
@@ -126,11 +126,38 @@ clawdbot config set browser.executablePath "/usr/bin/google-chrome"
## Local vs remote control
- **Local control (default):** the Gateway starts the loopback control service and can launch a local browser.
- **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it.
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
The Gateway starts the control server and can launch a local browser.
- **Remote control:** `controlUrl` is non-loopback. The Gateway **does not** start
a local server; it assumes you are pointing at an existing server elsewhere.
- **Remote CDP:** set `browser.profiles.<name>.cdpUrl` (or `browser.cdpUrl`) to
attach to a remote Chromium-based browser. In this case, Clawdbot will not launch a local browser.
## Remote browser (control server)
You can run the **browser control server** on another machine and point your
Gateway at it with a remote `controlUrl`. This lets the agent drive a browser
outside the host (lab box, VM, remote desktop, etc.).
Key points:
- The **control server** speaks to Chromium-based browsers (Chrome/Brave/Edge/Chromium) via **CDP**.
- The **Gateway** only needs the HTTP control URL.
- Profiles are resolved on the **control server** side.
Example:
```json5
{
browser: {
enabled: true,
controlUrl: "http://10.0.0.42:18791",
defaultProfile: "work"
}
}
```
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
directly to a Chromium-based browser instance without a remote control server.
Remote CDP URLs can include auth:
- Query tokens (e.g., `https://provider.example?token=<token>`)
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
@@ -139,11 +166,11 @@ 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)
### 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 extra browser config.
This is the default path for remote gateways.
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**.
@@ -152,7 +179,7 @@ Notes:
- On the node: `nodeHost.browserProxy.enabled=false`
- On the gateway: `gateway.nodes.browser.mode="off"`
## Browserless (hosted remote CDP)
### Browserless (hosted remote CDP)
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
@@ -180,16 +207,94 @@ Notes:
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
- Choose the region endpoint that matches your Browserless account (see their docs).
### Running the control server on the browser machine
Run a standalone browser control server (recommended when your Gateway is remote):
```bash
# on the machine that runs Chrome/Brave/Edge
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
```
Then point your Gateway at it:
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
// Option A (recommended): keep token in env on the Gateway
// (avoid writing secrets into config files)
// controlToken: "<token>"
}
}
```
And set the auth token in the Gateway environment:
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Option B: store the token in the Gateway config instead (same shared token):
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
controlToken: "<token>"
}
}
```
## Security
Key ideas:
- Browser control is loopback-only; access flows through the Gateway’s auth or node pairing.
- Keep the Gateway and any node hosts on a private network (Tailscale); avoid public exposure.
- Treat remote CDP URLs/tokens as secrets; prefer env vars or a secrets manager.
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
Remote CDP tips:
- Prefer HTTPS endpoints and short-lived tokens where possible.
- Avoid embedding long-lived tokens directly in config files.
Key ideas:
- Treat the browser control server like an admin API: **private network only**.
- Use **token auth** always when the server is reachable off-machine.
- Prefer **Tailnet-only** connectivity over LAN exposure.
### Tokens (what is shared with what?)
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
### Binding (don’t expose to your LAN by accident)
Recommended:
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
Avoid:
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
### TLS / HTTPS (recommended approach: terminate in front)
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
If you’re already using Tailscale, you have two good options:
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
```bash
# on the browser machine
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
Notes:
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
## Profiles (multi-browser)
@@ -213,12 +318,13 @@ Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” C
Full guide: [Chrome extension](/tools/chrome-extension)
Flow:
- The Gateway runs locally (same machine) or a node host runs on the browser machine.
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
Use `browser serve` only when the Gateway runs elsewhere (remote mode).
### Sandboxed sessions
@@ -281,7 +387,8 @@ Platforms:
## Control API (optional)
For local integrations only, the Gateway exposes a small loopback HTTP API:
If you want to integrate directly, the browser control server exposes a small
HTTP API:
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
@@ -505,11 +612,8 @@ These are useful for “make the site behave like X” workflows:
## Security & privacy
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
- `browser act kind=evaluate` / `clawdbot browser evaluate` and `wait --fn`
execute arbitrary JavaScript in the page context. Prompt injection can steer
this. Disable it with `browser.evaluateEnabled=false` if you do not need it.
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
- Keep the Gateway/node host private (loopback or tailnet-only).
- Keep control URLs loopback-only unless you intentionally expose the server.
- Remote CDP endpoints are powerful; tunnel and protect them.
## Troubleshooting
@@ -527,10 +631,12 @@ How it maps:
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
- `browser screenshot` captures pixels (full page or element).
- `browser` accepts:
- `profile` to choose a named browser profile (clawd, chrome, or remote CDP).
- `target` (`sandbox` | `host` | `node`) to select where the browser lives.
- `profile` to choose a named browser profile (host or remote control server).
- `target` (`sandbox` | `host` | `custom`) to select where the browser lives.
- `controlUrl` sets `target: "custom"` implicitly (remote control server).
- In sandboxed sessions, `target: "host"` requires `agents.defaults.sandbox.browser.allowHostControl=true`.
- If `target` is omitted: sandboxed sessions default to `sandbox`, non-sandbox sessions default to `host`.
- If a browser-capable node is connected, the tool may auto-route to it unless you pin `target="host"` or `target="node"`.
- Sandbox allowlists can restrict `target: "custom"` to specific URLs/hosts/ports.
- Defaults: allowlists unset (no restriction), and sandbox host control is disabled.
This keeps the agent deterministic and avoids brittle selectors.
+32 -15
View File
@@ -15,7 +15,7 @@ Attach/detach happens via a **single Chrome toolbar button**.
## What it is (concept)
There are three parts:
- **Browser control service** (Gateway or node): the API the agent/tool calls (via the Gateway)
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
@@ -87,22 +87,23 @@ clawdbot browser create-profile \
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
If you see `!`:
- Make sure the Gateway is running locally (default setup), or run a node host on this machine if the Gateway runs elsewhere.
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
- Open the extension Options page; it shows whether the relay is reachable.
## Remote Gateway (use a node host)
## Do I need `clawdbot browser serve`?
### Local Gateway (same machine as Chrome) — usually **no extra steps**
### Local Gateway (same machine as Chrome) — usually **no**
If the Gateway runs on the same machine as Chrome, it starts the browser control service on loopback
and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway.
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
you typically **do not** need `clawdbot browser serve`.
### Remote Gateway (Gateway runs elsewhere) — **run a node host**
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
auto-start the local relay server on `http://127.0.0.1:18792/`.
If your Gateway runs on another machine, start a node host on the machine that runs Chrome.
The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine.
### Remote Gateway (Gateway runs elsewhere) — **yes**
If multiple nodes are connected, pin one with `gateway.nodes.browser.node` or set `gateway.nodes.browser.mode`.
If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
(and publish it via Tailscale Serve / TLS). See the section below.
## Sandboxing (tool containers)
@@ -133,10 +134,26 @@ Then ensure the tool isn’t denied by tool policy, and (if needed) call `browse
Debugging: `clawdbot sandbox explain`
## Remote access tips
## Remote Gateway (recommended: Tailscale Serve)
- Keep the Gateway and node host on the same tailnet; avoid exposing relay ports to LAN or public Internet.
- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`).
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
On the **browser machine**:
```bash
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
On the **Gateway machine**:
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
- Provide the token (prefer env):
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
## How “extension path” works
@@ -159,8 +176,8 @@ This is powerful and risky. Treat it like giving the model “hands on your brow
Recommendations:
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
- Keep the Gateway and any node hosts tailnet-only; rely on Gateway auth + node pairing.
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
- Keep the browser control server tailnet-only (Tailscale) and require a token.
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
Related:
- Browser tool overview: [Browser](/tools/browser)
+1 -2
View File
@@ -67,8 +67,7 @@ Example:
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
- Linux: `/usr/local/bin`, `/usr/bin`, `/bin`
- `host=sandbox`: runs `sh -lc` (login shell) inside the container, so `/etc/profile` may reset `PATH`.
Clawdbot prepends `env.PATH` after profile sourcing via an internal env var (no shell interpolation);
`tools.exec.pathPrepend` applies here too.
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
if the exec call already sets `env.PATH`. Headless node hosts accept `PATH` only when it prepends
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
+3 -6
View File
@@ -249,17 +249,16 @@ Profile management:
- `reset-profile` — kill orphan process on profile's port (local only)
Common parameters:
- `controlUrl` (defaults from config)
- `profile` (optional; defaults to `browser.defaultProfile`)
- `target` (`sandbox` | `host` | `node`)
- `node` (optional; picks a specific node id/name)
Notes:
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
- Uses `browser.controlUrl` unless `controlUrl` is passed explicitly.
- All actions accept optional `profile` parameter for multi-instance support.
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
- Port range: 18800-18899 (~100 profiles max).
- Remote profiles are attach-only (no start/stop/reset).
- If a browser-capable node is connected, the tool may auto-route to it (unless you pin `target`).
- `snapshot` defaults to `ai` when Playwright is installed; use `aria` for the accessibility tree.
- `snapshot` also supports role-snapshot options (`interactive`, `compact`, `depth`, `selector`) which return refs like `e12`.
- `act` requires `ref` from `snapshot` (numeric `12` from AI snapshots, or `e12` from role snapshots); use `evaluate` for rare CSS selector needs.
@@ -411,9 +410,7 @@ Gateway-backed tools (`canvas`, `nodes`, `cron`):
- `timeoutMs`
Browser tool:
- `profile` (optional; defaults to `browser.defaultProfile`)
- `target` (`sandbox` | `host` | `node`)
- `node` (optional; pin a specific node id/name)
- `controlUrl` (defaults from config)
## Recommended agent flows
+3 -48
View File
@@ -11,7 +11,6 @@ import type {
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveToolsBySender,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "clawdbot/plugin-sdk";
@@ -107,36 +106,9 @@ export function resolveMSTeamsGroupToolPolicy(
});
if (resolved.channelConfig) {
const senderPolicy = resolveToolsBySender({
toolsBySender: resolved.channelConfig.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
if (senderPolicy) return senderPolicy;
if (resolved.channelConfig.tools) return resolved.channelConfig.tools;
const teamSenderPolicy = resolveToolsBySender({
toolsBySender: resolved.teamConfig?.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
if (teamSenderPolicy) return teamSenderPolicy;
return resolved.teamConfig?.tools;
}
if (resolved.teamConfig) {
const teamSenderPolicy = resolveToolsBySender({
toolsBySender: resolved.teamConfig.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
if (teamSenderPolicy) return teamSenderPolicy;
if (resolved.teamConfig.tools) return resolved.teamConfig.tools;
return resolved.channelConfig.tools ?? resolved.teamConfig?.tools;
}
if (resolved.teamConfig?.tools) return resolved.teamConfig.tools;
if (!groupId) return undefined;
@@ -153,24 +125,7 @@ export function resolveMSTeamsGroupToolPolicy(
normalizeKey: normalizeChannelSlug,
});
if (match.entry) {
const senderPolicy = resolveToolsBySender({
toolsBySender: match.entry.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
if (senderPolicy) return senderPolicy;
if (match.entry.tools) return match.entry.tools;
const teamSenderPolicy = resolveToolsBySender({
toolsBySender: teamConfig?.toolsBySender,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
if (teamSenderPolicy) return teamSenderPolicy;
return teamConfig?.tools;
return match.entry.tools ?? teamConfig?.tools;
}
}
+1 -1
View File
@@ -82,7 +82,7 @@
"docs:bin": "node scripts/build-docs-list.mjs",
"docs:dev": "cd docs && mint dev",
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
"build": "pnpm canvas:a2ui:bundle && tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"build": "tsc -p tsconfig.json && node --import tsx scripts/canvas-a2ui-copy.ts && node --import tsx scripts/copy-hook-metadata.ts && node --import tsx scripts/write-build-info.ts",
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
"release:check": "node --import tsx scripts/release-check.ts",
"ui:install": "node scripts/ui.js install",
+1 -8
View File
@@ -1,15 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
on_error() {
echo "A2UI bundling failed. Re-run with: pnpm canvas:a2ui:bundle" >&2
echo "If this persists, verify pnpm deps and try again." >&2
}
trap on_error ERR
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
INPUT_PATHS=(
"$ROOT_DIR/package.json"
@@ -40,7 +33,7 @@ compute_hash() {
current_hash="$(compute_hash)"
if [[ -f "$HASH_FILE" ]]; then
previous_hash="$(cat "$HASH_FILE")"
if [[ "$previous_hash" == "$current_hash" && -f "$OUTPUT_FILE" ]]; then
if [[ "$previous_hash" == "$current_hash" ]]; then
echo "A2UI bundle up to date; skipping."
exit 0
fi
+10 -35
View File
@@ -1,44 +1,19 @@
import fs from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const srcDir = path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir = path.join(repoRoot, "dist", "canvas-host", "a2ui");
export function getA2uiPaths(env = process.env) {
const srcDir =
env.CLAWDBOT_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
const outDir =
env.CLAWDBOT_A2UI_OUT_DIR ?? path.join(repoRoot, "dist", "canvas-host", "a2ui");
return { srcDir, outDir };
}
export async function copyA2uiAssets({
srcDir,
outDir,
}: {
srcDir: string;
outDir: string;
}) {
try {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
} catch (err) {
const message =
'Missing A2UI bundle assets. Run "pnpm canvas:a2ui:bundle" and retry.';
throw new Error(message, { cause: err });
}
async function main() {
await fs.stat(path.join(srcDir, "index.html"));
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
await fs.mkdir(path.dirname(outDir), { recursive: true });
await fs.cp(srcDir, outDir, { recursive: true });
}
async function main() {
const { srcDir, outDir } = getA2uiPaths();
await copyA2uiAssets({ srcDir, outDir });
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
}
main().catch((err) => {
console.error(String(err));
process.exit(1);
});
@@ -1,164 +0,0 @@
import fs from "node:fs/promises";
import path from "node:path";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveApiKeyForProfile } from "./oauth.js";
import { ensureAuthProfileStore } from "./store.js";
import type { AuthProfileStore } from "./types.js";
describe("resolveApiKeyForProfile fallback to main agent", () => {
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
let tmpDir: string;
let mainAgentDir: string;
let secondaryAgentDir: string;
beforeEach(async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
await fs.mkdir(mainAgentDir, { recursive: true });
await fs.mkdir(secondaryAgentDir, { recursive: true });
// Set environment variables so resolveClawdbotAgentDir() returns mainAgentDir
process.env.CLAWDBOT_STATE_DIR = tmpDir;
process.env.CLAWDBOT_AGENT_DIR = mainAgentDir;
process.env.PI_CODING_AGENT_DIR = mainAgentDir;
});
afterEach(async () => {
vi.unstubAllGlobals();
// Restore original environment
if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
await fs.rm(tmpDir, { recursive: true, force: true });
});
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
// Write expired credentials for secondary agent
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: expiredTime,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
// Write fresh credentials for main agent
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "fresh-access-token",
refresh: "fresh-refresh-token",
expires: freshTime,
},
},
};
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
// Mock fetch to simulate OAuth refresh failure
const fetchSpy = vi.fn(async () => {
return new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchSpy);
// Load the secondary agent's store (will merge with main agent's store)
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
// Call resolveApiKeyForProfile with the secondary agent's expired credentials
// This should:
// 1. Try to refresh the expired token (fails due to mocked fetch)
// 2. Fall back to main agent's fresh credentials
// 3. Copy those credentials to the secondary agent
const result = await resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("fresh-access-token");
expect(result?.provider).toBe("anthropic");
// Verify the credentials were copied to the secondary agent
const updatedSecondaryStore = JSON.parse(
await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
access: "fresh-access-token",
expires: freshTime,
});
});
it("throws error when both secondary and main agent credentials are expired", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
// Write expired credentials for both agents
const expiredStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: expiredTime,
},
},
};
await fs.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(expiredStore),
);
await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
// Mock fetch to simulate OAuth refresh failure
const fetchSpy = vi.fn(async () => {
return new Response(JSON.stringify({ error: "invalid_grant" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
});
vi.stubGlobal("fetch", fetchSpy);
const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
// Should throw because both agents have expired credentials
await expect(
resolveApiKeyForProfile({
store: loadedSecondaryStore,
profileId,
agentDir: secondaryAgentDir,
}),
).rejects.toThrow(/OAuth token refresh failed/);
});
});
+1 -27
View File
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -196,32 +196,6 @@ export async function resolveApiKeyForProfile(params: {
// keep original error
}
}
// Fallback: if this is a secondary agent, try using the main agent's credentials
if (params.agentDir) {
try {
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
const mainCred = mainStore.profiles[profileId];
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
// Main agent has fresh credentials - copy them to this agent and use them
refreshedStore.profiles[profileId] = { ...mainCred };
saveAuthProfileStore(refreshedStore, params.agentDir);
log.info("inherited fresh OAuth credentials from main agent", {
profileId,
agentDir: params.agentDir,
expires: new Date(mainCred.expires).toISOString(),
});
return {
apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
provider: mainCred.provider,
email: mainCred.email,
};
}
} catch {
// keep original error if main agent fallback also fails
}
}
const message = error instanceof Error ? error.message : String(error);
const hint = formatAuthDoctorHint({
cfg,
+1 -8
View File
@@ -60,18 +60,11 @@ export function buildDockerExecArgs(params: {
for (const [key, value] of Object.entries(params.env)) {
args.push("-e", `${key}=${value}`);
}
const hasCustomPath = typeof params.env.PATH === "string" && params.env.PATH.length > 0;
if (hasCustomPath) {
// Avoid interpolating PATH into the shell command; pass it via env instead.
args.push("-e", `CLAWDBOT_PREPEND_PATH=${params.env.PATH}`);
}
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
// overriding both Docker ENV and -e PATH=... environment variables.
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
// while preserving system paths that /etc/profile may have added.
const pathExport = hasCustomPath
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
: "";
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
return args;
}
+2 -23
View File
@@ -318,30 +318,9 @@ describe("buildDockerExecArgs", () => {
});
const commandArg = args[args.length - 1];
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
expect(commandArg).toContain("echo hello");
expect(commandArg).toBe(
'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; echo hello',
);
});
it("does not interpolate PATH into the shell command", () => {
const injectedPath = "$(touch /tmp/clawdbot-path-injection)";
const args = buildDockerExecArgs({
containerName: "test-container",
command: "echo hello",
env: {
PATH: injectedPath,
HOME: "/home/user",
},
tty: false,
});
const commandArg = args[args.length - 1];
expect(args).toContain(`CLAWDBOT_PREPEND_PATH=${injectedPath}`);
expect(commandArg).not.toContain(injectedPath);
expect(commandArg).toContain("CLAWDBOT_PREPEND_PATH");
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
});
it("does not add PATH export when PATH is not in env", () => {
+1 -10
View File
@@ -20,10 +20,8 @@ describe("gateway tool", () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, "kill").mockImplementation(() => true);
const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
const previousProfile = process.env.CLAWDBOT_PROFILE;
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-test-"));
process.env.CLAWDBOT_STATE_DIR = stateDir;
process.env.CLAWDBOT_PROFILE = "isolated";
try {
const tool = createClawdbotTools({
@@ -49,9 +47,7 @@ describe("gateway tool", () => {
payload?: { kind?: string; doctorHint?: string | null };
};
expect(parsed.payload?.kind).toBe("restart");
expect(parsed.payload?.doctorHint).toBe(
"Run: clawdbot --profile isolated doctor --non-interactive",
);
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
expect(kill).not.toHaveBeenCalled();
await vi.runAllTimersAsync();
@@ -64,11 +60,6 @@ describe("gateway tool", () => {
} else {
process.env.CLAWDBOT_STATE_DIR = previousStateDir;
}
if (previousProfile === undefined) {
delete process.env.CLAWDBOT_PROFILE;
} else {
process.env.CLAWDBOT_PROFILE = previousProfile;
}
}
});
+8 -2
View File
@@ -20,8 +20,11 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
import { createTtsTool } from "./tools/tts-tool.js";
export function createClawdbotTools(options?: {
sandboxBrowserBridgeUrl?: string;
browserControlUrl?: string;
allowHostBrowserControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
agentSessionKey?: string;
agentChannel?: GatewayMessageChannel;
agentAccountId?: string;
@@ -72,8 +75,11 @@ export function createClawdbotTools(options?: {
});
const tools: AnyAgentTool[] = [
createBrowserTool({
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
defaultControlUrl: options?.browserControlUrl,
allowHostControl: options?.allowHostBrowserControl,
allowedControlUrls: options?.allowedControlUrls,
allowedControlHosts: options?.allowedControlHosts,
allowedControlPorts: options?.allowedControlPorts,
}),
createCanvasTool(),
createNodesTool({
-42
View File
@@ -103,47 +103,5 @@ describe("pruneHistoryForContextShare", () => {
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
expect(pruned.droppedMessagesList).toEqual([]);
});
it("returns droppedMessagesList containing dropped messages", () => {
const messages: AgentMessage[] = [
makeMessage(1, 4000),
makeMessage(2, 4000),
makeMessage(3, 4000),
makeMessage(4, 4000),
];
const maxContextTokens = 2000; // budget is 1000 tokens (50%)
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBeGreaterThan(0);
expect(pruned.droppedMessagesList.length).toBe(pruned.droppedMessages);
// All messages accounted for: kept + dropped = original
const allIds = [
...pruned.droppedMessagesList.map((m) => m.timestamp),
...pruned.messages.map((m) => m.timestamp),
].sort((a, b) => a - b);
const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
expect(allIds).toEqual(originalIds);
});
it("returns empty droppedMessagesList when no pruning needed", () => {
const messages: AgentMessage[] = [makeMessage(1, 100)];
const pruned = pruneHistoryForContextShare({
messages,
maxContextTokens: 100_000,
maxHistoryShare: 0.5,
parts: 2,
});
expect(pruned.droppedChunks).toBe(0);
expect(pruned.droppedMessagesList).toEqual([]);
expect(pruned.messages.length).toBe(1);
});
});
-4
View File
@@ -301,7 +301,6 @@ export function pruneHistoryForContextShare(params: {
parts?: number;
}): {
messages: AgentMessage[];
droppedMessagesList: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
@@ -311,7 +310,6 @@ export function pruneHistoryForContextShare(params: {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
const allDroppedMessages: AgentMessage[] = [];
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
@@ -325,13 +323,11 @@ export function pruneHistoryForContextShare(params: {
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
allDroppedMessages.push(...dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
droppedMessagesList: allDroppedMessages,
droppedChunks,
droppedMessages,
droppedTokens,
+2 -9
View File
@@ -119,16 +119,9 @@ function mergeConfig(
const provider = overrides?.provider ?? defaults?.provider ?? "auto";
const defaultRemote = defaults?.remote;
const overrideRemote = overrides?.remote;
const hasRemoteConfig = Boolean(
overrideRemote?.baseUrl ||
overrideRemote?.apiKey ||
overrideRemote?.headers ||
defaultRemote?.baseUrl ||
defaultRemote?.apiKey ||
defaultRemote?.headers,
);
const hasRemote = Boolean(defaultRemote || overrideRemote);
const includeRemote =
hasRemoteConfig || provider === "openai" || provider === "gemini" || provider === "auto";
hasRemote || provider === "openai" || provider === "gemini" || provider === "auto";
const batch = {
enabled: overrideRemote?.batch?.enabled ?? defaultRemote?.batch?.enabled ?? true,
wait: overrideRemote?.batch?.wait ?? defaultRemote?.batch?.wait ?? true,
-123
View File
@@ -1,13 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { AuthProfileStore } from "./auth-profiles.js";
import { saveAuthProfileStore } from "./auth-profiles.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import { runWithModelFallback } from "./model-fallback.js";
function makeCfg(overrides: Partial<ClawdbotConfig> = {}): ClawdbotConfig {
@@ -124,122 +117,6 @@ describe("runWithModelFallback", () => {
expect(run.mock.calls[1]?.[1]).toBe("claude-haiku-3-5");
});
it("skips providers when all profiles are in cooldown", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
const provider = `cooldown-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "test-key",
},
},
usageStats: {
[profileId]: {
cooldownUntil: Date.now() + 60_000,
},
},
};
saveAuthProfileStore(store, tempDir);
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: `${provider}/m1`,
fallbacks: ["fallback/ok-model"],
},
},
},
});
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") return "ok";
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
});
try {
const result = await runWithModelFallback({
cfg,
provider,
model: "m1",
agentDir: tempDir,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe("rate_limit");
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("does not skip when any profile is available", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-auth-"));
const provider = `cooldown-mixed-${crypto.randomUUID()}`;
const profileA = `${provider}:a`;
const profileB = `${provider}:b`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileA]: {
type: "api_key",
provider,
key: "key-a",
},
[profileB]: {
type: "api_key",
provider,
key: "key-b",
},
},
usageStats: {
[profileA]: {
cooldownUntil: Date.now() + 60_000,
},
},
};
saveAuthProfileStore(store, tempDir);
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: `${provider}/m1`,
fallbacks: ["fallback/ok-model"],
},
},
},
});
const run = vi.fn().mockImplementation(async (providerId) => {
if (providerId === provider) return "ok";
return "unexpected";
});
try {
const result = await runWithModelFallback({
cfg,
provider,
model: "m1",
agentDir: tempDir,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([[provider, "m1"]]);
expect(result.attempts).toEqual([]);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("does not append configured primary when fallbacksOverride is set", async () => {
const cfg = makeCfg({
agents: {
-28
View File
@@ -14,11 +14,6 @@ import {
resolveModelRefFromString,
} from "./model-selection.js";
import type { FailoverReason } from "./pi-embedded-helpers.js";
import {
ensureAuthProfileStore,
isProfileInCooldown,
resolveAuthProfileOrder,
} from "./auth-profiles.js";
type ModelCandidate = {
provider: string;
@@ -194,7 +189,6 @@ export async function runWithModelFallback<T>(params: {
cfg: ClawdbotConfig | undefined;
provider: string;
model: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
fallbacksOverride?: string[];
run: (provider: string, model: string) => Promise<T>;
@@ -217,33 +211,11 @@ export async function runWithModelFallback<T>(params: {
model: params.model,
fallbacksOverride: params.fallbacksOverride,
});
const authStore = params.cfg
? ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false })
: null;
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (let i = 0; i < candidates.length; i += 1) {
const candidate = candidates[i] as ModelCandidate;
if (authStore) {
const profileIds = resolveAuthProfileOrder({
cfg: params.cfg,
store: authStore,
provider: candidate.provider,
});
const isAnyProfileAvailable = profileIds.some((id) => !isProfileInCooldown(authStore, id));
if (profileIds.length > 0 && !isAnyProfileAvailable) {
// All profiles for this provider are in cooldown; skip without attempting
attempts.push({
provider: candidate.provider,
model: candidate.model,
error: `Provider ${candidate.provider} is in cooldown (all profiles unavailable)`,
reason: "rate_limit",
});
continue;
}
}
try {
const result = await params.run(candidate.provider, candidate.model);
return {
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
},
browserAllowHostControl: true,
browser: {
bridgeUrl: "http://localhost:9222",
controlUrl: "http://localhost:9222",
noVncUrl: "http://localhost:6080",
containerName: "clawdbot-sbx-browser-test",
},
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
workspaceDir: "/tmp/clawdbot-sandbox",
workspaceAccess: "none",
agentWorkspaceMount: undefined,
browserBridgeUrl: "http://localhost:9222",
browserControlUrl: "http://localhost:9222",
browserNoVncUrl: "http://localhost:6080",
hostBrowserAllowed: true,
});
-1
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import type { ClawdbotConfig } from "../config/config.js";
import { ensureClawdbotModelsJson } from "./models-config.js";
@@ -7,7 +7,6 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { setCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeguard-runtime.js";
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
@@ -76,10 +75,6 @@ export function buildEmbeddedExtensionPaths(params: {
}): string[] {
const paths: string[] = [];
if (resolveCompactionMode(params.cfg) === "safeguard") {
const compactionCfg = params.cfg?.agents?.defaults?.compaction;
setCompactionSafeguardRuntime(params.sessionManager, {
maxHistoryShare: compactionCfg?.maxHistoryShare,
});
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}
const pruning = buildContextPruningExtension(params);
@@ -215,10 +215,6 @@ export async function runEmbeddedAttempt(
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
sessionKey: params.sessionKey ?? params.sessionId,
agentDir,
workspaceDir: effectiveWorkspace,
@@ -35,10 +35,6 @@ export type RunEmbeddedPiAgentParams = {
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
/** Current channel ID for auto-threading (Slack). */
currentChannelId?: string;
/** Current thread timestamp for auto-threading (Slack). */
@@ -31,10 +31,6 @@ export type EmbeddedRunAttemptParams = {
groupSpace?: string | null;
/** Parent session key for subagent policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
currentChannelId?: string;
currentThreadTs?: string;
replyToMode?: "off" | "first" | "all";
@@ -13,9 +13,12 @@ export function buildEmbeddedSandboxInfo(
workspaceDir: sandbox.workspaceDir,
workspaceAccess: sandbox.workspaceAccess,
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
browserBridgeUrl: sandbox.browser?.bridgeUrl,
browserControlUrl: sandbox.browser?.controlUrl,
browserNoVncUrl: sandbox.browser?.noVncUrl,
hostBrowserAllowed: sandbox.browserAllowHostControl,
allowedControlUrls: sandbox.browserAllowedControlUrls,
allowedControlHosts: sandbox.browserAllowedControlHosts,
allowedControlPorts: sandbox.browserAllowedControlPorts,
...(elevatedAllowed
? {
elevated: {
+4 -1
View File
@@ -69,9 +69,12 @@ export type EmbeddedSandboxInfo = {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
@@ -1,34 +0,0 @@
export type CompactionSafeguardRuntimeValue = {
maxHistoryShare?: number;
};
// Session-scoped runtime registry keyed by object identity.
// Follows the same WeakMap pattern as context-pruning/runtime.ts.
const REGISTRY = new WeakMap<object, CompactionSafeguardRuntimeValue>();
export function setCompactionSafeguardRuntime(
sessionManager: unknown,
value: CompactionSafeguardRuntimeValue | null,
): void {
if (!sessionManager || typeof sessionManager !== "object") {
return;
}
const key = sessionManager as object;
if (value === null) {
REGISTRY.delete(key);
return;
}
REGISTRY.set(key, value);
}
export function getCompactionSafeguardRuntime(
sessionManager: unknown,
): CompactionSafeguardRuntimeValue | null {
if (!sessionManager || typeof sessionManager !== "object") {
return null;
}
return REGISTRY.get(sessionManager as object) ?? null;
}
@@ -1,10 +1,6 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { describe, expect, it } from "vitest";
import {
getCompactionSafeguardRuntime,
setCompactionSafeguardRuntime,
} from "./compaction-safeguard-runtime.js";
import { __testing } from "./compaction-safeguard.js";
const {
@@ -212,41 +208,3 @@ describe("isOversizedForSummary", () => {
expect(typeof isOversized).toBe("boolean");
});
});
describe("compaction-safeguard runtime registry", () => {
it("stores and retrieves config by session manager identity", () => {
const sm = {};
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.3 });
const runtime = getCompactionSafeguardRuntime(sm);
expect(runtime).toEqual({ maxHistoryShare: 0.3 });
});
it("returns null for unknown session manager", () => {
const sm = {};
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
});
it("clears entry when value is null", () => {
const sm = {};
setCompactionSafeguardRuntime(sm, { maxHistoryShare: 0.7 });
expect(getCompactionSafeguardRuntime(sm)).not.toBeNull();
setCompactionSafeguardRuntime(sm, null);
expect(getCompactionSafeguardRuntime(sm)).toBeNull();
});
it("ignores non-object session managers", () => {
setCompactionSafeguardRuntime(null, { maxHistoryShare: 0.5 });
expect(getCompactionSafeguardRuntime(null)).toBeNull();
setCompactionSafeguardRuntime(undefined, { maxHistoryShare: 0.5 });
expect(getCompactionSafeguardRuntime(undefined)).toBeNull();
});
it("isolates different session managers", () => {
const sm1 = {};
const sm2 = {};
setCompactionSafeguardRuntime(sm1, { maxHistoryShare: 0.3 });
setCompactionSafeguardRuntime(sm2, { maxHistoryShare: 0.8 });
expect(getCompactionSafeguardRuntime(sm1)).toEqual({ maxHistoryShare: 0.3 });
expect(getCompactionSafeguardRuntime(sm2)).toEqual({ maxHistoryShare: 0.8 });
});
});
@@ -11,7 +11,6 @@ import {
resolveContextWindowTokens,
summarizeInStages,
} from "../compaction.js";
import { getCompactionSafeguardRuntime } from "./compaction-safeguard-runtime.js";
const FALLBACK_SUMMARY =
"Summary unavailable due to context limits. Older messages were truncated.";
const TURN_PREFIX_INSTRUCTIONS =
@@ -175,28 +174,21 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
const turnPrefixMessages = preparation.turnPrefixMessages ?? [];
let messagesToSummarize = preparation.messagesToSummarize;
const runtime = getCompactionSafeguardRuntime(ctx.sessionManager);
const maxHistoryShare = runtime?.maxHistoryShare ?? 0.5;
const tokensBefore =
typeof preparation.tokensBefore === "number" && Number.isFinite(preparation.tokensBefore)
? preparation.tokensBefore
: undefined;
let droppedSummary: string | undefined;
if (tokensBefore !== undefined) {
const summarizableTokens =
estimateMessagesTokens(messagesToSummarize) + estimateMessagesTokens(turnPrefixMessages);
const newContentTokens = Math.max(0, Math.floor(tokensBefore - summarizableTokens));
// Apply SAFETY_MARGIN so token underestimates don't trigger unnecessary pruning
const maxHistoryTokens = Math.floor(contextWindowTokens * maxHistoryShare * SAFETY_MARGIN);
const maxHistoryTokens = Math.floor(contextWindowTokens * 0.5);
if (newContentTokens > maxHistoryTokens) {
const pruned = pruneHistoryForContextShare({
messages: messagesToSummarize,
maxContextTokens: contextWindowTokens,
maxHistoryShare,
maxHistoryShare: 0.5,
parts: 2,
});
if (pruned.droppedChunks > 0) {
@@ -208,37 +200,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
`(${pruned.droppedMessages} messages) to fit history budget.`,
);
messagesToSummarize = pruned.messages;
// Summarize dropped messages so context isn't lost
if (pruned.droppedMessagesList.length > 0) {
try {
const droppedChunkRatio = computeAdaptiveChunkRatio(
pruned.droppedMessagesList,
contextWindowTokens,
);
const droppedMaxChunkTokens = Math.max(
1,
Math.floor(contextWindowTokens * droppedChunkRatio),
);
droppedSummary = await summarizeInStages({
messages: pruned.droppedMessagesList,
model,
apiKey,
signal,
reserveTokens: Math.max(1, Math.floor(preparation.settings.reserveTokens)),
maxChunkTokens: droppedMaxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions,
previousSummary: preparation.previousSummary,
});
} catch (droppedError) {
console.warn(
`Compaction safeguard: failed to summarize dropped messages, continuing without: ${
droppedError instanceof Error ? droppedError.message : String(droppedError)
}`,
);
}
}
}
}
}
@@ -249,10 +210,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
const maxChunkTokens = Math.max(1, Math.floor(contextWindowTokens * adaptiveRatio));
const reserveTokens = Math.max(1, Math.floor(preparation.settings.reserveTokens));
// Feed dropped-messages summary as previousSummary so the main summarization
// incorporates context from pruned messages instead of losing it entirely.
const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary;
const historySummary = await summarizeInStages({
messages: messagesToSummarize,
model,
@@ -262,7 +219,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void {
maxChunkTokens,
contextWindow: contextWindowTokens,
customInstructions,
previousSummary: effectivePreviousSummary,
previousSummary: preparation.previousSummary,
});
let summary = historySummary;
-70
View File
@@ -1,5 +1,4 @@
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import type { ClawdbotConfig } from "../config/config.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
import type { SandboxDockerConfig } from "./sandbox.js";
@@ -271,75 +270,6 @@ describe("Agent-specific tool filtering", () => {
expect(defaultNames).not.toContain("exec");
});
it("should apply per-sender tool policies for group tools", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
groups: {
"*": {
tools: { allow: ["read"] },
toolsBySender: {
alice: { allow: ["read", "exec"] },
},
},
},
},
},
};
const aliceTools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:whatsapp:group:family",
senderId: "alice",
workspaceDir: "/tmp/test-group-sender",
agentDir: "/tmp/agent-group-sender",
});
const aliceNames = aliceTools.map((t) => t.name);
expect(aliceNames).toContain("read");
expect(aliceNames).toContain("exec");
const bobTools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:whatsapp:group:family",
senderId: "bob",
workspaceDir: "/tmp/test-group-sender-bob",
agentDir: "/tmp/agent-group-sender",
});
const bobNames = bobTools.map((t) => t.name);
expect(bobNames).toContain("read");
expect(bobNames).not.toContain("exec");
});
it("should not let default sender policy override group tools", () => {
const cfg: ClawdbotConfig = {
channels: {
whatsapp: {
groups: {
"*": {
toolsBySender: {
admin: { allow: ["read", "exec"] },
},
},
locked: {
tools: { allow: ["read"] },
},
},
},
},
};
const adminTools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:whatsapp:group:locked",
senderId: "admin",
workspaceDir: "/tmp/test-group-default-override",
agentDir: "/tmp/agent-group-default-override",
});
const adminNames = adminTools.map((t) => t.name);
expect(adminNames).toContain("read");
expect(adminNames).not.toContain("exec");
});
it("should resolve telegram group tool policy for topic session keys", () => {
const cfg: ClawdbotConfig = {
channels: {
@@ -96,6 +96,7 @@ describe("createClawdbotCodingTools", () => {
};
expect(parameters.properties?.action).toBeDefined();
expect(parameters.properties?.target).toBeDefined();
expect(parameters.properties?.controlUrl).toBeDefined();
expect(parameters.properties?.targetUrl).toBeDefined();
expect(parameters.properties?.request).toBeDefined();
expect(parameters.required ?? []).toContain("action");
-12
View File
@@ -233,10 +233,6 @@ export function resolveGroupToolPolicy(params: {
groupChannel?: string | null;
groupSpace?: string | null;
accountId?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: string | null;
}): SandboxToolPolicy | undefined {
if (!params.config) return undefined;
const sessionContext = resolveGroupContextFromSessionKey(params.sessionKey);
@@ -259,20 +255,12 @@ export function resolveGroupToolPolicy(params: {
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
}) ??
resolveChannelGroupToolsPolicy({
cfg: params.config,
channel,
groupId,
accountId: params.accountId,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
return pickToolPolicy(toolsConfig);
}
+4 -9
View File
@@ -140,10 +140,6 @@ export function createClawdbotCodingTools(options?: {
groupSpace?: string | null;
/** Parent session key for subagent group policy inheritance. */
spawnedBy?: string | null;
senderId?: string | null;
senderName?: string | null;
senderUsername?: string | null;
senderE164?: 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). */
@@ -178,10 +174,6 @@ export function createClawdbotCodingTools(options?: {
groupChannel: options?.groupChannel,
groupSpace: options?.groupSpace,
accountId: options?.agentAccountId,
senderId: options?.senderId,
senderName: options?.senderName,
senderUsername: options?.senderUsername,
senderE164: options?.senderE164,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
@@ -302,8 +294,11 @@ export function createClawdbotCodingTools(options?: {
// Channel docking: include channel-defined agent tools (login, etc.).
...listChannelAgentTools({ cfg: options?.config }),
...createClawdbotTools({
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
browserControlUrl: sandbox?.browser?.controlUrl,
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
allowedControlUrls: sandbox?.browserAllowedControlUrls,
allowedControlHosts: sandbox?.browserAllowedControlHosts,
allowedControlPorts: sandbox?.browserAllowedControlPorts,
agentSessionKey: options?.sessionKey,
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
agentAccountId: options?.agentAccountId,
+6 -9
View File
@@ -1,9 +1,6 @@
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../../browser/bridge-server.js";
import { type ResolvedBrowserConfig, resolveProfile } from "../../browser/config.js";
import {
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_CLAWD_BROWSER_COLOR,
} from "../../browser/constants.js";
import { DEFAULT_CLAWD_BROWSER_COLOR } from "../../browser/constants.js";
import { BROWSER_BRIDGES } from "./browser-bridges.js";
import { DEFAULT_SANDBOX_BROWSER_IMAGE, SANDBOX_AGENT_WORKSPACE_MOUNT } from "./constants.js";
import {
@@ -42,12 +39,14 @@ function buildSandboxBrowserResolvedConfig(params: {
controlPort: number;
cdpPort: number;
headless: boolean;
evaluateEnabled: boolean;
}): ResolvedBrowserConfig {
const controlHost = "127.0.0.1";
const controlUrl = `http://${controlHost}:${params.controlPort}`;
const cdpHost = "127.0.0.1";
return {
enabled: true,
evaluateEnabled: params.evaluateEnabled,
controlUrl,
controlHost,
controlPort: params.controlPort,
cdpProtocol: "http",
cdpHost,
@@ -81,7 +80,6 @@ export async function ensureSandboxBrowser(params: {
workspaceDir: string;
agentWorkspaceDir: string;
cfg: SandboxConfig;
evaluateEnabled?: boolean;
}): Promise<SandboxBrowserContext | null> {
if (!params.cfg.browser.enabled) return null;
if (!isToolAllowed(params.cfg.tools, "browser")) return null;
@@ -176,7 +174,6 @@ export async function ensureSandboxBrowser(params: {
controlPort: 0,
cdpPort: mappedCdp,
headless: params.cfg.browser.headless,
evaluateEnabled: params.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED,
}),
onEnsureAttachTarget,
});
@@ -207,7 +204,7 @@ export async function ensureSandboxBrowser(params: {
: undefined;
return {
bridgeUrl: resolvedBridge.baseUrl,
controlUrl: resolvedBridge.baseUrl,
noVncUrl,
containerName,
};
+17
View File
@@ -86,6 +86,11 @@ export function resolveSandboxBrowserConfig(params: {
}): SandboxBrowserConfig {
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
const globalBrowser = params.globalBrowser;
const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
const allowedControlHosts =
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
const allowedControlPorts =
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
return {
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
@@ -100,6 +105,18 @@ export function resolveSandboxBrowserConfig(params: {
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
allowedControlUrls:
Array.isArray(allowedControlUrls) && allowedControlUrls.length > 0
? allowedControlUrls
: undefined,
allowedControlHosts:
Array.isArray(allowedControlHosts) && allowedControlHosts.length > 0
? allowedControlHosts
: undefined,
allowedControlPorts:
Array.isArray(allowedControlPorts) && allowedControlPorts.length > 0
? allowedControlPorts
: undefined,
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
autoStartTimeoutMs:
agentBrowser?.autoStartTimeoutMs ??
+3 -4
View File
@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import type { ClawdbotConfig } from "../../config/config.js";
import { defaultRuntime } from "../../runtime.js";
import { resolveUserPath } from "../../utils.js";
import { DEFAULT_BROWSER_EVALUATE_ENABLED } from "../../browser/constants.js";
import { syncSkillsToWorkspace } from "../skills.js";
import { DEFAULT_AGENT_WORKSPACE_DIR } from "../workspace.js";
import { ensureSandboxBrowser } from "./browser.js";
@@ -70,14 +69,11 @@ export async function resolveSandboxContext(params: {
cfg,
});
const evaluateEnabled =
params.config?.browser?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
const browser = await ensureSandboxBrowser({
scopeKey,
workspaceDir,
agentWorkspaceDir,
cfg,
evaluateEnabled,
});
return {
@@ -91,6 +87,9 @@ export async function resolveSandboxContext(params: {
docker: cfg.docker,
tools: cfg.tools,
browserAllowHostControl: cfg.browser.allowHostControl,
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
browser: browser ?? undefined,
};
}
+7 -1
View File
@@ -37,6 +37,9 @@ export type SandboxBrowserConfig = {
headless: boolean;
enableNoVnc: boolean;
allowHostControl: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
autoStart: boolean;
autoStartTimeoutMs: number;
};
@@ -60,7 +63,7 @@ export type SandboxConfig = {
};
export type SandboxBrowserContext = {
bridgeUrl: string;
controlUrl: string;
noVncUrl?: string;
containerName: string;
};
@@ -76,6 +79,9 @@ export type SandboxContext = {
docker: SandboxDockerConfig;
tools: SandboxToolPolicy;
browserAllowHostControl: boolean;
browserAllowedControlUrls?: string[];
browserAllowedControlHosts?: string[];
browserAllowedControlPorts?: number[];
browser?: SandboxBrowserContext;
};
+1 -1
View File
@@ -104,7 +104,7 @@ describe("acquireSessionWriteLock", () => {
});
it("cleans up locks on SIGINT without removing other handlers", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
const originalKill = process.kill.bind(process) as typeof process.kill;
const originalKill = process.kill.bind(process);
const killCalls: Array<NodeJS.Signals | undefined> = [];
let otherHandlerCalled = false;
-1
View File
@@ -6,7 +6,6 @@ import type { SkillEligibilityContext, SkillEntry } from "./types.js";
const DEFAULT_CONFIG_VALUES: Record<string, boolean> = {
"browser.enabled": true,
"browser.evaluateEnabled": true,
};
function isTruthy(value: unknown): boolean {
+16 -2
View File
@@ -165,9 +165,12 @@ export function buildAgentSystemPrompt(params: {
workspaceDir?: string;
workspaceAccess?: "none" | "ro" | "rw";
agentWorkspaceMount?: string;
browserBridgeUrl?: string;
browserControlUrl?: string;
browserNoVncUrl?: string;
hostBrowserAllowed?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
elevated?: {
allowed: boolean;
defaultLevel: "on" | "off" | "ask" | "full";
@@ -416,7 +419,9 @@ export function buildAgentSystemPrompt(params: {
: ""
}`
: "",
params.sandboxInfo.browserBridgeUrl ? "Sandbox browser: enabled." : "",
params.sandboxInfo.browserControlUrl
? `Sandbox browser control URL: ${params.sandboxInfo.browserControlUrl}`
: "",
params.sandboxInfo.browserNoVncUrl
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
: "",
@@ -425,6 +430,15 @@ export function buildAgentSystemPrompt(params: {
: params.sandboxInfo.hostBrowserAllowed === false
? "Host browser control: blocked."
: "",
params.sandboxInfo.allowedControlUrls?.length
? `Browser control URL allowlist: ${params.sandboxInfo.allowedControlUrls.join(", ")}`
: "",
params.sandboxInfo.allowedControlHosts?.length
? `Browser control host allowlist: ${params.sandboxInfo.allowedControlHosts.join(", ")}`
: "",
params.sandboxInfo.allowedControlPorts?.length
? `Browser control port allowlist: ${params.sandboxInfo.allowedControlPorts.join(", ")}`
: "",
params.sandboxInfo.elevated?.allowed
? "Elevated exec is available for this session."
: "",
+2 -1
View File
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
"act",
] as const;
const BROWSER_TARGETS = ["sandbox", "host", "node"] as const;
const BROWSER_TARGETS = ["sandbox", "host", "custom", "node"] as const;
const BROWSER_SNAPSHOT_FORMATS = ["aria", "ai"] as const;
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
@@ -86,6 +86,7 @@ export const BrowserToolSchema = Type.Object({
target: optionalStringEnum(BROWSER_TARGETS),
node: Type.Optional(Type.String()),
profile: Type.Optional(Type.String()),
controlUrl: Type.Optional(Type.String()),
targetUrl: Type.Optional(Type.String()),
targetId: Type.Optional(Type.String()),
limit: Type.Optional(Type.Number()),
+26 -10
View File
@@ -28,7 +28,23 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
const browserConfigMocks = vi.hoisted(() => ({
resolveBrowserConfig: vi.fn(() => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
controlHost: "127.0.0.1",
controlPort: 18791,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF0000",
headless: true,
noSandbox: false,
attachOnly: false,
defaultProfile: "clawd",
profiles: {
clawd: {
cdpPort: 18792,
color: "#FF0000",
},
},
})),
}));
vi.mock("../../browser/config.js", () => browserConfigMocks);
@@ -83,7 +99,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
@@ -101,7 +117,7 @@ describe("browser tool snapshot maxChars", () => {
});
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
maxChars: override,
}),
@@ -125,7 +141,7 @@ describe("browser tool snapshot maxChars", () => {
const tool = createBrowserTool();
await tool.execute?.(null, { action: "profiles" });
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith(undefined);
expect(browserClientMocks.browserProfiles).toHaveBeenCalledWith("http://127.0.0.1:18791");
});
it("passes refs mode through to browser snapshot", async () => {
@@ -133,7 +149,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
format: "ai",
refs: "aria",
@@ -149,7 +165,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
mode: "efficient",
}),
@@ -169,11 +185,11 @@ describe("browser tool snapshot maxChars", () => {
});
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({
profile: "chrome",
}),
@@ -204,7 +220,7 @@ describe("browser tool snapshot maxChars", () => {
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
});
it("keeps sandbox bridge url when node proxy is available", async () => {
it("keeps sandbox control url when node proxy is available", async () => {
nodesUtilsMocks.listNodes.mockResolvedValue([
{
nodeId: "node-1",
@@ -214,7 +230,7 @@ describe("browser tool snapshot maxChars", () => {
commands: ["browser.proxy"],
},
]);
const tool = createBrowserTool({ sandboxBridgeUrl: "http://127.0.0.1:9999" });
const tool = createBrowserTool({ defaultControlUrl: "http://127.0.0.1:9999" });
await tool.execute?.(null, { action: "status" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
@@ -238,7 +254,7 @@ describe("browser tool snapshot maxChars", () => {
await tool.execute?.(null, { action: "status", profile: "chrome" });
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
undefined,
"http://127.0.0.1:18791",
expect.objectContaining({ profile: "chrome" }),
);
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
+99 -22
View File
@@ -55,8 +55,9 @@ function isBrowserNode(node: NodeListNode) {
async function resolveBrowserNodeTarget(params: {
requestedNode?: string;
target?: "sandbox" | "host" | "node";
sandboxBridgeUrl?: string;
target?: "sandbox" | "host" | "custom" | "node";
controlUrl?: string;
defaultControlUrl?: string;
}): Promise<BrowserNodeTarget | null> {
const cfg = loadConfig();
const policy = cfg.gateway?.nodes?.browser;
@@ -67,9 +68,10 @@ async function resolveBrowserNodeTarget(params: {
}
return null;
}
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
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;
@@ -185,22 +187,70 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
}
function resolveBrowserBaseUrl(params: {
target?: "sandbox" | "host";
sandboxBridgeUrl?: string;
target?: "sandbox" | "host" | "custom";
controlUrl?: string;
defaultControlUrl?: string;
allowHostControl?: boolean;
}): string | undefined {
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
const resolved = resolveBrowserConfig(cfg.browser);
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
const normalizedDefault = params.defaultControlUrl?.trim() ?? "";
const target =
params.target ?? (normalizedControlUrl ? "custom" : normalizedDefault ? "sandbox" : "host");
const assertAllowedControlUrl = (url: string) => {
const allowedUrls = params.allowedControlUrls?.map((entry) => entry.trim().replace(/\/$/, ""));
const allowedHosts = params.allowedControlHosts?.map((entry) => entry.trim().toLowerCase());
const allowedPorts = params.allowedControlPorts;
if (!allowedUrls?.length && !allowedHosts?.length && !allowedPorts?.length) {
return;
}
let parsed: URL;
try {
parsed = new URL(url);
} catch {
throw new Error(`Invalid browser controlUrl: ${url}`);
}
const normalizedUrl = parsed.toString().replace(/\/$/, "");
if (allowedUrls?.length && !allowedUrls.includes(normalizedUrl)) {
throw new Error("Browser controlUrl is not in the allowed URL list.");
}
if (allowedHosts?.length && !allowedHosts.includes(parsed.hostname)) {
throw new Error("Browser controlUrl hostname is not in the allowed host list.");
}
if (allowedPorts?.length) {
const port =
parsed.port?.trim() !== "" ? Number(parsed.port) : parsed.protocol === "https:" ? 443 : 80;
if (!Number.isFinite(port) || !allowedPorts.includes(port)) {
throw new Error("Browser controlUrl port is not in the allowed port list.");
}
}
};
if (target !== "custom" && params.target && normalizedControlUrl) {
throw new Error('controlUrl is only supported with target="custom".');
}
if (target === "custom") {
if (!normalizedControlUrl) {
throw new Error("Custom browser target requires controlUrl.");
}
const normalized = normalizedControlUrl.replace(/\/$/, "");
assertAllowedControlUrl(normalized);
return normalized;
}
if (target === "sandbox") {
if (!normalizedSandbox) {
if (!normalizedDefault) {
throw new Error(
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
);
}
return normalizedSandbox.replace(/\/$/, "");
return normalizedDefault.replace(/\/$/, "");
}
if (params.allowHostControl === false) {
@@ -211,16 +261,27 @@ function resolveBrowserBaseUrl(params: {
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
);
}
return undefined;
const normalized = resolved.controlUrl.replace(/\/$/, "");
assertAllowedControlUrl(normalized);
return normalized;
}
export function createBrowserTool(opts?: {
sandboxBridgeUrl?: string;
defaultControlUrl?: string;
allowHostControl?: boolean;
allowedControlUrls?: string[];
allowedControlHosts?: string[];
allowedControlPorts?: number[];
}): AnyAgentTool {
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
const hostHint =
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
const allowlistHint =
opts?.allowedControlUrls?.length ||
opts?.allowedControlHosts?.length ||
opts?.allowedControlPorts?.length
? "Custom targets are restricted by sandbox allowlists."
: "Custom targets are unrestricted.";
return {
label: "Browser",
name: "browser",
@@ -233,22 +294,33 @@ export function createBrowserTool(opts?: {
"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|node). Default: ${targetDefault}.`,
`target selects browser location (sandbox|host|custom|node). Default: ${targetDefault}.`,
"controlUrl implies target=custom (remote control server).",
hostHint,
allowlistHint,
].join(" "),
parameters: BrowserToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
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" | "node" | undefined;
let target = readStringParam(params, "target") as
| "sandbox"
| "host"
| "custom"
| "node"
| undefined;
if (requestedNode && target && target !== "node") {
throw new Error('node is only supported with target="node".');
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 && !requestedNode && profile === "chrome") {
if (!target && !controlUrl?.trim() && !requestedNode && profile === "chrome") {
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
target = "host";
}
@@ -256,16 +328,21 @@ export function createBrowserTool(opts?: {
const nodeTarget = await resolveBrowserNodeTarget({
requestedNode: requestedNode ?? undefined,
target,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
});
const resolvedTarget = target === "node" ? undefined : target;
const baseUrl = nodeTarget
? undefined
? ""
: resolveBrowserBaseUrl({
target: resolvedTarget,
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
controlUrl,
defaultControlUrl: opts?.defaultControlUrl,
allowHostControl: opts?.allowHostControl,
allowedControlUrls: opts?.allowedControlUrls,
allowedControlHosts: opts?.allowedControlHosts,
allowedControlPorts: opts?.allowedControlPorts,
});
const proxyRequest = nodeTarget
-40
View File
@@ -8,17 +8,12 @@ const sendMessageTelegram = vi.fn(async () => ({
messageId: "789",
chatId: "123",
}));
const sendStickerTelegram = vi.fn(async () => ({
messageId: "456",
chatId: "123",
}));
const deleteMessageTelegram = vi.fn(async () => ({ ok: true }));
const originalToken = process.env.TELEGRAM_BOT_TOKEN;
vi.mock("../../telegram/send.js", () => ({
reactMessageTelegram: (...args: unknown[]) => reactMessageTelegram(...args),
sendMessageTelegram: (...args: unknown[]) => sendMessageTelegram(...args),
sendStickerTelegram: (...args: unknown[]) => sendStickerTelegram(...args),
deleteMessageTelegram: (...args: unknown[]) => deleteMessageTelegram(...args),
}));
@@ -26,7 +21,6 @@ describe("handleTelegramAction", () => {
beforeEach(() => {
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
sendStickerTelegram.mockClear();
deleteMessageTelegram.mockClear();
process.env.TELEGRAM_BOT_TOKEN = "tok";
});
@@ -102,40 +96,6 @@ describe("handleTelegramAction", () => {
);
});
it("rejects sticker actions when disabled by default", async () => {
const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
await expect(
handleTelegramAction(
{
action: "sendSticker",
to: "123",
fileId: "sticker",
},
cfg,
),
).rejects.toThrow(/sticker actions are disabled/i);
expect(sendStickerTelegram).not.toHaveBeenCalled();
});
it("sends stickers when enabled", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", actions: { sticker: true } } },
} as ClawdbotConfig;
await handleTelegramAction(
{
action: "sendSticker",
to: "123",
fileId: "sticker",
},
cfg,
);
expect(sendStickerTelegram).toHaveBeenCalledWith(
"123",
"sticker",
expect.objectContaining({ token: "tok" }),
);
});
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
-61
View File
@@ -6,9 +6,7 @@ import {
editMessageTelegram,
reactMessageTelegram,
sendMessageTelegram,
sendStickerTelegram,
} from "../../telegram/send.js";
import { getCacheStats, searchStickers } from "../../telegram/sticker-cache.js";
import { resolveTelegramToken } from "../../telegram/token.js";
import {
resolveTelegramInlineButtonsScope,
@@ -257,64 +255,5 @@ export async function handleTelegramAction(
});
}
if (action === "sendSticker") {
if (!isActionEnabled("sticker", false)) {
throw new Error(
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
);
}
const to = readStringParam(params, "to", { required: true });
const fileId = readStringParam(params, "fileId", { required: true });
const replyToMessageId = readNumberParam(params, "replyToMessageId", {
integer: true,
});
const messageThreadId = readNumberParam(params, "messageThreadId", {
integer: true,
});
const token = resolveTelegramToken(cfg, { accountId }).token;
if (!token) {
throw new Error(
"Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.",
);
}
const result = await sendStickerTelegram(to, fileId, {
token,
accountId: accountId ?? undefined,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
});
return jsonResult({
ok: true,
messageId: result.messageId,
chatId: result.chatId,
});
}
if (action === "searchSticker") {
if (!isActionEnabled("sticker", false)) {
throw new Error(
"Telegram sticker actions are disabled. Set channels.telegram.actions.sticker to true.",
);
}
const query = readStringParam(params, "query", { required: true });
const limit = readNumberParam(params, "limit", { integer: true }) ?? 5;
const results = searchStickers(query, limit);
return jsonResult({
ok: true,
count: results.length,
stickers: results.map((s) => ({
fileId: s.fileId,
emoji: s.emoji,
description: s.description,
setName: s.setName,
})),
});
}
if (action === "stickerCacheStats") {
const stats = getCacheStats();
return jsonResult({ ok: true, ...stats });
}
throw new Error(`Unsupported Telegram action: ${action}`);
}
@@ -138,7 +138,6 @@ export async function runAgentTurnWithFallback(params: {
cfg: params.followupRun.run.config,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentDir: params.followupRun.run.agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
@@ -232,10 +231,6 @@ export async function runAgentTurnWithFallback(params: {
groupChannel:
params.sessionCtx.GroupChannel?.trim() ?? params.sessionCtx.GroupSubject?.trim(),
groupSpace: params.sessionCtx.GroupSpace?.trim() ?? undefined,
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
// Provider threading context for tool auto-injection
...buildThreadingToolContext({
sessionCtx: params.sessionCtx,
@@ -92,7 +92,6 @@ export async function runMemoryFlushIfNeeded(params: {
cfg: params.followupRun.run.config,
provider: params.followupRun.run.provider,
model: params.followupRun.run.model,
agentDir: params.followupRun.run.agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(
params.followupRun.run.config,
resolveAgentIdFromSessionKey(params.followupRun.run.sessionKey),
@@ -115,10 +114,6 @@ export async function runMemoryFlushIfNeeded(params: {
config: params.followupRun.run.config,
hasRepliedRef: params.opts?.hasRepliedRef,
}),
senderId: params.sessionCtx.SenderId?.trim() || undefined,
senderName: params.sessionCtx.SenderName?.trim() || undefined,
senderUsername: params.sessionCtx.SenderUsername?.trim() || undefined,
senderE164: params.sessionCtx.SenderE164?.trim() || undefined,
sessionFile: params.followupRun.run.sessionFile,
workspaceDir: params.followupRun.run.workspaceDir,
agentDir: params.followupRun.run.agentDir,
-5
View File
@@ -129,7 +129,6 @@ export function createFollowupRunner(params: {
cfg: queued.run.config,
provider: queued.run.provider,
model: queued.run.model,
agentDir: queued.run.agentDir,
fallbacksOverride: resolveAgentModelFallbacksOverride(
queued.run.config,
resolveAgentIdFromSessionKey(queued.run.sessionKey),
@@ -147,10 +146,6 @@ export function createFollowupRunner(params: {
groupId: queued.run.groupId,
groupChannel: queued.run.groupChannel,
groupSpace: queued.run.groupSpace,
senderId: queued.run.senderId,
senderName: queued.run.senderName,
senderUsername: queued.run.senderUsername,
senderE164: queued.run.senderE164,
sessionFile: queued.run.sessionFile,
workspaceDir: queued.run.workspaceDir,
config: queued.run.config,
-4
View File
@@ -370,10 +370,6 @@ export async function runPreparedReply(
groupId: resolveGroupSessionKey(sessionCtx)?.id ?? undefined,
groupChannel: sessionCtx.GroupChannel?.trim() ?? sessionCtx.GroupSubject?.trim(),
groupSpace: sessionCtx.GroupSpace?.trim() ?? undefined,
senderId: sessionCtx.SenderId?.trim() || undefined,
senderName: sessionCtx.SenderName?.trim() || undefined,
senderUsername: sessionCtx.SenderUsername?.trim() || undefined,
senderE164: sessionCtx.SenderE164?.trim() || undefined,
sessionFile,
workspaceDir,
config: cfg,
-4
View File
@@ -51,10 +51,6 @@ export type FollowupRun = {
groupId?: string;
groupChannel?: string;
groupSpace?: string;
senderId?: string;
senderName?: string;
senderUsername?: string;
senderE164?: string;
sessionFile: string;
workspaceDir: string;
config: ClawdbotConfig;
-3
View File
@@ -1,5 +1,4 @@
import type { ChannelId } from "../channels/plugins/types.js";
import type { StickerMetadata } from "../telegram/bot/types.js";
import type { InternalMessageChannel } from "../utils/message-channel.js";
import type { CommandArgs } from "./commands-registry.types.js";
import type {
@@ -65,8 +64,6 @@ export type MsgContext = {
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
/** Telegram sticker metadata (emoji, set name, file IDs, cached description). */
Sticker?: StickerMetadata;
OutputDir?: string;
OutputBase?: string;
/** Remote host for SCP when media lives on a different machine (e.g., clawdbot@192.168.64.3). */
+4 -3
View File
@@ -4,7 +4,6 @@ import express from "express";
import type { ResolvedBrowserConfig } from "./config.js";
import { registerBrowserRoutes } from "./routes/index.js";
import type { BrowserRouteRegistrar } from "./routes/types.js";
import {
type BrowserServerState,
createBrowserRouteContext,
@@ -51,7 +50,7 @@ export async function startBrowserBridgeServer(params: {
getState: () => state,
onEnsureAttachTarget: params.onEnsureAttachTarget,
});
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
registerBrowserRoutes(app, ctx);
const server = await new Promise<Server>((resolve, reject) => {
const s = app.listen(port, host, () => resolve(s));
@@ -62,9 +61,11 @@ export async function startBrowserBridgeServer(params: {
const resolvedPort = address?.port ?? port;
state.server = server;
state.port = resolvedPort;
state.resolved.controlHost = host;
state.resolved.controlPort = resolvedPort;
state.resolved.controlUrl = `http://${host}:${resolvedPort}`;
const baseUrl = `http://${host}:${resolvedPort}`;
const baseUrl = state.resolved.controlUrl;
return { server, port: resolvedPort, baseUrl, state };
}
+15 -25
View File
@@ -9,12 +9,6 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
return `${trimmed.replace(/\/$/, "")}${path}`;
}
export type BrowserFormField = {
ref: string;
type: string;
@@ -98,15 +92,11 @@ export type BrowserDownloadPayload = {
};
export async function browserNavigate(
baseUrl: string | undefined,
opts: {
url: string;
targetId?: string;
profile?: string;
},
baseUrl: string,
opts: { url: string; targetId?: string; profile?: string },
): Promise<BrowserActionTabResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
@@ -115,7 +105,7 @@ export async function browserNavigate(
}
export async function browserArmDialog(
baseUrl: string | undefined,
baseUrl: string,
opts: {
accept: boolean;
promptText?: string;
@@ -125,7 +115,7 @@ export async function browserArmDialog(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -139,7 +129,7 @@ export async function browserArmDialog(
}
export async function browserArmFileChooser(
baseUrl: string | undefined,
baseUrl: string,
opts: {
paths: string[];
ref?: string;
@@ -151,7 +141,7 @@ export async function browserArmFileChooser(
},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/file-chooser${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/file-chooser${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -167,7 +157,7 @@ export async function browserArmFileChooser(
}
export async function browserWaitForDownload(
baseUrl: string | undefined,
baseUrl: string,
opts: {
path?: string;
targetId?: string;
@@ -180,7 +170,7 @@ export async function browserWaitForDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
}>(`${baseUrl}/wait/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -193,7 +183,7 @@ export async function browserWaitForDownload(
}
export async function browserDownload(
baseUrl: string | undefined,
baseUrl: string,
opts: {
ref: string;
path: string;
@@ -207,7 +197,7 @@ export async function browserDownload(
ok: true;
targetId: string;
download: BrowserDownloadPayload;
}>(withBaseUrl(baseUrl, `/download${q}`), {
}>(`${baseUrl}/download${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -221,12 +211,12 @@ export async function browserDownload(
}
export async function browserAct(
baseUrl: string | undefined,
baseUrl: string,
req: BrowserActRequest,
opts?: { profile?: string },
): Promise<BrowserActResponse> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
@@ -235,7 +225,7 @@ export async function browserAct(
}
export async function browserScreenshotAction(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
fullPage?: boolean;
@@ -246,7 +236,7 @@ export async function browserScreenshotAction(
},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+16 -22
View File
@@ -10,14 +10,8 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
return `${trimmed.replace(/\/$/, "")}${path}`;
}
export async function browserConsoleMessages(
baseUrl: string | undefined,
baseUrl: string,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
const q = new URLSearchParams();
@@ -29,15 +23,15 @@ export async function browserConsoleMessages(
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
}
export async function browserPdfSave(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -46,7 +40,7 @@ export async function browserPdfSave(
}
export async function browserPageErrors(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
const q = new URLSearchParams();
@@ -58,11 +52,11 @@ export async function browserPageErrors(
ok: true;
targetId: string;
errors: BrowserPageError[];
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
}
export async function browserRequests(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
filter?: string;
@@ -80,11 +74,11 @@ export async function browserRequests(
ok: true;
targetId: string;
requests: BrowserNetworkRequest[];
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
}
export async function browserTraceStart(
baseUrl: string | undefined,
baseUrl: string,
opts: {
targetId?: string;
screenshots?: boolean;
@@ -94,7 +88,7 @@ export async function browserTraceStart(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -108,11 +102,11 @@ export async function browserTraceStart(
}
export async function browserTraceStop(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; path?: string; profile?: string } = {},
): Promise<BrowserActionPathResult> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
@@ -121,11 +115,11 @@ export async function browserTraceStop(
}
export async function browserHighlight(
baseUrl: string | undefined,
baseUrl: string,
opts: { ref: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
@@ -134,7 +128,7 @@ export async function browserHighlight(
}
export async function browserResponseBody(
baseUrl: string | undefined,
baseUrl: string,
opts: {
url: string;
targetId?: string;
@@ -164,7 +158,7 @@ export async function browserResponseBody(
body: string;
truncated?: boolean;
};
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
}>(`${baseUrl}/response/body${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+61 -76
View File
@@ -5,14 +5,8 @@ function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
return `${trimmed.replace(/\/$/, "")}${path}`;
}
export async function browserCookies(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
const q = new URLSearchParams();
@@ -23,11 +17,11 @@ export async function browserCookies(
ok: true;
targetId: string;
cookies: unknown[];
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
}
export async function browserCookiesSet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
cookie: Record<string, unknown>;
targetId?: string;
@@ -35,7 +29,7 @@ export async function browserCookiesSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
@@ -44,11 +38,11 @@ export async function browserCookiesSet(
}
export async function browserCookiesClear(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId }),
@@ -57,7 +51,7 @@ export async function browserCookiesClear(
}
export async function browserStorageGet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
kind: "local" | "session";
key?: string;
@@ -74,11 +68,11 @@ export async function browserStorageGet(
ok: true;
targetId: string;
values: Record<string, string>;
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
}
export async function browserStorageSet(
baseUrl: string | undefined,
baseUrl: string,
opts: {
kind: "local" | "session";
key: string;
@@ -88,28 +82,25 @@ export async function browserStorageSet(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/storage/${opts.kind}/set${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
key: opts.key,
value: opts.value,
}),
timeoutMs: 20000,
});
}
export async function browserStorageClear(
baseUrl: string | undefined,
baseUrl: string,
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
`${baseUrl}/storage/${opts.kind}/clear${q}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -120,11 +111,11 @@ export async function browserStorageClear(
}
export async function browserSetOffline(
baseUrl: string | undefined,
baseUrl: string,
opts: { offline: boolean; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
@@ -133,7 +124,7 @@ export async function browserSetOffline(
}
export async function browserSetHeaders(
baseUrl: string | undefined,
baseUrl: string,
opts: {
headers: Record<string, string>;
targetId?: string;
@@ -141,7 +132,7 @@ export async function browserSetHeaders(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
@@ -150,7 +141,7 @@ export async function browserSetHeaders(
}
export async function browserSetHttpCredentials(
baseUrl: string | undefined,
baseUrl: string,
opts: {
username?: string;
password?: string;
@@ -160,24 +151,21 @@ export async function browserSetHttpCredentials(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/credentials${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/credentials${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
username: opts.username,
password: opts.password,
clear: opts.clear,
}),
timeoutMs: 20000,
});
}
export async function browserSetGeolocation(
baseUrl: string | undefined,
baseUrl: string,
opts: {
latitude?: number;
longitude?: number;
@@ -189,26 +177,23 @@ export async function browserSetGeolocation(
} = {},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(
withBaseUrl(baseUrl, `/set/geolocation${q}`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/geolocation${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
targetId: opts.targetId,
latitude: opts.latitude,
longitude: opts.longitude,
accuracy: opts.accuracy,
origin: opts.origin,
clear: opts.clear,
}),
timeoutMs: 20000,
});
}
export async function browserSetMedia(
baseUrl: string | undefined,
baseUrl: string,
opts: {
colorScheme: "dark" | "light" | "no-preference" | "none";
targetId?: string;
@@ -216,7 +201,7 @@ export async function browserSetMedia(
},
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -228,11 +213,11 @@ export async function browserSetMedia(
}
export async function browserSetTimezone(
baseUrl: string | undefined,
baseUrl: string,
opts: { timezoneId: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -244,11 +229,11 @@ export async function browserSetTimezone(
}
export async function browserSetLocale(
baseUrl: string | undefined,
baseUrl: string,
opts: { locale: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
@@ -257,11 +242,11 @@ export async function browserSetLocale(
}
export async function browserSetDevice(
baseUrl: string | undefined,
baseUrl: string,
opts: { name: string; targetId?: string; profile?: string },
): Promise<BrowserActionTargetOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
@@ -270,11 +255,11 @@ export async function browserSetDevice(
}
export async function browserClearPermissions(
baseUrl: string | undefined,
baseUrl: string,
opts: { targetId?: string; profile?: string } = {},
): Promise<BrowserActionOk> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
+68 -88
View File
@@ -1,51 +1,57 @@
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
import { loadConfig } from "../config/config.js";
import { formatCliCommand } from "../cli/command-format.js";
import {
createBrowserControlContext,
startBrowserControlServiceFromConfig,
} from "./control-service.js";
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
import { resolveBrowserConfig } from "./config.js";
function isAbsoluteHttp(url: string): boolean {
return /^https?:\/\//i.test(url.trim());
let cachedConfigToken: string | null | undefined = undefined;
function getBrowserControlToken(): string | null {
const env = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
if (env) return env;
if (cachedConfigToken !== undefined) return cachedConfigToken;
try {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
const token = resolved.controlToken?.trim() || "";
cachedConfigToken = token ? token : null;
} catch {
cachedConfigToken = null;
}
return cachedConfigToken;
}
function unwrapCause(err: unknown): unknown {
if (!err || typeof err !== "object") return null;
const cause = (err as { cause?: unknown }).cause;
return cause ?? null;
}
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
const hint = isAbsoluteHttp(url)
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
const msg = String(err);
const msgLower = msg.toLowerCase();
const looksLikeTimeout =
msgLower.includes("timed out") ||
msgLower.includes("timeout") ||
msgLower.includes("aborted") ||
msgLower.includes("abort") ||
msgLower.includes("aborterror");
if (looksLikeTimeout) {
const cause = unwrapCause(err);
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
if (code === "ECONNREFUSED") {
return new Error(
`Can't reach the clawd browser control service (timed out after ${timeoutMs}ms). ${hint}`,
`Can't reach the clawd browser control server at ${url} (connection refused). ${hint}`,
);
}
return new Error(`Can't reach the clawd browser control service. ${hint} (${msg})`);
}
async function fetchHttpJson<T>(
url: string,
init: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = init.timeoutMs ?? 5000;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { ...init, signal: ctrl.signal });
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `HTTP ${res.status}`);
}
return (await res.json()) as T;
} finally {
clearTimeout(t);
if (code === "ETIMEDOUT" || code === "UND_ERR_CONNECT_TIMEOUT") {
return new Error(
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
);
}
const msg = formatErrorMessage(err);
if (msg.toLowerCase().includes("abort")) {
return new Error(
`Can't reach the clawd browser control server at ${url} (timed out after ${timeoutMs}ms). ${hint}`,
);
}
return new Error(`Can't reach the clawd browser control server at ${url}. ${hint} (${msg})`);
}
export async function fetchBrowserJson<T>(
@@ -53,58 +59,32 @@ export async function fetchBrowserJson<T>(
init?: RequestInit & { timeoutMs?: number },
): Promise<T> {
const timeoutMs = init?.timeoutMs ?? 5000;
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
let res: Response;
try {
if (isAbsoluteHttp(url)) {
return await fetchHttpJson<T>(url, { ...init, timeoutMs });
}
const started = await startBrowserControlServiceFromConfig();
if (!started) {
throw new Error("browser control disabled");
}
const dispatcher = createBrowserRouteDispatcher(createBrowserControlContext());
const parsed = new URL(url, "http://localhost");
const query: Record<string, unknown> = {};
for (const [key, value] of parsed.searchParams.entries()) {
query[key] = value;
}
let body = init?.body;
if (typeof body === "string") {
try {
body = JSON.parse(body);
} catch {
// keep as string
const token = getBrowserControlToken();
const mergedHeaders = (() => {
if (!token) return init?.headers;
const h = new Headers(init?.headers ?? {});
if (!h.has("Authorization")) {
h.set("Authorization", `Bearer ${token}`);
}
}
const dispatchPromise = dispatcher.dispatch({
method:
init?.method?.toUpperCase() === "DELETE"
? "DELETE"
: init?.method?.toUpperCase() === "POST"
? "POST"
: "GET",
path: parsed.pathname,
query,
body,
});
const result = await (timeoutMs
? Promise.race([
dispatchPromise,
new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("timed out")), timeoutMs),
),
])
: dispatchPromise);
if (result.status >= 400) {
const message =
result.body && typeof result.body === "object" && "error" in result.body
? String((result.body as { error?: unknown }).error)
: `HTTP ${result.status}`;
throw new Error(message);
}
return result.body as T;
return h;
})();
res = await fetch(url, {
...init,
...(mergedHeaders ? { headers: mergedHeaders } : {}),
signal: ctrl.signal,
} as RequestInit);
} catch (err) {
throw enhanceBrowserFetchError(url, err, timeoutMs);
} finally {
clearTimeout(t);
}
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text ? `${res.status}: ${text}` : `HTTP ${res.status}`);
}
return (await res.json()) as T;
}
+39 -3
View File
@@ -16,7 +16,7 @@ describe("browser client", () => {
vi.unstubAllGlobals();
});
it("wraps connection failures with a sandbox hint", async () => {
it("wraps connection failures with a gateway hint", async () => {
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
code: "ECONNREFUSED",
});
@@ -26,7 +26,7 @@ describe("browser client", () => {
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/sandboxed session/i);
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/Start .*gateway/i);
});
it("adds useful timeout messaging for abort-like failures", async () => {
@@ -34,6 +34,41 @@ describe("browser client", () => {
await expect(browserStatus("http://127.0.0.1:18791")).rejects.toThrow(/timed out/i);
});
it("adds Authorization when CLAWDBOT_BROWSER_CONTROL_TOKEN is set", async () => {
const prev = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN;
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = "t1";
const calls: Array<{ url: string; init?: RequestInit }> = [];
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
calls.push({ url, init });
return {
ok: true,
json: async () => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
running: false,
pid: null,
cdpPort: 18792,
chosenBrowser: null,
userDataDir: null,
color: "#FF0000",
headless: true,
attachOnly: false,
}),
} as unknown as Response;
}),
);
await browserStatus("http://127.0.0.1:18791");
const init = calls[0]?.init;
const auth = new Headers(init?.headers ?? {}).get("Authorization");
expect(auth).toBe("Bearer t1");
process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN = prev;
});
it("surfaces non-2xx responses with body text", async () => {
vi.stubGlobal(
"fetch",
@@ -46,7 +81,7 @@ describe("browser client", () => {
await expect(
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
).rejects.toThrow(/conflict/i);
).rejects.toThrow(/409: conflict/i);
});
it("adds labels + efficient mode query params to snapshots", async () => {
@@ -220,6 +255,7 @@ describe("browser client", () => {
ok: true,
json: async () => ({
enabled: true,
controlUrl: "http://127.0.0.1:18791",
running: true,
pid: 1,
cdpPort: 18792,
+51 -56
View File
@@ -1,7 +1,10 @@
import { loadConfig } from "../config/config.js";
import { fetchBrowserJson } from "./client-fetch.js";
import { resolveBrowserConfig } from "./config.js";
export type BrowserStatus = {
enabled: boolean;
controlUrl: string;
profile?: string;
running: boolean;
cdpReady?: boolean;
@@ -86,64 +89,59 @@ export type SnapshotResult =
imageType?: "png" | "jpeg";
};
export function resolveBrowserControlUrl(overrideUrl?: string) {
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser);
const url = overrideUrl?.trim() ? overrideUrl.trim() : resolved.controlUrl;
return url.replace(/\/$/, "");
}
function buildProfileQuery(profile?: string): string {
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
}
function withBaseUrl(baseUrl: string | undefined, path: string): string {
const trimmed = baseUrl?.trim();
if (!trimmed) return path;
return `${trimmed.replace(/\/$/, "")}${path}`;
}
export async function browserStatus(
baseUrl?: string,
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserStatus> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
timeoutMs: 1500,
});
}
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
withBaseUrl(baseUrl, `/profiles`),
{
timeoutMs: 3000,
},
);
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
timeoutMs: 3000,
});
return res.profiles ?? [];
}
export async function browserStart(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
export async function browserStart(baseUrl: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
await fetchBrowserJson(`${baseUrl}/start${q}`, {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserStop(baseUrl?: string, opts?: { profile?: string }): Promise<void> {
export async function browserStop(baseUrl: string, opts?: { profile?: string }): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
method: "POST",
timeoutMs: 15000,
});
}
export async function browserResetProfile(
baseUrl?: string,
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserResetProfileResult> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserResetProfileResult>(
withBaseUrl(baseUrl, `/reset-profile${q}`),
{
method: "POST",
timeoutMs: 20000,
},
);
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
method: "POST",
timeoutMs: 20000,
});
}
export type BrowserCreateProfileResult = {
@@ -156,7 +154,7 @@ export type BrowserCreateProfileResult = {
};
export async function browserCreateProfile(
baseUrl: string | undefined,
baseUrl: string,
opts: {
name: string;
color?: string;
@@ -164,20 +162,17 @@ export async function browserCreateProfile(
driver?: "clawd" | "extension";
},
): Promise<BrowserCreateProfileResult> {
return await fetchBrowserJson<BrowserCreateProfileResult>(
withBaseUrl(baseUrl, `/profiles/create`),
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver: opts.driver,
}),
timeoutMs: 10000,
},
);
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: opts.name,
color: opts.color,
cdpUrl: opts.cdpUrl,
driver: opts.driver,
}),
timeoutMs: 10000,
});
}
export type BrowserDeleteProfileResult = {
@@ -187,11 +182,11 @@ export type BrowserDeleteProfileResult = {
};
export async function browserDeleteProfile(
baseUrl: string | undefined,
baseUrl: string,
profile: string,
): Promise<BrowserDeleteProfileResult> {
return await fetchBrowserJson<BrowserDeleteProfileResult>(
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
{
method: "DELETE",
timeoutMs: 20000,
@@ -200,24 +195,24 @@ export async function browserDeleteProfile(
}
export async function browserTabs(
baseUrl?: string,
baseUrl: string,
opts?: { profile?: string },
): Promise<BrowserTab[]> {
const q = buildProfileQuery(opts?.profile);
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
withBaseUrl(baseUrl, `/tabs${q}`),
`${baseUrl}/tabs${q}`,
{ timeoutMs: 3000 },
);
return res.tabs ?? [];
}
export async function browserOpenTab(
baseUrl: string | undefined,
baseUrl: string,
url: string,
opts?: { profile?: string },
): Promise<BrowserTab> {
const q = buildProfileQuery(opts?.profile);
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
@@ -226,12 +221,12 @@ export async function browserOpenTab(
}
export async function browserFocusTab(
baseUrl: string | undefined,
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId }),
@@ -240,19 +235,19 @@ export async function browserFocusTab(
}
export async function browserCloseTab(
baseUrl: string | undefined,
baseUrl: string,
targetId: string,
opts?: { profile?: string },
): Promise<void> {
const q = buildProfileQuery(opts?.profile);
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
method: "DELETE",
timeoutMs: 5000,
});
}
export async function browserTabAction(
baseUrl: string | undefined,
baseUrl: string,
opts: {
action: "list" | "new" | "close" | "select";
index?: number;
@@ -260,7 +255,7 @@ export async function browserTabAction(
},
): Promise<unknown> {
const q = buildProfileQuery(opts.profile);
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -272,7 +267,7 @@ export async function browserTabAction(
}
export async function browserSnapshot(
baseUrl: string | undefined,
baseUrl: string,
opts: {
format: "aria" | "ai";
targetId?: string;
@@ -306,7 +301,7 @@ export async function browserSnapshot(
if (opts.labels === true) q.set("labels", "1");
if (opts.mode) q.set("mode", opts.mode);
if (opts.profile) q.set("profile", opts.profile);
return await fetchBrowserJson<SnapshotResult>(withBaseUrl(baseUrl, `/snapshot?${q.toString()}`), {
return await fetchBrowserJson<SnapshotResult>(`${baseUrl}/snapshot?${q.toString()}`, {
timeoutMs: 20000,
});
}
+24 -31
View File
@@ -2,14 +2,13 @@ import { describe, expect, it } from "vitest";
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
describe("browser config", () => {
it("defaults to enabled with loopback defaults and lobster-orange color", () => {
it("defaults to enabled with loopback control url and lobster-orange color", () => {
const resolved = resolveBrowserConfig(undefined);
expect(resolved.enabled).toBe(true);
expect(resolved.controlPort).toBe(18791);
expect(resolved.controlHost).toBe("127.0.0.1");
expect(resolved.color).toBe("#FF4500");
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
const profile = resolveProfile(resolved, resolved.defaultProfile);
expect(profile?.name).toBe("chrome");
expect(profile?.driver).toBe("extension");
@@ -47,31 +46,9 @@ describe("browser config", () => {
}
});
it("derives default ports from gateway.port when env is unset", () => {
const prev = process.env.CLAWDBOT_GATEWAY_PORT;
delete process.env.CLAWDBOT_GATEWAY_PORT;
try {
const resolved = resolveBrowserConfig(undefined, { gateway: { port: 19011 } });
expect(resolved.controlPort).toBe(19013);
const chrome = resolveProfile(resolved, "chrome");
expect(chrome?.driver).toBe("extension");
expect(chrome?.cdpPort).toBe(19014);
expect(chrome?.cdpUrl).toBe("http://127.0.0.1:19014");
const clawd = resolveProfile(resolved, "clawd");
expect(clawd?.cdpPort).toBe(19022);
expect(clawd?.cdpUrl).toBe("http://127.0.0.1:19022");
} finally {
if (prev === undefined) {
delete process.env.CLAWDBOT_GATEWAY_PORT;
} else {
process.env.CLAWDBOT_GATEWAY_PORT = prev;
}
}
});
it("normalizes hex colors", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18791",
color: "ff4500",
});
expect(resolved.color).toBe("#FF4500");
@@ -79,6 +56,7 @@ describe("browser config", () => {
it("supports custom remote CDP timeouts", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
remoteCdpTimeoutMs: 2200,
remoteCdpHandshakeTimeoutMs: 5000,
});
@@ -88,21 +66,31 @@ describe("browser config", () => {
it("falls back to default color for invalid hex", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://localhost:18791",
color: "#GGGGGG",
});
expect(resolved.color).toBe("#FF4500");
});
it("treats non-loopback cdpUrl as remote", () => {
it("treats non-loopback control urls as remote", () => {
const resolved = resolveBrowserConfig({
cdpUrl: "http://example.com:9222",
controlUrl: "http://example.com:18791",
});
const profile = resolveProfile(resolved, "clawd");
expect(profile?.cdpIsLoopback).toBe(false);
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
});
it("derives CDP host/protocol from control url when cdpUrl is unset", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:19000",
});
expect(resolved.controlPort).toBe(19000);
expect(resolved.cdpHost).toBe("127.0.0.1");
expect(resolved.cdpProtocol).toBe("http");
});
it("supports explicit CDP URLs for the default profile", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "http://example.com:9222",
});
const profile = resolveProfile(resolved, "clawd");
@@ -113,6 +101,7 @@ describe("browser config", () => {
it("uses profile cdpUrl when provided", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
@@ -126,6 +115,7 @@ describe("browser config", () => {
it("uses base protocol for profiles with only cdpPort", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
cdpUrl: "https://example.com:9443",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
@@ -137,11 +127,14 @@ describe("browser config", () => {
});
it("rejects unsupported protocols", () => {
expect(() => resolveBrowserConfig({ cdpUrl: "ws://127.0.0.1:18791" })).toThrow(/must be http/i);
expect(() => resolveBrowserConfig({ controlUrl: "ws://127.0.0.1:18791" })).toThrow(
/must be http/i,
);
});
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
clawd: { cdpPort: 18792, color: "#FF4500" },
},
+30 -17
View File
@@ -1,14 +1,12 @@
import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
import {
deriveDefaultBrowserCdpPortRange,
deriveDefaultBrowserControlPort,
DEFAULT_BROWSER_CONTROL_PORT,
} from "../config/port-defaults.js";
import { resolveGatewayPort } from "../config/paths.js";
import {
DEFAULT_CLAWD_BROWSER_COLOR,
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
DEFAULT_CLAWD_BROWSER_ENABLED,
DEFAULT_BROWSER_EVALUATE_ENABLED,
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
} from "./constants.js";
@@ -16,8 +14,10 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
export type ResolvedBrowserConfig = {
enabled: boolean;
evaluateEnabled: boolean;
controlUrl: string;
controlHost: string;
controlPort: number;
controlToken?: string;
cdpProtocol: "http" | "https";
cdpHost: string;
cdpIsLoopback: boolean;
@@ -137,14 +137,24 @@ function ensureDefaultChromeExtensionProfile(
};
return result;
}
export function resolveBrowserConfig(
cfg: BrowserConfig | undefined,
rootConfig?: ClawdbotConfig,
): ResolvedBrowserConfig {
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
const evaluateEnabled = cfg?.evaluateEnabled ?? DEFAULT_BROWSER_EVALUATE_ENABLED;
const gatewayPort = resolveGatewayPort(rootConfig);
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
const controlToken = cfg?.controlToken?.trim() || undefined;
const derivedControlPort = (() => {
const raw = process.env.CLAWDBOT_GATEWAY_PORT?.trim();
if (!raw) return null;
const gatewayPort = Number.parseInt(raw, 10);
if (!Number.isFinite(gatewayPort) || gatewayPort <= 0) return null;
return deriveDefaultBrowserControlPort(gatewayPort);
})();
const derivedControlUrl = derivedControlPort ? `http://127.0.0.1:${derivedControlPort}` : null;
const controlInfo = parseHttpUrl(
cfg?.controlUrl ?? envControlUrl ?? derivedControlUrl ?? DEFAULT_CLAWD_BROWSER_CONTROL_URL,
"browser.controlUrl",
);
const controlPort = controlInfo.port;
const defaultColor = normalizeHexColor(cfg?.color);
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
@@ -168,10 +178,11 @@ export function resolveBrowserConfig(
const derivedPort = controlPort + 1;
if (derivedPort > 65535) {
throw new Error(
`Derived CDP port (${derivedPort}) is too high; check gateway port configuration.`,
`browser.controlUrl port (${controlPort}) is too high; cannot derive CDP port (${derivedPort})`,
);
}
const derived = new URL(`http://127.0.0.1:${derivedPort}`);
const derived = new URL(controlInfo.normalized);
derived.port = String(derivedPort);
cdpInfo = {
parsed: derived,
port: derivedPort,
@@ -200,8 +211,10 @@ export function resolveBrowserConfig(
return {
enabled,
evaluateEnabled,
controlUrl: controlInfo.normalized,
controlHost: controlInfo.parsed.hostname,
controlPort,
...(controlToken ? { controlToken } : {}),
cdpProtocol,
cdpHost: cdpInfo.parsed.hostname,
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
@@ -256,6 +269,6 @@ export function resolveProfile(
};
}
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
return true;
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
return isLoopbackHost(resolved.controlHost);
}
+1 -1
View File
@@ -1,5 +1,5 @@
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
export const DEFAULT_BROWSER_EVALUATE_ENABLED = true;
export const DEFAULT_CLAWD_BROWSER_CONTROL_URL = "http://127.0.0.1:18791";
export const DEFAULT_CLAWD_BROWSER_COLOR = "#FF4500";
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
-80
View File
@@ -1,80 +0,0 @@
import { loadConfig } from "../config/config.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
const logService = log.child("service");
export function getBrowserControlState(): BrowserServerState | null {
return state;
}
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
});
}
export async function startBrowserControlServiceFromConfig(): Promise<BrowserServerState | null> {
if (state) return state;
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
if (!resolved.enabled) return null;
state = {
server: null,
port: resolved.controlPort,
resolved,
profiles: new Map(),
};
// If any profile uses the Chrome extension relay, start the local relay server eagerly
// so the extension can connect before the first browser action.
for (const name of Object.keys(resolved.profiles)) {
const profile = resolveProfile(resolved, name);
if (!profile || profile.driver !== "extension") continue;
await ensureChromeExtensionRelayServer({ cdpUrl: profile.cdpUrl }).catch((err) => {
logService.warn(`Chrome extension relay init failed for profile "${name}": ${String(err)}`);
});
}
logService.info(
`Browser control service ready (profiles=${Object.keys(resolved.profiles).length})`,
);
return state;
}
export async function stopBrowserControlService(): Promise<void> {
const current = state;
if (!current) return;
const ctx = createBrowserRouteContext({
getState: () => state,
});
try {
for (const name of Object.keys(current.resolved.profiles)) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
// ignore
}
}
} catch (err) {
logService.warn(`clawd browser stop failed: ${String(err)}`);
}
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
+8 -2
View File
@@ -49,7 +49,9 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
describe("BrowserProfilesService", () => {
it("allocates next local port for new profiles", async () => {
const resolved = resolveBrowserConfig({});
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx, state } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -64,7 +66,9 @@ describe("BrowserProfilesService", () => {
});
it("accepts per-profile cdpUrl for remote Chrome", async () => {
const resolved = resolveBrowserConfig({});
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
});
const { ctx } = createCtx(resolved);
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
@@ -93,6 +97,7 @@ describe("BrowserProfilesService", () => {
it("deletes remote profiles without stopping or removing local data", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
},
@@ -119,6 +124,7 @@ describe("BrowserProfilesService", () => {
it("deletes local profiles and moves data to Trash", async () => {
const resolved = resolveBrowserConfig({
controlUrl: "http://127.0.0.1:18791",
profiles: {
work: { cdpPort: 18801, color: "#0066CC" },
},
+3 -26
View File
@@ -1,3 +1,5 @@
import type express from "express";
import type { BrowserFormField } from "../client-actions-core.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -14,12 +16,8 @@ import {
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
@@ -39,7 +37,6 @@ export function registerBrowserAgentActRoutes(
const cdpUrl = profileCtx.profile.cdpUrl;
const pw = await requirePwAi(res, `act:${kind}`);
if (!pw) return;
const evaluateEnabled = ctx.state().resolved.evaluateEnabled;
switch (kind) {
case "click": {
@@ -211,16 +208,6 @@ export function registerBrowserAgentActRoutes(
: undefined;
const fn = toStringOrEmpty(body.fn) || undefined;
const timeoutMs = toNumber(body.timeoutMs) ?? undefined;
if (fn && !evaluateEnabled) {
return jsonError(
res,
403,
[
"wait --fn is disabled by config (browser.evaluateEnabled=false).",
"Docs: /gateway/configuration#browser-clawd-managed-browser",
].join("\n"),
);
}
if (
timeMs === undefined &&
!text &&
@@ -251,16 +238,6 @@ export function registerBrowserAgentActRoutes(
return res.json({ ok: true, targetId: tab.targetId });
}
case "evaluate": {
if (!evaluateEnabled) {
return jsonError(
res,
403,
[
"act:evaluate is disabled by config (browser.evaluateEnabled=false).",
"Docs: /gateway/configuration#browser-clawd-managed-browser",
].join("\n"),
);
}
const fn = toStringOrEmpty(body.fn);
if (!fn) return jsonError(res, 400, "fn is required");
const ref = toStringOrEmpty(body.ref) || undefined;
+3 -5
View File
@@ -2,15 +2,13 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentDebugRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+7 -6
View File
@@ -1,8 +1,9 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import { getProfileContext, jsonError } from "./utils.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
export const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
@@ -14,21 +15,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
"This is more reliable for modern SPAs.",
].join("\n");
export function readBody(req: BrowserRequest): Record<string, unknown> {
export function readBody(req: express.Request): Record<string, unknown> {
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body;
}
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));
}
export function resolveProfileContext(
req: BrowserRequest,
res: BrowserResponse,
req: express.Request,
res: express.Response,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
@@ -44,7 +45,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
}
export async function requirePwAi(
res: BrowserResponse,
res: express.Response,
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();
+3 -5
View File
@@ -1,5 +1,7 @@
import path from "node:path";
import type express from "express";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
@@ -21,12 +23,8 @@ import {
resolveProfileContext,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
+3 -5
View File
@@ -1,12 +1,10 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentStorageRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;
Loaded 100 of 198 files, more files were not shown because too many files have changed in this diff. Show more