Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a087540ef3 | ||
|
|
ff0cbee297 | ||
|
|
9227026af3 | ||
|
|
23f65cc90b |
No files matched your search
@@ -40,8 +40,6 @@ apps/ios/*.xcfilelist
|
|||||||
|
|
||||||
# Vendor build artifacts
|
# Vendor build artifacts
|
||||||
vendor/a2ui/renderers/lit/dist/
|
vendor/a2ui/renderers/lit/dist/
|
||||||
src/canvas-host/a2ui/*.bundle.js
|
|
||||||
src/canvas-host/a2ui/*.map
|
|
||||||
.bundle.hash
|
.bundle.hash
|
||||||
|
|
||||||
# fastlane (iOS)
|
# fastlane (iOS)
|
||||||
|
|||||||
+2
-6
@@ -6,7 +6,6 @@ Docs: https://docs.clawd.bot
|
|||||||
Status: unreleased.
|
Status: unreleased.
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk).
|
|
||||||
- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
|
- 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.
|
- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204.
|
||||||
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
- Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281)
|
||||||
@@ -37,10 +36,7 @@ Status: unreleased.
|
|||||||
- CI: increase Node heap size for macOS checks. (#1890) Thanks @realZachi.
|
- 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.
|
- 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: 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.
|
- 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: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
|
||||||
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
|
- 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.
|
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
|
||||||
@@ -709,7 +705,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
|
|
||||||
### Highlights
|
### Highlights
|
||||||
- Web search: `web_search`/`web_fetch` tools (Brave API) + first-time setup in onboarding/configure.
|
- 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.
|
- 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.
|
- Security: expanded `clawdbot security audit` (+ `--fix`), detect-secrets CI scan, and a `SECURITY.md` reporting policy.
|
||||||
|
|
||||||
@@ -727,7 +723,7 @@ Thanks @AlexMikhalev, @CoreyH, @John-Rood, @KrauseFx, @MaudeBot, @Nachx639, @Nic
|
|||||||
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
|
- 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.
|
- 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.
|
- 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
|
### Fixes
|
||||||
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
- Sessions: refactor session store updates to lock + mutate per-entry, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||||
|
|||||||
@@ -384,6 +384,7 @@ Browser control (optional):
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
color: "#FF4500"
|
color: "#FF4500"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ For threat model + hardening guidance (including `clawdbot security audit --deep
|
|||||||
|
|
||||||
- `https://docs.clawd.bot/gateway/security`
|
- `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
|
## Runtime Requirements
|
||||||
|
|
||||||
### Node.js Version
|
### Node.js Version
|
||||||
|
|||||||
@@ -83,10 +83,7 @@ enum CommandResolver {
|
|||||||
"/usr/bin",
|
"/usr/bin",
|
||||||
"/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)
|
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||||
#endif
|
|
||||||
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||||
if !clawdbotPaths.isEmpty {
|
if !clawdbotPaths.isEmpty {
|
||||||
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||||
@@ -192,13 +189,9 @@ enum CommandResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||||
#if DEBUG
|
|
||||||
let root = projectRoot ?? self.projectRoot()
|
let root = projectRoot ?? self.projectRoot()
|
||||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||||
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
return FileManager().isExecutableFile(atPath: candidate) ? candidate : nil
|
||||||
#else
|
|
||||||
return nil
|
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static func nodeCliPath() -> String? {
|
static func nodeCliPath() -> String? {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export default defineConfig({
|
|||||||
output: {
|
output: {
|
||||||
file: outputFile,
|
file: outputFile,
|
||||||
format: "esm",
|
format: "esm",
|
||||||
codeSplitting: false,
|
inlineDynamicImports: true,
|
||||||
sourcemap: false,
|
sourcemap: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -168,7 +168,8 @@
|
|||||||
<h2>Getting started</h2>
|
<h2>Getting started</h2>
|
||||||
<p>
|
<p>
|
||||||
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
|
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>
|
||||||
<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>
|
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
@@ -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
|
||||||
+3119
File diff suppressed because it is too large.
Load diff
@@ -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
|
||||||
Vendored
+15
@@ -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>
|
||||||
+12
-10
@@ -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:
|
read_when:
|
||||||
- You use `clawdbot browser` and want examples for common tasks
|
- 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)
|
- You want to use the Chrome extension relay (attach/detach via toolbar button)
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -16,10 +16,8 @@ Related:
|
|||||||
|
|
||||||
## Common flags
|
## Common flags
|
||||||
|
|
||||||
- `--url <gatewayWsUrl>`: Gateway WebSocket URL (defaults to config).
|
- `--url <controlUrl>`: override `browser.controlUrl` for this command invocation.
|
||||||
- `--token <token>`: Gateway token (if required).
|
- `--browser-profile <name>`: choose a browser profile (default comes from config).
|
||||||
- `--timeout <ms>`: request timeout (ms).
|
|
||||||
- `--browser-profile <name>`: choose a browser profile (default from config).
|
|
||||||
- `--json`: machine-readable output (where supported).
|
- `--json`: machine-readable output (where supported).
|
||||||
|
|
||||||
## Quick start (local)
|
## Quick start (local)
|
||||||
@@ -95,10 +93,14 @@ Then Chrome → `chrome://extensions` → enable “Developer mode” → “Loa
|
|||||||
|
|
||||||
Full guide: [Chrome extension](/tools/chrome-extension)
|
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
@@ -859,8 +859,9 @@ Location:
|
|||||||
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
Browser control CLI (dedicated Chrome/Brave/Edge/Chromium). See [`clawdbot browser`](/cli/browser) and the [Browser tool](/tools/browser).
|
||||||
|
|
||||||
Common options:
|
Common options:
|
||||||
- `--url`, `--token`, `--timeout`, `--json`
|
- `--url <controlUrl>`
|
||||||
- `--browser-profile <name>`
|
- `--browser-profile <name>`
|
||||||
|
- `--json`
|
||||||
|
|
||||||
Manage:
|
Manage:
|
||||||
- `browser status`
|
- `browser status`
|
||||||
|
|||||||
@@ -956,7 +956,6 @@
|
|||||||
"gateway/doctor",
|
"gateway/doctor",
|
||||||
"gateway/logging",
|
"gateway/logging",
|
||||||
"gateway/security",
|
"gateway/security",
|
||||||
"gateway/security-formal-verification",
|
|
||||||
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
"gateway/sandbox-vs-tool-policy-vs-elevated",
|
||||||
"gateway/sandboxing",
|
"gateway/sandboxing",
|
||||||
"gateway/troubleshooting",
|
"gateway/troubleshooting",
|
||||||
|
|||||||
@@ -2759,7 +2759,7 @@ Example:
|
|||||||
|
|
||||||
### `browser` (clawd-managed browser)
|
### `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 can point at a **remote** Chromium-based browser via `profiles.<name>.cdpUrl`. Remote
|
||||||
profiles are attach-only (start/stop/reset are disabled).
|
profiles are attach-only (start/stop/reset are disabled).
|
||||||
|
|
||||||
@@ -2768,8 +2768,8 @@ scheme/host for profiles that only set `cdpPort`.
|
|||||||
|
|
||||||
Defaults:
|
Defaults:
|
||||||
- enabled: `true`
|
- enabled: `true`
|
||||||
- control service: loopback only (port derived from `gateway.port`, default `18791`)
|
- control URL: `http://127.0.0.1:18791` (CDP uses `18792`)
|
||||||
- CDP URL: `http://127.0.0.1:18792` (control service + 1, legacy single-profile)
|
- CDP URL: `http://127.0.0.1:18792` (control URL + 1, legacy single-profile)
|
||||||
- profile color: `#FF4500` (lobster-orange)
|
- profile color: `#FF4500` (lobster-orange)
|
||||||
- Note: the control server is started by the running gateway (Clawdbot.app menubar, or `clawdbot gateway`).
|
- 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.
|
- Auto-detect order: default browser if Chromium-based; otherwise Chrome → Brave → Edge → Chromium → Chrome Canary.
|
||||||
@@ -2778,6 +2778,7 @@ Defaults:
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
// cdpUrl: "http://127.0.0.1:18792", // legacy single-profile override
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
profiles: {
|
profiles: {
|
||||||
|
|||||||
@@ -83,13 +83,13 @@ Defaults (can be overridden via env/flags/config):
|
|||||||
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
- `CLAWDBOT_STATE_DIR=~/.clawdbot-dev`
|
||||||
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
- `CLAWDBOT_CONFIG_PATH=~/.clawdbot-dev/clawdbot.json`
|
||||||
- `CLAWDBOT_GATEWAY_PORT=19001` (Gateway WS + HTTP)
|
- `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`)
|
- `canvasHost.port=19005` (derived: `gateway.port+4`)
|
||||||
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
- `agents.defaults.workspace` default becomes `~/clawd-dev` when you run `setup`/`onboard` under `--dev`.
|
||||||
|
|
||||||
Derived ports (rules of thumb):
|
Derived ports (rules of thumb):
|
||||||
- Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`)
|
- 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)
|
- `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).
|
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108` (persisted per profile).
|
||||||
|
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ clawdbot --profile rescue gateway install
|
|||||||
|
|
||||||
Base port = `gateway.port` (or `CLAWDBOT_GATEWAY_PORT` / `--port`).
|
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`
|
- `canvasHost.port = base + 4`
|
||||||
- Browser profile CDP ports auto-allocate from `browser.controlPort + 9 .. + 108`
|
- 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)
|
## Browser/CDP notes (common footgun)
|
||||||
|
|
||||||
- Do **not** pin `browser.cdpUrl` to the same values on multiple instances.
|
- 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 (derived from its gateway port).
|
- Each instance needs its own browser control port and CDP range.
|
||||||
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
|
- If you need explicit CDP ports, set `browser.profiles.<name>.cdpPort` per instance.
|
||||||
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
|
- Remote Chrome: use `browser.profiles.<name>.cdpUrl` (per profile, per instance).
|
||||||
|
|
||||||
|
|||||||
@@ -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://`.
|
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
|
||||||
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`.
|
||||||
Set it to `false` if you want tokens/passwords instead.
|
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).
|
Deep dive: [Security](/gateway/security).
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
---
|
|
||||||
title: Formal Verification (Security Models)
|
|
||||||
summary: Machine-checked security models for Clawdbot’s highest-risk paths.
|
|
||||||
permalink: /gateway/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: [github.com/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)
|
|
||||||
+21
-15
@@ -7,8 +7,6 @@ read_when:
|
|||||||
|
|
||||||
## Quick check: `clawdbot security audit`
|
## Quick check: `clawdbot security audit`
|
||||||
|
|
||||||
See also: [Formal Verification (Security Models)](/gateway/security-formal-verification/)
|
|
||||||
|
|
||||||
Run this regularly (especially after changing config or exposing network surfaces):
|
Run this regularly (especially after changing config or exposing network surfaces):
|
||||||
|
|
||||||
```bash
|
```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?
|
- **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?
|
- **Tool blast radius** (elevated tools + open rooms): could prompt injection turn into shell/file/network actions?
|
||||||
- **Network exposure** (Gateway bind/auth, Tailscale Serve/Funnel).
|
- **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).
|
- **Local disk hygiene** (permissions, symlinks, config includes, “synced folder” paths).
|
||||||
- **Plugins** (extensions exist without an explicit allowlist).
|
- **Plugins** (extensions exist without an explicit allowlist).
|
||||||
- **Model hygiene** (warn when configured models look legacy; not a hard block).
|
- **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.
|
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.
|
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.
|
4. **Permissions**: make sure state/config/credentials/auth are not group/world-readable.
|
||||||
5. **Plugins/extensions**: only load what you explicitly trust.
|
5. **Plugins/extensions**: only load what you explicitly trust.
|
||||||
6. **Model choice**: prefer modern, instruction-hardened models for any bot with tools.
|
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).
|
- Lock down inbound surfaces (DM policy, group allowlists, mention gating).
|
||||||
2. **Rotate secrets**
|
2. **Rotate secrets**
|
||||||
- Rotate `gateway.auth` token/password.
|
- 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).
|
- Revoke/rotate model provider credentials (API keys / OAuth).
|
||||||
3. **Review artifacts**
|
3. **Review artifacts**
|
||||||
- Check Gateway logs and recent sessions/transcripts for unexpected tool calls.
|
- 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).
|
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**
|
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 and let the Gateway proxy browser actions (see [Browser tool](/tools/browser)).
|
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
|
||||||
Treat node pairing like admin access.
|
|
||||||
|
|
||||||
Recommended pattern:
|
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:
|
Avoid:
|
||||||
- Exposing relay/control ports over LAN or public Internet.
|
- `--bind 0.0.0.0` (LAN-visible surface)
|
||||||
- Tailscale Funnel for browser control endpoints (public exposure).
|
- Tailscale Funnel for browser control endpoints (public exposure)
|
||||||
|
|
||||||
### 0.7) Secrets on disk (what’s sensitive)
|
### 0.7) Secrets on disk (what’s sensitive)
|
||||||
|
|
||||||
@@ -576,8 +581,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
|
|||||||
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
|
- 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).
|
- 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.
|
- 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.
|
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
|
||||||
- Disable browser proxy routing when you don’t need it (`gateway.nodes.browser.mode="off"`).
|
- 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.
|
- 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)
|
## Per-agent access profiles (multi-agent)
|
||||||
|
|||||||
@@ -100,13 +100,35 @@ clawdbot gateway --tailscale funnel --auth password
|
|||||||
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
- Serve/Funnel only expose the **Gateway control UI + WS**. Nodes connect over
|
||||||
the same Gateway WS endpoint, so Serve can work for node access.
|
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,
|
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
|
||||||
run a **node host** on the browser machine and keep both on the same tailnet.
|
and publish it through Tailscale **Serve** (tailnet-only):
|
||||||
The Gateway will proxy browser actions to the node; no separate control server or Serve URL needed.
|
|
||||||
|
|
||||||
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
|
## Tailscale prerequisites + limits
|
||||||
|
|
||||||
|
|||||||
+3
-4
@@ -1093,10 +1093,9 @@ clawdbot browser extension path
|
|||||||
|
|
||||||
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
|
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 on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
|
||||||
If the Gateway runs elsewhere, run a node host on the browser machine so the Gateway can proxy browser actions.
|
|
||||||
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
|
||||||
|
|
||||||
## Sandboxing and memory
|
## 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.
|
- **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`.
|
- **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
|
- **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
|
SSH is fine for ad‑hoc shell access, but nodes are simpler for ongoing agent workflows and
|
||||||
device automation.
|
device automation.
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
---
|
|
||||||
permalink: /security/formal-verification/
|
|
||||||
---
|
|
||||||
|
|
||||||
This page moved to: [/gateway/security-formal-verification/](/gateway/security-formal-verification/)
|
|
||||||
@@ -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.
|
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):
|
Gateway (from this repo):
|
||||||
|
|
||||||
|
|||||||
+136
-27
@@ -1,5 +1,5 @@
|
|||||||
---
|
---
|
||||||
summary: "Integrated browser control service + action commands"
|
summary: "Integrated browser control server + action commands"
|
||||||
read_when:
|
read_when:
|
||||||
- Adding agent-controlled browser automation
|
- Adding agent-controlled browser automation
|
||||||
- Debugging why clawd is interfering with your own Chrome
|
- 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.
|
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
|
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:
|
Beginner view:
|
||||||
- Think of it as a **separate, agent-only browser**.
|
- Think of it as a **separate, agent-only browser**.
|
||||||
@@ -57,7 +57,8 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
{
|
{
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true, // default: true
|
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)
|
remoteCdpTimeoutMs: 1500, // remote CDP HTTP timeout (ms)
|
||||||
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
remoteCdpHandshakeTimeoutMs: 3000, // remote CDP WebSocket handshake timeout (ms)
|
||||||
defaultProfile: "chrome",
|
defaultProfile: "chrome",
|
||||||
@@ -76,11 +77,10 @@ Browser settings live in `~/.clawdbot/clawdbot.json`.
|
|||||||
```
|
```
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The browser control service binds to loopback on a port derived from `gateway.port`
|
- `controlUrl` defaults to `http://127.0.0.1:18791`.
|
||||||
(default: `18791`, which is gateway + 2). The relay uses the next port (`18792`).
|
|
||||||
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
- If you override the Gateway port (`gateway.port` or `CLAWDBOT_GATEWAY_PORT`),
|
||||||
the derived browser ports shift to stay in the same “family”.
|
the default browser ports shift to stay in the same “family” (control = gateway + 2).
|
||||||
- `cdpUrl` defaults to the relay port when unset.
|
- `cdpUrl` defaults to `controlUrl + 1` when unset.
|
||||||
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
- `remoteCdpTimeoutMs` applies to remote (non-loopback) CDP reachability checks.
|
||||||
- `remoteCdpHandshakeTimeoutMs` applies to remote CDP WebSocket 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.”
|
- `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 vs remote control
|
||||||
|
|
||||||
- **Local control (default):** the Gateway starts the loopback control service and can launch a local browser.
|
- **Local control (default):** `controlUrl` is loopback (`127.0.0.1`/`localhost`).
|
||||||
- **Remote control (node host):** run a node host on the machine that has the browser; the Gateway proxies browser actions to it.
|
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
|
- **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.
|
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:
|
Remote CDP URLs can include auth:
|
||||||
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
- Query tokens (e.g., `https://provider.example?token=<token>`)
|
||||||
- HTTP Basic auth (e.g., `https://user:pass@provider.example`)
|
- 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
|
to the CDP WebSocket. Prefer environment variables or secrets managers for
|
||||||
tokens instead of committing them to config files.
|
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
|
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.
|
auto-route browser tool calls to that node without any custom `controlUrl`
|
||||||
This is the default path for remote gateways.
|
setup. This is the default path for remote gateways.
|
||||||
|
|
||||||
Notes:
|
Notes:
|
||||||
- The node host exposes its local browser control server via a **proxy command**.
|
- 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 node: `nodeHost.browserProxy.enabled=false`
|
||||||
- On the gateway: `gateway.nodes.browser.mode="off"`
|
- 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
|
[Browserless](https://browserless.io) is a hosted Chromium service that exposes
|
||||||
CDP endpoints over HTTPS. You can point a Clawdbot browser profile at a
|
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.
|
- Replace `<BROWSERLESS_API_KEY>` with your real Browserless token.
|
||||||
- Choose the region endpoint that matches your Browserless account (see their docs).
|
- 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
|
## Security
|
||||||
|
|
||||||
Key ideas:
|
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
|
||||||
- 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.
|
|
||||||
|
|
||||||
Remote CDP tips:
|
Key ideas:
|
||||||
- Prefer HTTPS endpoints and short-lived tokens where possible.
|
- Treat the browser control server like an admin API: **private network only**.
|
||||||
- Avoid embedding long-lived tokens directly in config files.
|
- 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)
|
## 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)
|
Full guide: [Chrome extension](/tools/chrome-extension)
|
||||||
|
|
||||||
Flow:
|
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`).
|
- 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).
|
- 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.
|
- 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
|
### Sandboxed sessions
|
||||||
|
|
||||||
@@ -281,7 +387,8 @@ Platforms:
|
|||||||
|
|
||||||
## Control API (optional)
|
## 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`
|
- Status/start/stop: `GET /`, `POST /start`, `POST /stop`
|
||||||
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
- Tabs: `GET /tabs`, `POST /tabs/open`, `POST /tabs/focus`, `DELETE /tabs/:targetId`
|
||||||
@@ -506,7 +613,7 @@ These are useful for “make the site behave like X” workflows:
|
|||||||
|
|
||||||
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
- The clawd browser profile may contain logged-in sessions; treat it as sensitive.
|
||||||
- For logins and anti-bot notes (X/Twitter, etc.), see [Browser login + X/Twitter posting](/tools/browser-login).
|
- 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.
|
- Remote CDP endpoints are powerful; tunnel and protect them.
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -524,10 +631,12 @@ How it maps:
|
|||||||
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
|
- `browser act` uses the snapshot `ref` IDs to click/type/drag/select.
|
||||||
- `browser screenshot` captures pixels (full page or element).
|
- `browser screenshot` captures pixels (full page or element).
|
||||||
- `browser` accepts:
|
- `browser` accepts:
|
||||||
- `profile` to choose a named browser profile (clawd, chrome, or remote CDP).
|
- `profile` to choose a named browser profile (host or remote control server).
|
||||||
- `target` (`sandbox` | `host` | `node`) to select where the browser lives.
|
- `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`.
|
- 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 `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.
|
This keeps the agent deterministic and avoids brittle selectors.
|
||||||
@@ -15,7 +15,7 @@ Attach/detach happens via a **single Chrome toolbar button**.
|
|||||||
## What it is (concept)
|
## What it is (concept)
|
||||||
|
|
||||||
There are three parts:
|
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)
|
- **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
|
- **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).
|
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
|
||||||
|
|
||||||
If you see `!`:
|
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.
|
- 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
|
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
|
||||||
and auto-starts the relay server. The extension talks to the local relay; the CLI/tool calls go to the Gateway.
|
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.
|
### Remote Gateway (Gateway runs elsewhere) — **yes**
|
||||||
The Gateway will proxy browser actions to that node; the extension + relay stay local to the browser machine.
|
|
||||||
|
|
||||||
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)
|
## 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`
|
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.
|
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
|
||||||
- Pair nodes intentionally; disable browser proxy routing if you don’t want remote control (`gateway.nodes.browser.mode="off"`).
|
|
||||||
|
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
|
## How “extension path” works
|
||||||
|
|
||||||
@@ -159,8 +176,8 @@ This is powerful and risky. Treat it like giving the model “hands on your brow
|
|||||||
|
|
||||||
Recommendations:
|
Recommendations:
|
||||||
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
|
- 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.
|
- Keep the browser control server tailnet-only (Tailscale) and require a token.
|
||||||
- Avoid exposing relay ports over LAN (`0.0.0.0`) and avoid Funnel (public).
|
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
|
||||||
|
|
||||||
Related:
|
Related:
|
||||||
- Browser tool overview: [Browser](/tools/browser)
|
- Browser tool overview: [Browser](/tools/browser)
|
||||||
|
|||||||
+1
-2
@@ -67,8 +67,7 @@ Example:
|
|||||||
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
- macOS: `/opt/homebrew/bin`, `/usr/local/bin`, `/usr/bin`, `/bin`
|
||||||
- Linux: `/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`.
|
- `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);
|
Clawdbot prepends `env.PATH` after profile sourcing; `tools.exec.pathPrepend` applies here too.
|
||||||
`tools.exec.pathPrepend` applies here too.
|
|
||||||
- `host=node`: only env overrides you pass are sent to the node. `tools.exec.pathPrepend` only applies
|
- `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
|
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.
|
the node host PATH (no replacement). macOS nodes drop `PATH` overrides entirely.
|
||||||
|
|||||||
+3
-6
@@ -249,17 +249,16 @@ Profile management:
|
|||||||
- `reset-profile` — kill orphan process on profile's port (local only)
|
- `reset-profile` — kill orphan process on profile's port (local only)
|
||||||
|
|
||||||
Common parameters:
|
Common parameters:
|
||||||
|
- `controlUrl` (defaults from config)
|
||||||
- `profile` (optional; defaults to `browser.defaultProfile`)
|
- `profile` (optional; defaults to `browser.defaultProfile`)
|
||||||
- `target` (`sandbox` | `host` | `node`)
|
|
||||||
- `node` (optional; picks a specific node id/name)
|
|
||||||
Notes:
|
Notes:
|
||||||
- Requires `browser.enabled=true` (default is `true`; set `false` to disable).
|
- 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.
|
- All actions accept optional `profile` parameter for multi-instance support.
|
||||||
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
- When `profile` is omitted, uses `browser.defaultProfile` (defaults to "chrome").
|
||||||
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
- Profile names: lowercase alphanumeric + hyphens only (max 64 chars).
|
||||||
- Port range: 18800-18899 (~100 profiles max).
|
- Port range: 18800-18899 (~100 profiles max).
|
||||||
- Remote profiles are attach-only (no start/stop/reset).
|
- 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` 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`.
|
- `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.
|
- `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`
|
- `timeoutMs`
|
||||||
|
|
||||||
Browser tool:
|
Browser tool:
|
||||||
- `profile` (optional; defaults to `browser.defaultProfile`)
|
- `controlUrl` (defaults from config)
|
||||||
- `target` (`sandbox` | `host` | `node`)
|
|
||||||
- `node` (optional; pin a specific node id/name)
|
|
||||||
|
|
||||||
## Recommended agent flows
|
## Recommended agent flows
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -82,7 +82,7 @@
|
|||||||
"docs:bin": "node scripts/build-docs-list.mjs",
|
"docs:bin": "node scripts/build-docs-list.mjs",
|
||||||
"docs:dev": "cd docs && mint dev",
|
"docs:dev": "cd docs && mint dev",
|
||||||
"docs:build": "cd docs && pnpm dlx --reporter append-only mint broken-links",
|
"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",
|
"plugins:sync": "node --import tsx scripts/sync-plugin-versions.ts",
|
||||||
"release:check": "node --import tsx scripts/release-check.ts",
|
"release:check": "node --import tsx scripts/release-check.ts",
|
||||||
"ui:install": "node scripts/ui.js install",
|
"ui:install": "node scripts/ui.js install",
|
||||||
|
|||||||
@@ -1,15 +1,8 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
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)"
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
HASH_FILE="$ROOT_DIR/src/canvas-host/a2ui/.bundle.hash"
|
||||||
OUTPUT_FILE="$ROOT_DIR/src/canvas-host/a2ui/a2ui.bundle.js"
|
|
||||||
|
|
||||||
INPUT_PATHS=(
|
INPUT_PATHS=(
|
||||||
"$ROOT_DIR/package.json"
|
"$ROOT_DIR/package.json"
|
||||||
@@ -40,7 +33,7 @@ compute_hash() {
|
|||||||
current_hash="$(compute_hash)"
|
current_hash="$(compute_hash)"
|
||||||
if [[ -f "$HASH_FILE" ]]; then
|
if [[ -f "$HASH_FILE" ]]; then
|
||||||
previous_hash="$(cat "$HASH_FILE")"
|
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."
|
echo "A2UI bundle up to date; skipping."
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|||||||
+10
-35
@@ -1,44 +1,19 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
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 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) {
|
async function main() {
|
||||||
const srcDir =
|
await fs.stat(path.join(srcDir, "index.html"));
|
||||||
env.CLAWDBOT_A2UI_SRC_DIR ?? path.join(repoRoot, "src", "canvas-host", "a2ui");
|
await fs.stat(path.join(srcDir, "a2ui.bundle.js"));
|
||||||
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 });
|
|
||||||
}
|
|
||||||
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
await fs.mkdir(path.dirname(outDir), { recursive: true });
|
||||||
await fs.cp(srcDir, outDir, { recursive: true });
|
await fs.cp(srcDir, outDir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
main().catch((err) => {
|
||||||
const { srcDir, outDir } = getA2uiPaths();
|
console.error(String(err));
|
||||||
await copyA2uiAssets({ srcDir, outDir });
|
process.exit(1);
|
||||||
}
|
});
|
||||||
|
|
||||||
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
||||||
main().catch((err) => {
|
|
||||||
console.error(String(err));
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -60,18 +60,11 @@ export function buildDockerExecArgs(params: {
|
|||||||
for (const [key, value] of Object.entries(params.env)) {
|
for (const [key, value] of Object.entries(params.env)) {
|
||||||
args.push("-e", `${key}=${value}`);
|
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,
|
// Login shell (-l) sources /etc/profile which resets PATH to a minimal set,
|
||||||
// overriding both Docker ENV and -e PATH=... environment variables.
|
// overriding both Docker ENV and -e PATH=... environment variables.
|
||||||
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
// Prepend custom PATH after profile sourcing to ensure custom tools are accessible
|
||||||
// while preserving system paths that /etc/profile may have added.
|
// while preserving system paths that /etc/profile may have added.
|
||||||
const pathExport = hasCustomPath
|
const pathExport = params.env.PATH ? `export PATH="${params.env.PATH}:$PATH"; ` : "";
|
||||||
? 'export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"; unset CLAWDBOT_PREPEND_PATH; '
|
|
||||||
: "";
|
|
||||||
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
args.push(params.containerName, "sh", "-lc", `${pathExport}${params.command}`);
|
||||||
return args;
|
return args;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -318,30 +318,9 @@ describe("buildDockerExecArgs", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const commandArg = args[args.length - 1];
|
const commandArg = args[args.length - 1];
|
||||||
expect(args).toContain("CLAWDBOT_PREPEND_PATH=/custom/bin:/usr/local/bin:/usr/bin");
|
expect(commandArg).toContain('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"');
|
||||||
expect(commandArg).toContain('export PATH="${CLAWDBOT_PREPEND_PATH}:$PATH"');
|
|
||||||
expect(commandArg).toContain("echo hello");
|
expect(commandArg).toContain("echo hello");
|
||||||
expect(commandArg).toBe(
|
expect(commandArg).toBe('export PATH="/custom/bin:/usr/local/bin:/usr/bin:$PATH"; echo hello');
|
||||||
'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");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not add PATH export when PATH is not in env", () => {
|
it("does not add PATH export when PATH is not in env", () => {
|
||||||
|
|||||||
@@ -47,9 +47,7 @@ describe("gateway tool", () => {
|
|||||||
payload?: { kind?: string; doctorHint?: string | null };
|
payload?: { kind?: string; doctorHint?: string | null };
|
||||||
};
|
};
|
||||||
expect(parsed.payload?.kind).toBe("restart");
|
expect(parsed.payload?.kind).toBe("restart");
|
||||||
expect(parsed.payload?.doctorHint).toBe(
|
expect(parsed.payload?.doctorHint).toBe("Run: clawdbot doctor --non-interactive");
|
||||||
"Run: clawdbot --profile isolated doctor --non-interactive",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(kill).not.toHaveBeenCalled();
|
expect(kill).not.toHaveBeenCalled();
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
|
|||||||
@@ -20,8 +20,11 @@ import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
|
|||||||
import { createTtsTool } from "./tools/tts-tool.js";
|
import { createTtsTool } from "./tools/tts-tool.js";
|
||||||
|
|
||||||
export function createClawdbotTools(options?: {
|
export function createClawdbotTools(options?: {
|
||||||
sandboxBrowserBridgeUrl?: string;
|
browserControlUrl?: string;
|
||||||
allowHostBrowserControl?: boolean;
|
allowHostBrowserControl?: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
agentSessionKey?: string;
|
agentSessionKey?: string;
|
||||||
agentChannel?: GatewayMessageChannel;
|
agentChannel?: GatewayMessageChannel;
|
||||||
agentAccountId?: string;
|
agentAccountId?: string;
|
||||||
@@ -72,8 +75,11 @@ export function createClawdbotTools(options?: {
|
|||||||
});
|
});
|
||||||
const tools: AnyAgentTool[] = [
|
const tools: AnyAgentTool[] = [
|
||||||
createBrowserTool({
|
createBrowserTool({
|
||||||
sandboxBridgeUrl: options?.sandboxBrowserBridgeUrl,
|
defaultControlUrl: options?.browserControlUrl,
|
||||||
allowHostControl: options?.allowHostBrowserControl,
|
allowHostControl: options?.allowHostBrowserControl,
|
||||||
|
allowedControlUrls: options?.allowedControlUrls,
|
||||||
|
allowedControlHosts: options?.allowedControlHosts,
|
||||||
|
allowedControlPorts: options?.allowedControlPorts,
|
||||||
}),
|
}),
|
||||||
createCanvasTool(),
|
createCanvasTool(),
|
||||||
createNodesTool({
|
createNodesTool({
|
||||||
|
|||||||
@@ -127,7 +127,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
},
|
},
|
||||||
browserAllowHostControl: true,
|
browserAllowHostControl: true,
|
||||||
browser: {
|
browser: {
|
||||||
bridgeUrl: "http://localhost:9222",
|
controlUrl: "http://localhost:9222",
|
||||||
noVncUrl: "http://localhost:6080",
|
noVncUrl: "http://localhost:6080",
|
||||||
containerName: "clawdbot-sbx-browser-test",
|
containerName: "clawdbot-sbx-browser-test",
|
||||||
},
|
},
|
||||||
@@ -138,7 +138,7 @@ describe("buildEmbeddedSandboxInfo", () => {
|
|||||||
workspaceDir: "/tmp/clawdbot-sandbox",
|
workspaceDir: "/tmp/clawdbot-sandbox",
|
||||||
workspaceAccess: "none",
|
workspaceAccess: "none",
|
||||||
agentWorkspaceMount: undefined,
|
agentWorkspaceMount: undefined,
|
||||||
browserBridgeUrl: "http://localhost:9222",
|
browserControlUrl: "http://localhost:9222",
|
||||||
browserNoVncUrl: "http://localhost:6080",
|
browserNoVncUrl: "http://localhost:6080",
|
||||||
hostBrowserAllowed: true,
|
hostBrowserAllowed: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,9 +13,12 @@ export function buildEmbeddedSandboxInfo(
|
|||||||
workspaceDir: sandbox.workspaceDir,
|
workspaceDir: sandbox.workspaceDir,
|
||||||
workspaceAccess: sandbox.workspaceAccess,
|
workspaceAccess: sandbox.workspaceAccess,
|
||||||
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
agentWorkspaceMount: sandbox.workspaceAccess === "ro" ? "/agent" : undefined,
|
||||||
browserBridgeUrl: sandbox.browser?.bridgeUrl,
|
browserControlUrl: sandbox.browser?.controlUrl,
|
||||||
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
browserNoVncUrl: sandbox.browser?.noVncUrl,
|
||||||
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
hostBrowserAllowed: sandbox.browserAllowHostControl,
|
||||||
|
allowedControlUrls: sandbox.browserAllowedControlUrls,
|
||||||
|
allowedControlHosts: sandbox.browserAllowedControlHosts,
|
||||||
|
allowedControlPorts: sandbox.browserAllowedControlPorts,
|
||||||
...(elevatedAllowed
|
...(elevatedAllowed
|
||||||
? {
|
? {
|
||||||
elevated: {
|
elevated: {
|
||||||
|
|||||||
@@ -69,9 +69,12 @@ export type EmbeddedSandboxInfo = {
|
|||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
workspaceAccess?: "none" | "ro" | "rw";
|
workspaceAccess?: "none" | "ro" | "rw";
|
||||||
agentWorkspaceMount?: string;
|
agentWorkspaceMount?: string;
|
||||||
browserBridgeUrl?: string;
|
browserControlUrl?: string;
|
||||||
browserNoVncUrl?: string;
|
browserNoVncUrl?: string;
|
||||||
hostBrowserAllowed?: boolean;
|
hostBrowserAllowed?: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
elevated?: {
|
elevated?: {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
defaultLevel: "on" | "off" | "ask" | "full";
|
defaultLevel: "on" | "off" | "ask" | "full";
|
||||||
|
|||||||
+1
@@ -96,6 +96,7 @@ describe("createClawdbotCodingTools", () => {
|
|||||||
};
|
};
|
||||||
expect(parameters.properties?.action).toBeDefined();
|
expect(parameters.properties?.action).toBeDefined();
|
||||||
expect(parameters.properties?.target).toBeDefined();
|
expect(parameters.properties?.target).toBeDefined();
|
||||||
|
expect(parameters.properties?.controlUrl).toBeDefined();
|
||||||
expect(parameters.properties?.targetUrl).toBeDefined();
|
expect(parameters.properties?.targetUrl).toBeDefined();
|
||||||
expect(parameters.properties?.request).toBeDefined();
|
expect(parameters.properties?.request).toBeDefined();
|
||||||
expect(parameters.required ?? []).toContain("action");
|
expect(parameters.required ?? []).toContain("action");
|
||||||
|
|||||||
@@ -294,8 +294,11 @@ export function createClawdbotCodingTools(options?: {
|
|||||||
// Channel docking: include channel-defined agent tools (login, etc.).
|
// Channel docking: include channel-defined agent tools (login, etc.).
|
||||||
...listChannelAgentTools({ cfg: options?.config }),
|
...listChannelAgentTools({ cfg: options?.config }),
|
||||||
...createClawdbotTools({
|
...createClawdbotTools({
|
||||||
sandboxBrowserBridgeUrl: sandbox?.browser?.bridgeUrl,
|
browserControlUrl: sandbox?.browser?.controlUrl,
|
||||||
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
allowHostBrowserControl: sandbox ? sandbox.browserAllowHostControl : true,
|
||||||
|
allowedControlUrls: sandbox?.browserAllowedControlUrls,
|
||||||
|
allowedControlHosts: sandbox?.browserAllowedControlHosts,
|
||||||
|
allowedControlPorts: sandbox?.browserAllowedControlPorts,
|
||||||
agentSessionKey: options?.sessionKey,
|
agentSessionKey: options?.sessionKey,
|
||||||
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
agentChannel: resolveGatewayMessageChannel(options?.messageProvider),
|
||||||
agentAccountId: options?.agentAccountId,
|
agentAccountId: options?.agentAccountId,
|
||||||
|
|||||||
@@ -40,9 +40,13 @@ function buildSandboxBrowserResolvedConfig(params: {
|
|||||||
cdpPort: number;
|
cdpPort: number;
|
||||||
headless: boolean;
|
headless: boolean;
|
||||||
}): ResolvedBrowserConfig {
|
}): ResolvedBrowserConfig {
|
||||||
|
const controlHost = "127.0.0.1";
|
||||||
|
const controlUrl = `http://${controlHost}:${params.controlPort}`;
|
||||||
const cdpHost = "127.0.0.1";
|
const cdpHost = "127.0.0.1";
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl,
|
||||||
|
controlHost,
|
||||||
controlPort: params.controlPort,
|
controlPort: params.controlPort,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost,
|
cdpHost,
|
||||||
@@ -200,7 +204,7 @@ export async function ensureSandboxBrowser(params: {
|
|||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
bridgeUrl: resolvedBridge.baseUrl,
|
controlUrl: resolvedBridge.baseUrl,
|
||||||
noVncUrl,
|
noVncUrl,
|
||||||
containerName,
|
containerName,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -86,6 +86,11 @@ export function resolveSandboxBrowserConfig(params: {
|
|||||||
}): SandboxBrowserConfig {
|
}): SandboxBrowserConfig {
|
||||||
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
const agentBrowser = params.scope === "shared" ? undefined : params.agentBrowser;
|
||||||
const globalBrowser = params.globalBrowser;
|
const globalBrowser = params.globalBrowser;
|
||||||
|
const allowedControlUrls = agentBrowser?.allowedControlUrls ?? globalBrowser?.allowedControlUrls;
|
||||||
|
const allowedControlHosts =
|
||||||
|
agentBrowser?.allowedControlHosts ?? globalBrowser?.allowedControlHosts;
|
||||||
|
const allowedControlPorts =
|
||||||
|
agentBrowser?.allowedControlPorts ?? globalBrowser?.allowedControlPorts;
|
||||||
return {
|
return {
|
||||||
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
enabled: agentBrowser?.enabled ?? globalBrowser?.enabled ?? false,
|
||||||
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
image: agentBrowser?.image ?? globalBrowser?.image ?? DEFAULT_SANDBOX_BROWSER_IMAGE,
|
||||||
@@ -100,6 +105,18 @@ export function resolveSandboxBrowserConfig(params: {
|
|||||||
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
headless: agentBrowser?.headless ?? globalBrowser?.headless ?? false,
|
||||||
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
enableNoVnc: agentBrowser?.enableNoVnc ?? globalBrowser?.enableNoVnc ?? true,
|
||||||
allowHostControl: agentBrowser?.allowHostControl ?? globalBrowser?.allowHostControl ?? false,
|
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,
|
autoStart: agentBrowser?.autoStart ?? globalBrowser?.autoStart ?? true,
|
||||||
autoStartTimeoutMs:
|
autoStartTimeoutMs:
|
||||||
agentBrowser?.autoStartTimeoutMs ??
|
agentBrowser?.autoStartTimeoutMs ??
|
||||||
|
|||||||
@@ -87,6 +87,9 @@ export async function resolveSandboxContext(params: {
|
|||||||
docker: cfg.docker,
|
docker: cfg.docker,
|
||||||
tools: cfg.tools,
|
tools: cfg.tools,
|
||||||
browserAllowHostControl: cfg.browser.allowHostControl,
|
browserAllowHostControl: cfg.browser.allowHostControl,
|
||||||
|
browserAllowedControlUrls: cfg.browser.allowedControlUrls,
|
||||||
|
browserAllowedControlHosts: cfg.browser.allowedControlHosts,
|
||||||
|
browserAllowedControlPorts: cfg.browser.allowedControlPorts,
|
||||||
browser: browser ?? undefined,
|
browser: browser ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ export type SandboxBrowserConfig = {
|
|||||||
headless: boolean;
|
headless: boolean;
|
||||||
enableNoVnc: boolean;
|
enableNoVnc: boolean;
|
||||||
allowHostControl: boolean;
|
allowHostControl: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
autoStart: boolean;
|
autoStart: boolean;
|
||||||
autoStartTimeoutMs: number;
|
autoStartTimeoutMs: number;
|
||||||
};
|
};
|
||||||
@@ -60,7 +63,7 @@ export type SandboxConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type SandboxBrowserContext = {
|
export type SandboxBrowserContext = {
|
||||||
bridgeUrl: string;
|
controlUrl: string;
|
||||||
noVncUrl?: string;
|
noVncUrl?: string;
|
||||||
containerName: string;
|
containerName: string;
|
||||||
};
|
};
|
||||||
@@ -76,6 +79,9 @@ export type SandboxContext = {
|
|||||||
docker: SandboxDockerConfig;
|
docker: SandboxDockerConfig;
|
||||||
tools: SandboxToolPolicy;
|
tools: SandboxToolPolicy;
|
||||||
browserAllowHostControl: boolean;
|
browserAllowHostControl: boolean;
|
||||||
|
browserAllowedControlUrls?: string[];
|
||||||
|
browserAllowedControlHosts?: string[];
|
||||||
|
browserAllowedControlPorts?: number[];
|
||||||
browser?: SandboxBrowserContext;
|
browser?: SandboxBrowserContext;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ describe("acquireSessionWriteLock", () => {
|
|||||||
});
|
});
|
||||||
it("cleans up locks on SIGINT without removing other handlers", async () => {
|
it("cleans up locks on SIGINT without removing other handlers", async () => {
|
||||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
|
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> = [];
|
const killCalls: Array<NodeJS.Signals | undefined> = [];
|
||||||
let otherHandlerCalled = false;
|
let otherHandlerCalled = false;
|
||||||
|
|
||||||
|
|||||||
@@ -165,9 +165,12 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
workspaceDir?: string;
|
workspaceDir?: string;
|
||||||
workspaceAccess?: "none" | "ro" | "rw";
|
workspaceAccess?: "none" | "ro" | "rw";
|
||||||
agentWorkspaceMount?: string;
|
agentWorkspaceMount?: string;
|
||||||
browserBridgeUrl?: string;
|
browserControlUrl?: string;
|
||||||
browserNoVncUrl?: string;
|
browserNoVncUrl?: string;
|
||||||
hostBrowserAllowed?: boolean;
|
hostBrowserAllowed?: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
elevated?: {
|
elevated?: {
|
||||||
allowed: boolean;
|
allowed: boolean;
|
||||||
defaultLevel: "on" | "off" | "ask" | "full";
|
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
|
params.sandboxInfo.browserNoVncUrl
|
||||||
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
? `Sandbox browser observer (noVNC): ${params.sandboxInfo.browserNoVncUrl}`
|
||||||
: "",
|
: "",
|
||||||
@@ -425,6 +430,15 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
: params.sandboxInfo.hostBrowserAllowed === false
|
: params.sandboxInfo.hostBrowserAllowed === false
|
||||||
? "Host browser control: blocked."
|
? "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
|
params.sandboxInfo.elevated?.allowed
|
||||||
? "Elevated exec is available for this session."
|
? "Elevated exec is available for this session."
|
||||||
: "",
|
: "",
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const BROWSER_TOOL_ACTIONS = [
|
|||||||
"act",
|
"act",
|
||||||
] as const;
|
] 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_FORMATS = ["aria", "ai"] as const;
|
||||||
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
const BROWSER_SNAPSHOT_MODES = ["efficient"] as const;
|
||||||
@@ -86,6 +86,7 @@ export const BrowserToolSchema = Type.Object({
|
|||||||
target: optionalStringEnum(BROWSER_TARGETS),
|
target: optionalStringEnum(BROWSER_TARGETS),
|
||||||
node: Type.Optional(Type.String()),
|
node: Type.Optional(Type.String()),
|
||||||
profile: Type.Optional(Type.String()),
|
profile: Type.Optional(Type.String()),
|
||||||
|
controlUrl: Type.Optional(Type.String()),
|
||||||
targetUrl: Type.Optional(Type.String()),
|
targetUrl: Type.Optional(Type.String()),
|
||||||
targetId: Type.Optional(Type.String()),
|
targetId: Type.Optional(Type.String()),
|
||||||
limit: Type.Optional(Type.Number()),
|
limit: Type.Optional(Type.Number()),
|
||||||
|
|||||||
@@ -28,7 +28,23 @@ vi.mock("../../browser/client.js", () => browserClientMocks);
|
|||||||
const browserConfigMocks = vi.hoisted(() => ({
|
const browserConfigMocks = vi.hoisted(() => ({
|
||||||
resolveBrowserConfig: vi.fn(() => ({
|
resolveBrowserConfig: vi.fn(() => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 18791,
|
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);
|
vi.mock("../../browser/config.js", () => browserConfigMocks);
|
||||||
@@ -83,7 +99,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
format: "ai",
|
format: "ai",
|
||||||
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
maxChars: DEFAULT_AI_SNAPSHOT_MAX_CHARS,
|
||||||
@@ -101,7 +117,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
maxChars: override,
|
maxChars: override,
|
||||||
}),
|
}),
|
||||||
@@ -125,7 +141,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
const tool = createBrowserTool();
|
const tool = createBrowserTool();
|
||||||
await tool.execute?.(null, { action: "profiles" });
|
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 () => {
|
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" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai", refs: "aria" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
format: "ai",
|
format: "ai",
|
||||||
refs: "aria",
|
refs: "aria",
|
||||||
@@ -149,7 +165,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
await tool.execute?.(null, { action: "snapshot", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
mode: "efficient",
|
mode: "efficient",
|
||||||
}),
|
}),
|
||||||
@@ -169,11 +185,11 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to host when using profile=chrome (even in sandboxed sessions)", async () => {
|
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" });
|
await tool.execute?.(null, { action: "snapshot", profile: "chrome", snapshotFormat: "ai" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserSnapshot).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
profile: "chrome",
|
profile: "chrome",
|
||||||
}),
|
}),
|
||||||
@@ -204,7 +220,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
expect(browserClientMocks.browserStatus).not.toHaveBeenCalled();
|
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([
|
nodesUtilsMocks.listNodes.mockResolvedValue([
|
||||||
{
|
{
|
||||||
nodeId: "node-1",
|
nodeId: "node-1",
|
||||||
@@ -214,7 +230,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
commands: ["browser.proxy"],
|
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" });
|
await tool.execute?.(null, { action: "status" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||||
@@ -238,7 +254,7 @@ describe("browser tool snapshot maxChars", () => {
|
|||||||
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
await tool.execute?.(null, { action: "status", profile: "chrome" });
|
||||||
|
|
||||||
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
expect(browserClientMocks.browserStatus).toHaveBeenCalledWith(
|
||||||
undefined,
|
"http://127.0.0.1:18791",
|
||||||
expect.objectContaining({ profile: "chrome" }),
|
expect.objectContaining({ profile: "chrome" }),
|
||||||
);
|
);
|
||||||
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
expect(gatewayMocks.callGatewayTool).not.toHaveBeenCalled();
|
||||||
|
|||||||
@@ -55,8 +55,9 @@ function isBrowserNode(node: NodeListNode) {
|
|||||||
|
|
||||||
async function resolveBrowserNodeTarget(params: {
|
async function resolveBrowserNodeTarget(params: {
|
||||||
requestedNode?: string;
|
requestedNode?: string;
|
||||||
target?: "sandbox" | "host" | "node";
|
target?: "sandbox" | "host" | "custom" | "node";
|
||||||
sandboxBridgeUrl?: string;
|
controlUrl?: string;
|
||||||
|
defaultControlUrl?: string;
|
||||||
}): Promise<BrowserNodeTarget | null> {
|
}): Promise<BrowserNodeTarget | null> {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const policy = cfg.gateway?.nodes?.browser;
|
const policy = cfg.gateway?.nodes?.browser;
|
||||||
@@ -67,9 +68,10 @@ async function resolveBrowserNodeTarget(params: {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (params.sandboxBridgeUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
if (params.defaultControlUrl?.trim() && params.target !== "node" && !params.requestedNode) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
if (params.controlUrl?.trim()) return null;
|
||||||
if (params.target && params.target !== "node") return null;
|
if (params.target && params.target !== "node") return null;
|
||||||
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
if (mode === "manual" && params.target !== "node" && !params.requestedNode) {
|
||||||
return null;
|
return null;
|
||||||
@@ -185,22 +187,70 @@ function applyProxyPaths(result: unknown, mapping: Map<string, string>) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolveBrowserBaseUrl(params: {
|
function resolveBrowserBaseUrl(params: {
|
||||||
target?: "sandbox" | "host";
|
target?: "sandbox" | "host" | "custom";
|
||||||
sandboxBridgeUrl?: string;
|
controlUrl?: string;
|
||||||
|
defaultControlUrl?: string;
|
||||||
allowHostControl?: boolean;
|
allowHostControl?: boolean;
|
||||||
}): string | undefined {
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
|
}) {
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
const resolved = resolveBrowserConfig(cfg.browser);
|
||||||
const normalizedSandbox = params.sandboxBridgeUrl?.trim() ?? "";
|
const normalizedControlUrl = params.controlUrl?.trim() ?? "";
|
||||||
const target = params.target ?? (normalizedSandbox ? "sandbox" : "host");
|
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 (target === "sandbox") {
|
||||||
if (!normalizedSandbox) {
|
if (!normalizedDefault) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'Sandbox browser is unavailable. Enable agents.defaults.sandbox.browser.enabled or use target="host" if allowed.',
|
'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) {
|
if (params.allowHostControl === false) {
|
||||||
@@ -211,16 +261,27 @@ function resolveBrowserBaseUrl(params: {
|
|||||||
"Browser control is disabled. Set browser.enabled=true in ~/.clawdbot/clawdbot.json.",
|
"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?: {
|
export function createBrowserTool(opts?: {
|
||||||
sandboxBridgeUrl?: string;
|
defaultControlUrl?: string;
|
||||||
allowHostControl?: boolean;
|
allowHostControl?: boolean;
|
||||||
|
allowedControlUrls?: string[];
|
||||||
|
allowedControlHosts?: string[];
|
||||||
|
allowedControlPorts?: number[];
|
||||||
}): AnyAgentTool {
|
}): AnyAgentTool {
|
||||||
const targetDefault = opts?.sandboxBridgeUrl ? "sandbox" : "host";
|
const targetDefault = opts?.defaultControlUrl ? "sandbox" : "host";
|
||||||
const hostHint =
|
const hostHint =
|
||||||
opts?.allowHostControl === false ? "Host target blocked by policy." : "Host target allowed.";
|
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 {
|
return {
|
||||||
label: "Browser",
|
label: "Browser",
|
||||||
name: "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).",
|
"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.',
|
'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.",
|
"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,
|
hostHint,
|
||||||
|
allowlistHint,
|
||||||
].join(" "),
|
].join(" "),
|
||||||
parameters: BrowserToolSchema,
|
parameters: BrowserToolSchema,
|
||||||
execute: async (_toolCallId, args) => {
|
execute: async (_toolCallId, args) => {
|
||||||
const params = args as Record<string, unknown>;
|
const params = args as Record<string, unknown>;
|
||||||
const action = readStringParam(params, "action", { required: true });
|
const action = readStringParam(params, "action", { required: true });
|
||||||
|
const controlUrl = readStringParam(params, "controlUrl");
|
||||||
const profile = readStringParam(params, "profile");
|
const profile = readStringParam(params, "profile");
|
||||||
const requestedNode = readStringParam(params, "node");
|
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") {
|
if (controlUrl?.trim() && (target === "node" || requestedNode)) {
|
||||||
throw new Error('node is only supported with target="node".');
|
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.
|
// Chrome extension relay takeover is a host Chrome feature; prefer host unless explicitly targeting a node.
|
||||||
target = "host";
|
target = "host";
|
||||||
}
|
}
|
||||||
@@ -256,16 +328,21 @@ export function createBrowserTool(opts?: {
|
|||||||
const nodeTarget = await resolveBrowserNodeTarget({
|
const nodeTarget = await resolveBrowserNodeTarget({
|
||||||
requestedNode: requestedNode ?? undefined,
|
requestedNode: requestedNode ?? undefined,
|
||||||
target,
|
target,
|
||||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
controlUrl,
|
||||||
|
defaultControlUrl: opts?.defaultControlUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
const resolvedTarget = target === "node" ? undefined : target;
|
const resolvedTarget = target === "node" ? undefined : target;
|
||||||
const baseUrl = nodeTarget
|
const baseUrl = nodeTarget
|
||||||
? undefined
|
? ""
|
||||||
: resolveBrowserBaseUrl({
|
: resolveBrowserBaseUrl({
|
||||||
target: resolvedTarget,
|
target: resolvedTarget,
|
||||||
sandboxBridgeUrl: opts?.sandboxBridgeUrl,
|
controlUrl,
|
||||||
|
defaultControlUrl: opts?.defaultControlUrl,
|
||||||
allowHostControl: opts?.allowHostControl,
|
allowHostControl: opts?.allowHostControl,
|
||||||
|
allowedControlUrls: opts?.allowedControlUrls,
|
||||||
|
allowedControlHosts: opts?.allowedControlHosts,
|
||||||
|
allowedControlPorts: opts?.allowedControlPorts,
|
||||||
});
|
});
|
||||||
|
|
||||||
const proxyRequest = nodeTarget
|
const proxyRequest = nodeTarget
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import express from "express";
|
|||||||
|
|
||||||
import type { ResolvedBrowserConfig } from "./config.js";
|
import type { ResolvedBrowserConfig } from "./config.js";
|
||||||
import { registerBrowserRoutes } from "./routes/index.js";
|
import { registerBrowserRoutes } from "./routes/index.js";
|
||||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
|
||||||
import {
|
import {
|
||||||
type BrowserServerState,
|
type BrowserServerState,
|
||||||
createBrowserRouteContext,
|
createBrowserRouteContext,
|
||||||
@@ -51,7 +50,7 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
getState: () => state,
|
getState: () => state,
|
||||||
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
onEnsureAttachTarget: params.onEnsureAttachTarget,
|
||||||
});
|
});
|
||||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
registerBrowserRoutes(app, ctx);
|
||||||
|
|
||||||
const server = await new Promise<Server>((resolve, reject) => {
|
const server = await new Promise<Server>((resolve, reject) => {
|
||||||
const s = app.listen(port, host, () => resolve(s));
|
const s = app.listen(port, host, () => resolve(s));
|
||||||
@@ -62,9 +61,11 @@ export async function startBrowserBridgeServer(params: {
|
|||||||
const resolvedPort = address?.port ?? port;
|
const resolvedPort = address?.port ?? port;
|
||||||
state.server = server;
|
state.server = server;
|
||||||
state.port = resolvedPort;
|
state.port = resolvedPort;
|
||||||
|
state.resolved.controlHost = host;
|
||||||
state.resolved.controlPort = resolvedPort;
|
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 };
|
return { server, port: resolvedPort, baseUrl, state };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,6 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
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 = {
|
export type BrowserFormField = {
|
||||||
ref: string;
|
ref: string;
|
||||||
type: string;
|
type: string;
|
||||||
@@ -98,15 +92,11 @@ export type BrowserDownloadPayload = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function browserNavigate(
|
export async function browserNavigate(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: { url: string; targetId?: string; profile?: string },
|
||||||
url: string;
|
|
||||||
targetId?: string;
|
|
||||||
profile?: string;
|
|
||||||
},
|
|
||||||
): Promise<BrowserActionTabResult> {
|
): Promise<BrowserActionTabResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTabResult>(withBaseUrl(baseUrl, `/navigate${q}`), {
|
return await fetchBrowserJson<BrowserActionTabResult>(`${baseUrl}/navigate${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
body: JSON.stringify({ url: opts.url, targetId: opts.targetId }),
|
||||||
@@ -115,7 +105,7 @@ export async function browserNavigate(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserArmDialog(
|
export async function browserArmDialog(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
accept: boolean;
|
accept: boolean;
|
||||||
promptText?: string;
|
promptText?: string;
|
||||||
@@ -125,7 +115,7 @@ export async function browserArmDialog(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/hooks/dialog${q}`), {
|
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/hooks/dialog${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -139,7 +129,7 @@ export async function browserArmDialog(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserArmFileChooser(
|
export async function browserArmFileChooser(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
paths: string[];
|
paths: string[];
|
||||||
ref?: string;
|
ref?: string;
|
||||||
@@ -151,7 +141,7 @@ export async function browserArmFileChooser(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -167,7 +157,7 @@ export async function browserArmFileChooser(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserWaitForDownload(
|
export async function browserWaitForDownload(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
path?: string;
|
path?: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -180,7 +170,7 @@ export async function browserWaitForDownload(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
download: BrowserDownloadPayload;
|
download: BrowserDownloadPayload;
|
||||||
}>(withBaseUrl(baseUrl, `/wait/download${q}`), {
|
}>(`${baseUrl}/wait/download${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -193,7 +183,7 @@ export async function browserWaitForDownload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserDownload(
|
export async function browserDownload(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
ref: string;
|
ref: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -207,7 +197,7 @@ export async function browserDownload(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
download: BrowserDownloadPayload;
|
download: BrowserDownloadPayload;
|
||||||
}>(withBaseUrl(baseUrl, `/download${q}`), {
|
}>(`${baseUrl}/download${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -221,12 +211,12 @@ export async function browserDownload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserAct(
|
export async function browserAct(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
req: BrowserActRequest,
|
req: BrowserActRequest,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserActResponse> {
|
): Promise<BrowserActResponse> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
return await fetchBrowserJson<BrowserActResponse>(withBaseUrl(baseUrl, `/act${q}`), {
|
return await fetchBrowserJson<BrowserActResponse>(`${baseUrl}/act${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(req),
|
body: JSON.stringify(req),
|
||||||
@@ -235,7 +225,7 @@ export async function browserAct(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserScreenshotAction(
|
export async function browserScreenshotAction(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
fullPage?: boolean;
|
fullPage?: boolean;
|
||||||
@@ -246,7 +236,7 @@ export async function browserScreenshotAction(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/screenshot${q}`), {
|
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/screenshot${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -10,14 +10,8 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
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(
|
export async function browserConsoleMessages(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { level?: string; targetId?: string; profile?: string } = {},
|
opts: { level?: string; targetId?: string; profile?: string } = {},
|
||||||
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -29,15 +23,15 @@ export async function browserConsoleMessages(
|
|||||||
ok: true;
|
ok: true;
|
||||||
messages: BrowserConsoleMessage[];
|
messages: BrowserConsoleMessage[];
|
||||||
targetId: string;
|
targetId: string;
|
||||||
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
|
}>(`${baseUrl}/console${suffix}`, { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserPdfSave(
|
export async function browserPdfSave(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/pdf${q}`), {
|
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/pdf${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId }),
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
@@ -46,7 +40,7 @@ export async function browserPdfSave(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserPageErrors(
|
export async function browserPageErrors(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
opts: { targetId?: string; clear?: boolean; profile?: string } = {},
|
||||||
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
): Promise<{ ok: true; targetId: string; errors: BrowserPageError[] }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -58,11 +52,11 @@ export async function browserPageErrors(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
errors: BrowserPageError[];
|
errors: BrowserPageError[];
|
||||||
}>(withBaseUrl(baseUrl, `/errors${suffix}`), { timeoutMs: 20000 });
|
}>(`${baseUrl}/errors${suffix}`, { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserRequests(
|
export async function browserRequests(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
filter?: string;
|
filter?: string;
|
||||||
@@ -80,11 +74,11 @@ export async function browserRequests(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
requests: BrowserNetworkRequest[];
|
requests: BrowserNetworkRequest[];
|
||||||
}>(withBaseUrl(baseUrl, `/requests${suffix}`), { timeoutMs: 20000 });
|
}>(`${baseUrl}/requests${suffix}`, { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTraceStart(
|
export async function browserTraceStart(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
screenshots?: boolean;
|
screenshots?: boolean;
|
||||||
@@ -94,7 +88,7 @@ export async function browserTraceStart(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/trace/start${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/trace/start${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -108,11 +102,11 @@ export async function browserTraceStart(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTraceStop(
|
export async function browserTraceStop(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; path?: string; profile?: string } = {},
|
opts: { targetId?: string; path?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionPathResult> {
|
): Promise<BrowserActionPathResult> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionPathResult>(withBaseUrl(baseUrl, `/trace/stop${q}`), {
|
return await fetchBrowserJson<BrowserActionPathResult>(`${baseUrl}/trace/stop${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
body: JSON.stringify({ targetId: opts.targetId, path: opts.path }),
|
||||||
@@ -121,11 +115,11 @@ export async function browserTraceStop(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserHighlight(
|
export async function browserHighlight(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { ref: string; targetId?: string; profile?: string },
|
opts: { ref: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/highlight${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/highlight${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
body: JSON.stringify({ targetId: opts.targetId, ref: opts.ref }),
|
||||||
@@ -134,7 +128,7 @@ export async function browserHighlight(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserResponseBody(
|
export async function browserResponseBody(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
url: string;
|
url: string;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -164,7 +158,7 @@ export async function browserResponseBody(
|
|||||||
body: string;
|
body: string;
|
||||||
truncated?: boolean;
|
truncated?: boolean;
|
||||||
};
|
};
|
||||||
}>(withBaseUrl(baseUrl, `/response/body${q}`), {
|
}>(`${baseUrl}/response/body${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
|
|||||||
@@ -5,14 +5,8 @@ function buildProfileQuery(profile?: string): string {
|
|||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
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(
|
export async function browserCookies(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
): Promise<{ ok: true; targetId: string; cookies: unknown[] }> {
|
||||||
const q = new URLSearchParams();
|
const q = new URLSearchParams();
|
||||||
@@ -23,11 +17,11 @@ export async function browserCookies(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
cookies: unknown[];
|
cookies: unknown[];
|
||||||
}>(withBaseUrl(baseUrl, `/cookies${suffix}`), { timeoutMs: 20000 });
|
}>(`${baseUrl}/cookies${suffix}`, { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserCookiesSet(
|
export async function browserCookiesSet(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
cookie: Record<string, unknown>;
|
cookie: Record<string, unknown>;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -35,7 +29,7 @@ export async function browserCookiesSet(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/set${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/set${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
body: JSON.stringify({ targetId: opts.targetId, cookie: opts.cookie }),
|
||||||
@@ -44,11 +38,11 @@ export async function browserCookiesSet(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserCookiesClear(
|
export async function browserCookiesClear(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/cookies/clear${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/cookies/clear${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId }),
|
body: JSON.stringify({ targetId: opts.targetId }),
|
||||||
@@ -57,7 +51,7 @@ export async function browserCookiesClear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageGet(
|
export async function browserStorageGet(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
kind: "local" | "session";
|
kind: "local" | "session";
|
||||||
key?: string;
|
key?: string;
|
||||||
@@ -74,11 +68,11 @@ export async function browserStorageGet(
|
|||||||
ok: true;
|
ok: true;
|
||||||
targetId: string;
|
targetId: string;
|
||||||
values: Record<string, string>;
|
values: Record<string, string>;
|
||||||
}>(withBaseUrl(baseUrl, `/storage/${opts.kind}${suffix}`), { timeoutMs: 20000 });
|
}>(`${baseUrl}/storage/${opts.kind}${suffix}`, { timeoutMs: 20000 });
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageSet(
|
export async function browserStorageSet(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
kind: "local" | "session";
|
kind: "local" | "session";
|
||||||
key: string;
|
key: string;
|
||||||
@@ -88,28 +82,25 @@ export async function browserStorageSet(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/storage/${opts.kind}/set${q}`, {
|
||||||
withBaseUrl(baseUrl, `/storage/${opts.kind}/set${q}`),
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
targetId: opts.targetId,
|
||||||
body: JSON.stringify({
|
key: opts.key,
|
||||||
targetId: opts.targetId,
|
value: opts.value,
|
||||||
key: opts.key,
|
}),
|
||||||
value: opts.value,
|
timeoutMs: 20000,
|
||||||
}),
|
});
|
||||||
timeoutMs: 20000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserStorageClear(
|
export async function browserStorageClear(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
opts: { kind: "local" | "session"; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
return await fetchBrowserJson<BrowserActionTargetOk>(
|
||||||
withBaseUrl(baseUrl, `/storage/${opts.kind}/clear${q}`),
|
`${baseUrl}/storage/${opts.kind}/clear${q}`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -120,11 +111,11 @@ export async function browserStorageClear(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetOffline(
|
export async function browserSetOffline(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { offline: boolean; targetId?: string; profile?: string },
|
opts: { offline: boolean; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/offline${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/offline${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
body: JSON.stringify({ targetId: opts.targetId, offline: opts.offline }),
|
||||||
@@ -133,7 +124,7 @@ export async function browserSetOffline(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetHeaders(
|
export async function browserSetHeaders(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
headers: Record<string, string>;
|
headers: Record<string, string>;
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -141,7 +132,7 @@ export async function browserSetHeaders(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/headers${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/headers${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
body: JSON.stringify({ targetId: opts.targetId, headers: opts.headers }),
|
||||||
@@ -150,7 +141,7 @@ export async function browserSetHeaders(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetHttpCredentials(
|
export async function browserSetHttpCredentials(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
username?: string;
|
username?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
@@ -160,24 +151,21 @@ export async function browserSetHttpCredentials(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/credentials${q}`, {
|
||||||
withBaseUrl(baseUrl, `/set/credentials${q}`),
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
targetId: opts.targetId,
|
||||||
body: JSON.stringify({
|
username: opts.username,
|
||||||
targetId: opts.targetId,
|
password: opts.password,
|
||||||
username: opts.username,
|
clear: opts.clear,
|
||||||
password: opts.password,
|
}),
|
||||||
clear: opts.clear,
|
timeoutMs: 20000,
|
||||||
}),
|
});
|
||||||
timeoutMs: 20000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetGeolocation(
|
export async function browserSetGeolocation(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
latitude?: number;
|
latitude?: number;
|
||||||
longitude?: number;
|
longitude?: number;
|
||||||
@@ -189,26 +177,23 @@ export async function browserSetGeolocation(
|
|||||||
} = {},
|
} = {},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/geolocation${q}`, {
|
||||||
withBaseUrl(baseUrl, `/set/geolocation${q}`),
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
targetId: opts.targetId,
|
||||||
body: JSON.stringify({
|
latitude: opts.latitude,
|
||||||
targetId: opts.targetId,
|
longitude: opts.longitude,
|
||||||
latitude: opts.latitude,
|
accuracy: opts.accuracy,
|
||||||
longitude: opts.longitude,
|
origin: opts.origin,
|
||||||
accuracy: opts.accuracy,
|
clear: opts.clear,
|
||||||
origin: opts.origin,
|
}),
|
||||||
clear: opts.clear,
|
timeoutMs: 20000,
|
||||||
}),
|
});
|
||||||
timeoutMs: 20000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetMedia(
|
export async function browserSetMedia(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
colorScheme: "dark" | "light" | "no-preference" | "none";
|
colorScheme: "dark" | "light" | "no-preference" | "none";
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -216,7 +201,7 @@ export async function browserSetMedia(
|
|||||||
},
|
},
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/media${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/media${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -228,11 +213,11 @@ export async function browserSetMedia(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetTimezone(
|
export async function browserSetTimezone(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { timezoneId: string; targetId?: string; profile?: string },
|
opts: { timezoneId: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/timezone${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/timezone${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -244,11 +229,11 @@ export async function browserSetTimezone(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetLocale(
|
export async function browserSetLocale(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { locale: string; targetId?: string; profile?: string },
|
opts: { locale: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/locale${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/locale${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
body: JSON.stringify({ targetId: opts.targetId, locale: opts.locale }),
|
||||||
@@ -257,11 +242,11 @@ export async function browserSetLocale(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSetDevice(
|
export async function browserSetDevice(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { name: string; targetId?: string; profile?: string },
|
opts: { name: string; targetId?: string; profile?: string },
|
||||||
): Promise<BrowserActionTargetOk> {
|
): Promise<BrowserActionTargetOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionTargetOk>(withBaseUrl(baseUrl, `/set/device${q}`), {
|
return await fetchBrowserJson<BrowserActionTargetOk>(`${baseUrl}/set/device${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
body: JSON.stringify({ targetId: opts.targetId, name: opts.name }),
|
||||||
@@ -270,11 +255,11 @@ export async function browserSetDevice(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserClearPermissions(
|
export async function browserClearPermissions(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: { targetId?: string; profile?: string } = {},
|
opts: { targetId?: string; profile?: string } = {},
|
||||||
): Promise<BrowserActionOk> {
|
): Promise<BrowserActionOk> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson<BrowserActionOk>(withBaseUrl(baseUrl, `/set/geolocation${q}`), {
|
return await fetchBrowserJson<BrowserActionOk>(`${baseUrl}/set/geolocation${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
body: JSON.stringify({ targetId: opts.targetId, clear: true }),
|
||||||
|
|||||||
+68
-81
@@ -1,44 +1,57 @@
|
|||||||
|
import { extractErrorCode, formatErrorMessage } from "../infra/errors.js";
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { formatCliCommand } from "../cli/command-format.js";
|
import { formatCliCommand } from "../cli/command-format.js";
|
||||||
import {
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
createBrowserControlContext,
|
|
||||||
startBrowserControlServiceFromConfig,
|
|
||||||
} from "./control-service.js";
|
|
||||||
import { createBrowserRouteDispatcher } from "./routes/dispatcher.js";
|
|
||||||
|
|
||||||
function isAbsoluteHttp(url: string): boolean {
|
let cachedConfigToken: string | null | undefined = undefined;
|
||||||
return /^https?:\/\//i.test(url.trim());
|
|
||||||
|
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 {
|
function enhanceBrowserFetchError(url: string, err: unknown, timeoutMs: number): Error {
|
||||||
const hint = isAbsoluteHttp(url)
|
const cause = unwrapCause(err);
|
||||||
? "If this is a sandboxed session, ensure the sandbox browser is running and try again."
|
const code = extractErrorCode(cause) ?? extractErrorCode(err) ?? "";
|
||||||
: `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
|
||||||
const msg = String(err);
|
const hint = `Start (or restart) the Clawdbot gateway (Clawdbot.app menubar, or \`${formatCliCommand("clawdbot gateway")}\`) and try again.`;
|
||||||
if (msg.toLowerCase().includes("timed out") || msg.toLowerCase().includes("timeout")) {
|
|
||||||
|
if (code === "ECONNREFUSED") {
|
||||||
return new Error(
|
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})`);
|
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}`,
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>(
|
export async function fetchBrowserJson<T>(
|
||||||
@@ -46,58 +59,32 @@ export async function fetchBrowserJson<T>(
|
|||||||
init?: RequestInit & { timeoutMs?: number },
|
init?: RequestInit & { timeoutMs?: number },
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const timeoutMs = init?.timeoutMs ?? 5000;
|
const timeoutMs = init?.timeoutMs ?? 5000;
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
||||||
|
let res: Response;
|
||||||
try {
|
try {
|
||||||
if (isAbsoluteHttp(url)) {
|
const token = getBrowserControlToken();
|
||||||
return await fetchHttpJson<T>(url, init ? { ...init, timeoutMs } : { timeoutMs });
|
const mergedHeaders = (() => {
|
||||||
}
|
if (!token) return init?.headers;
|
||||||
const started = await startBrowserControlServiceFromConfig();
|
const h = new Headers(init?.headers ?? {});
|
||||||
if (!started) {
|
if (!h.has("Authorization")) {
|
||||||
throw new Error("browser control disabled");
|
h.set("Authorization", `Bearer ${token}`);
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
}
|
return h;
|
||||||
const dispatchPromise = dispatcher.dispatch({
|
})();
|
||||||
method:
|
res = await fetch(url, {
|
||||||
init?.method?.toUpperCase() === "DELETE"
|
...init,
|
||||||
? "DELETE"
|
...(mergedHeaders ? { headers: mergedHeaders } : {}),
|
||||||
: init?.method?.toUpperCase() === "POST"
|
signal: ctrl.signal,
|
||||||
? "POST"
|
} as RequestInit);
|
||||||
: "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;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw enhanceBrowserFetchError(url, err, timeoutMs);
|
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;
|
||||||
}
|
}
|
||||||
@@ -16,7 +16,7 @@ describe("browser client", () => {
|
|||||||
vi.unstubAllGlobals();
|
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"), {
|
const refused = Object.assign(new Error("connect ECONNREFUSED 127.0.0.1"), {
|
||||||
code: "ECONNREFUSED",
|
code: "ECONNREFUSED",
|
||||||
});
|
});
|
||||||
@@ -26,7 +26,7 @@ describe("browser client", () => {
|
|||||||
|
|
||||||
vi.stubGlobal("fetch", vi.fn().mockRejectedValue(fetchFailed));
|
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 () => {
|
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);
|
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 () => {
|
it("surfaces non-2xx responses with body text", async () => {
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@@ -46,7 +81,7 @@ describe("browser client", () => {
|
|||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
browserSnapshot("http://127.0.0.1:18791", { format: "aria", limit: 1 }),
|
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 () => {
|
it("adds labels + efficient mode query params to snapshots", async () => {
|
||||||
@@ -220,6 +255,7 @@ describe("browser client", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
json: async () => ({
|
json: async () => ({
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
running: true,
|
running: true,
|
||||||
pid: 1,
|
pid: 1,
|
||||||
cdpPort: 18792,
|
cdpPort: 18792,
|
||||||
|
|||||||
+51
-56
@@ -1,7 +1,10 @@
|
|||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
import { fetchBrowserJson } from "./client-fetch.js";
|
import { fetchBrowserJson } from "./client-fetch.js";
|
||||||
|
import { resolveBrowserConfig } from "./config.js";
|
||||||
|
|
||||||
export type BrowserStatus = {
|
export type BrowserStatus = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
controlUrl: string;
|
||||||
profile?: string;
|
profile?: string;
|
||||||
running: boolean;
|
running: boolean;
|
||||||
cdpReady?: boolean;
|
cdpReady?: boolean;
|
||||||
@@ -86,64 +89,59 @@ export type SnapshotResult =
|
|||||||
imageType?: "png" | "jpeg";
|
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 {
|
function buildProfileQuery(profile?: string): string {
|
||||||
return profile ? `?profile=${encodeURIComponent(profile)}` : "";
|
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(
|
export async function browserStatus(
|
||||||
baseUrl?: string,
|
baseUrl: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserStatus> {
|
): Promise<BrowserStatus> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
return await fetchBrowserJson<BrowserStatus>(withBaseUrl(baseUrl, `/${q}`), {
|
return await fetchBrowserJson<BrowserStatus>(`${baseUrl}/${q}`, {
|
||||||
timeoutMs: 1500,
|
timeoutMs: 1500,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserProfiles(baseUrl?: string): Promise<ProfileStatus[]> {
|
export async function browserProfiles(baseUrl: string): Promise<ProfileStatus[]> {
|
||||||
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(
|
const res = await fetchBrowserJson<{ profiles: ProfileStatus[] }>(`${baseUrl}/profiles`, {
|
||||||
withBaseUrl(baseUrl, `/profiles`),
|
timeoutMs: 3000,
|
||||||
{
|
});
|
||||||
timeoutMs: 3000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return res.profiles ?? [];
|
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);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/start${q}`), {
|
await fetchBrowserJson(`${baseUrl}/start${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 15000,
|
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);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/stop${q}`), {
|
await fetchBrowserJson(`${baseUrl}/stop${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
timeoutMs: 15000,
|
timeoutMs: 15000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserResetProfile(
|
export async function browserResetProfile(
|
||||||
baseUrl?: string,
|
baseUrl: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserResetProfileResult> {
|
): Promise<BrowserResetProfileResult> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
return await fetchBrowserJson<BrowserResetProfileResult>(
|
return await fetchBrowserJson<BrowserResetProfileResult>(`${baseUrl}/reset-profile${q}`, {
|
||||||
withBaseUrl(baseUrl, `/reset-profile${q}`),
|
method: "POST",
|
||||||
{
|
timeoutMs: 20000,
|
||||||
method: "POST",
|
});
|
||||||
timeoutMs: 20000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserCreateProfileResult = {
|
export type BrowserCreateProfileResult = {
|
||||||
@@ -156,7 +154,7 @@ export type BrowserCreateProfileResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function browserCreateProfile(
|
export async function browserCreateProfile(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
color?: string;
|
color?: string;
|
||||||
@@ -164,20 +162,17 @@ export async function browserCreateProfile(
|
|||||||
driver?: "clawd" | "extension";
|
driver?: "clawd" | "extension";
|
||||||
},
|
},
|
||||||
): Promise<BrowserCreateProfileResult> {
|
): Promise<BrowserCreateProfileResult> {
|
||||||
return await fetchBrowserJson<BrowserCreateProfileResult>(
|
return await fetchBrowserJson<BrowserCreateProfileResult>(`${baseUrl}/profiles/create`, {
|
||||||
withBaseUrl(baseUrl, `/profiles/create`),
|
method: "POST",
|
||||||
{
|
headers: { "Content-Type": "application/json" },
|
||||||
method: "POST",
|
body: JSON.stringify({
|
||||||
headers: { "Content-Type": "application/json" },
|
name: opts.name,
|
||||||
body: JSON.stringify({
|
color: opts.color,
|
||||||
name: opts.name,
|
cdpUrl: opts.cdpUrl,
|
||||||
color: opts.color,
|
driver: opts.driver,
|
||||||
cdpUrl: opts.cdpUrl,
|
}),
|
||||||
driver: opts.driver,
|
timeoutMs: 10000,
|
||||||
}),
|
});
|
||||||
timeoutMs: 10000,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BrowserDeleteProfileResult = {
|
export type BrowserDeleteProfileResult = {
|
||||||
@@ -187,11 +182,11 @@ export type BrowserDeleteProfileResult = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function browserDeleteProfile(
|
export async function browserDeleteProfile(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
profile: string,
|
profile: string,
|
||||||
): Promise<BrowserDeleteProfileResult> {
|
): Promise<BrowserDeleteProfileResult> {
|
||||||
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
return await fetchBrowserJson<BrowserDeleteProfileResult>(
|
||||||
withBaseUrl(baseUrl, `/profiles/${encodeURIComponent(profile)}`),
|
`${baseUrl}/profiles/${encodeURIComponent(profile)}`,
|
||||||
{
|
{
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
timeoutMs: 20000,
|
timeoutMs: 20000,
|
||||||
@@ -200,24 +195,24 @@ export async function browserDeleteProfile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTabs(
|
export async function browserTabs(
|
||||||
baseUrl?: string,
|
baseUrl: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserTab[]> {
|
): Promise<BrowserTab[]> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
const res = await fetchBrowserJson<{ running: boolean; tabs: BrowserTab[] }>(
|
||||||
withBaseUrl(baseUrl, `/tabs${q}`),
|
`${baseUrl}/tabs${q}`,
|
||||||
{ timeoutMs: 3000 },
|
{ timeoutMs: 3000 },
|
||||||
);
|
);
|
||||||
return res.tabs ?? [];
|
return res.tabs ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserOpenTab(
|
export async function browserOpenTab(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
url: string,
|
url: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<BrowserTab> {
|
): Promise<BrowserTab> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
return await fetchBrowserJson<BrowserTab>(withBaseUrl(baseUrl, `/tabs/open${q}`), {
|
return await fetchBrowserJson<BrowserTab>(`${baseUrl}/tabs/open${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ url }),
|
body: JSON.stringify({ url }),
|
||||||
@@ -226,12 +221,12 @@ export async function browserOpenTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserFocusTab(
|
export async function browserFocusTab(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/focus${q}`), {
|
await fetchBrowserJson(`${baseUrl}/tabs/focus${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ targetId }),
|
body: JSON.stringify({ targetId }),
|
||||||
@@ -240,19 +235,19 @@ export async function browserFocusTab(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserCloseTab(
|
export async function browserCloseTab(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
targetId: string,
|
targetId: string,
|
||||||
opts?: { profile?: string },
|
opts?: { profile?: string },
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const q = buildProfileQuery(opts?.profile);
|
const q = buildProfileQuery(opts?.profile);
|
||||||
await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/${encodeURIComponent(targetId)}${q}`), {
|
await fetchBrowserJson(`${baseUrl}/tabs/${encodeURIComponent(targetId)}${q}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
timeoutMs: 5000,
|
timeoutMs: 5000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function browserTabAction(
|
export async function browserTabAction(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
action: "list" | "new" | "close" | "select";
|
action: "list" | "new" | "close" | "select";
|
||||||
index?: number;
|
index?: number;
|
||||||
@@ -260,7 +255,7 @@ export async function browserTabAction(
|
|||||||
},
|
},
|
||||||
): Promise<unknown> {
|
): Promise<unknown> {
|
||||||
const q = buildProfileQuery(opts.profile);
|
const q = buildProfileQuery(opts.profile);
|
||||||
return await fetchBrowserJson(withBaseUrl(baseUrl, `/tabs/action${q}`), {
|
return await fetchBrowserJson(`${baseUrl}/tabs/action${q}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -272,7 +267,7 @@ export async function browserTabAction(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function browserSnapshot(
|
export async function browserSnapshot(
|
||||||
baseUrl: string | undefined,
|
baseUrl: string,
|
||||||
opts: {
|
opts: {
|
||||||
format: "aria" | "ai";
|
format: "aria" | "ai";
|
||||||
targetId?: string;
|
targetId?: string;
|
||||||
@@ -306,7 +301,7 @@ export async function browserSnapshot(
|
|||||||
if (opts.labels === true) q.set("labels", "1");
|
if (opts.labels === true) q.set("labels", "1");
|
||||||
if (opts.mode) q.set("mode", opts.mode);
|
if (opts.mode) q.set("mode", opts.mode);
|
||||||
if (opts.profile) q.set("profile", opts.profile);
|
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,
|
timeoutMs: 20000,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-31
@@ -2,14 +2,13 @@ import { describe, expect, it } from "vitest";
|
|||||||
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||||
|
|
||||||
describe("browser config", () => {
|
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);
|
const resolved = resolveBrowserConfig(undefined);
|
||||||
expect(resolved.enabled).toBe(true);
|
expect(resolved.enabled).toBe(true);
|
||||||
expect(resolved.controlPort).toBe(18791);
|
expect(resolved.controlPort).toBe(18791);
|
||||||
|
expect(resolved.controlHost).toBe("127.0.0.1");
|
||||||
expect(resolved.color).toBe("#FF4500");
|
expect(resolved.color).toBe("#FF4500");
|
||||||
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
expect(shouldStartLocalBrowserServer(resolved)).toBe(true);
|
||||||
expect(resolved.cdpHost).toBe("127.0.0.1");
|
|
||||||
expect(resolved.cdpProtocol).toBe("http");
|
|
||||||
const profile = resolveProfile(resolved, resolved.defaultProfile);
|
const profile = resolveProfile(resolved, resolved.defaultProfile);
|
||||||
expect(profile?.name).toBe("chrome");
|
expect(profile?.name).toBe("chrome");
|
||||||
expect(profile?.driver).toBe("extension");
|
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", () => {
|
it("normalizes hex colors", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://localhost:18791",
|
||||||
color: "ff4500",
|
color: "ff4500",
|
||||||
});
|
});
|
||||||
expect(resolved.color).toBe("#FF4500");
|
expect(resolved.color).toBe("#FF4500");
|
||||||
@@ -79,6 +56,7 @@ describe("browser config", () => {
|
|||||||
|
|
||||||
it("supports custom remote CDP timeouts", () => {
|
it("supports custom remote CDP timeouts", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
remoteCdpTimeoutMs: 2200,
|
remoteCdpTimeoutMs: 2200,
|
||||||
remoteCdpHandshakeTimeoutMs: 5000,
|
remoteCdpHandshakeTimeoutMs: 5000,
|
||||||
});
|
});
|
||||||
@@ -88,21 +66,31 @@ describe("browser config", () => {
|
|||||||
|
|
||||||
it("falls back to default color for invalid hex", () => {
|
it("falls back to default color for invalid hex", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://localhost:18791",
|
||||||
color: "#GGGGGG",
|
color: "#GGGGGG",
|
||||||
});
|
});
|
||||||
expect(resolved.color).toBe("#FF4500");
|
expect(resolved.color).toBe("#FF4500");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("treats non-loopback cdpUrl as remote", () => {
|
it("treats non-loopback control urls as remote", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
cdpUrl: "http://example.com:9222",
|
controlUrl: "http://example.com:18791",
|
||||||
});
|
});
|
||||||
const profile = resolveProfile(resolved, "clawd");
|
expect(shouldStartLocalBrowserServer(resolved)).toBe(false);
|
||||||
expect(profile?.cdpIsLoopback).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", () => {
|
it("supports explicit CDP URLs for the default profile", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
cdpUrl: "http://example.com:9222",
|
cdpUrl: "http://example.com:9222",
|
||||||
});
|
});
|
||||||
const profile = resolveProfile(resolved, "clawd");
|
const profile = resolveProfile(resolved, "clawd");
|
||||||
@@ -113,6 +101,7 @@ describe("browser config", () => {
|
|||||||
|
|
||||||
it("uses profile cdpUrl when provided", () => {
|
it("uses profile cdpUrl when provided", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
profiles: {
|
profiles: {
|
||||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
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", () => {
|
it("uses base protocol for profiles with only cdpPort", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
cdpUrl: "https://example.com:9443",
|
cdpUrl: "https://example.com:9443",
|
||||||
profiles: {
|
profiles: {
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
@@ -137,11 +127,14 @@ describe("browser config", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects unsupported protocols", () => {
|
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", () => {
|
it("does not add the built-in chrome extension profile if the derived relay port is already used", () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
profiles: {
|
profiles: {
|
||||||
clawd: { cdpPort: 18792, color: "#FF4500" },
|
clawd: { cdpPort: 18792, color: "#FF4500" },
|
||||||
},
|
},
|
||||||
|
|||||||
+30
-13
@@ -1,12 +1,11 @@
|
|||||||
import type { BrowserConfig, BrowserProfileConfig, ClawdbotConfig } from "../config/config.js";
|
import type { BrowserConfig, BrowserProfileConfig } from "../config/config.js";
|
||||||
import {
|
import {
|
||||||
deriveDefaultBrowserCdpPortRange,
|
deriveDefaultBrowserCdpPortRange,
|
||||||
deriveDefaultBrowserControlPort,
|
deriveDefaultBrowserControlPort,
|
||||||
DEFAULT_BROWSER_CONTROL_PORT,
|
|
||||||
} from "../config/port-defaults.js";
|
} from "../config/port-defaults.js";
|
||||||
import { resolveGatewayPort } from "../config/paths.js";
|
|
||||||
import {
|
import {
|
||||||
DEFAULT_CLAWD_BROWSER_COLOR,
|
DEFAULT_CLAWD_BROWSER_COLOR,
|
||||||
|
DEFAULT_CLAWD_BROWSER_CONTROL_URL,
|
||||||
DEFAULT_CLAWD_BROWSER_ENABLED,
|
DEFAULT_CLAWD_BROWSER_ENABLED,
|
||||||
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
DEFAULT_BROWSER_DEFAULT_PROFILE_NAME,
|
||||||
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
DEFAULT_CLAWD_BROWSER_PROFILE_NAME,
|
||||||
@@ -15,7 +14,10 @@ import { CDP_PORT_RANGE_START, getUsedPorts } from "./profiles.js";
|
|||||||
|
|
||||||
export type ResolvedBrowserConfig = {
|
export type ResolvedBrowserConfig = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
controlUrl: string;
|
||||||
|
controlHost: string;
|
||||||
controlPort: number;
|
controlPort: number;
|
||||||
|
controlToken?: string;
|
||||||
cdpProtocol: "http" | "https";
|
cdpProtocol: "http" | "https";
|
||||||
cdpHost: string;
|
cdpHost: string;
|
||||||
cdpIsLoopback: boolean;
|
cdpIsLoopback: boolean;
|
||||||
@@ -135,13 +137,24 @@ function ensureDefaultChromeExtensionProfile(
|
|||||||
};
|
};
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
export function resolveBrowserConfig(
|
export function resolveBrowserConfig(cfg: BrowserConfig | undefined): ResolvedBrowserConfig {
|
||||||
cfg: BrowserConfig | undefined,
|
|
||||||
rootConfig?: ClawdbotConfig,
|
|
||||||
): ResolvedBrowserConfig {
|
|
||||||
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
const enabled = cfg?.enabled ?? DEFAULT_CLAWD_BROWSER_ENABLED;
|
||||||
const gatewayPort = resolveGatewayPort(rootConfig);
|
const envControlUrl = process.env.CLAWDBOT_BROWSER_CONTROL_URL?.trim();
|
||||||
const controlPort = deriveDefaultBrowserControlPort(gatewayPort ?? DEFAULT_BROWSER_CONTROL_PORT);
|
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 defaultColor = normalizeHexColor(cfg?.color);
|
||||||
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
const remoteCdpTimeoutMs = normalizeTimeoutMs(cfg?.remoteCdpTimeoutMs, 1500);
|
||||||
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
const remoteCdpHandshakeTimeoutMs = normalizeTimeoutMs(
|
||||||
@@ -165,10 +178,11 @@ export function resolveBrowserConfig(
|
|||||||
const derivedPort = controlPort + 1;
|
const derivedPort = controlPort + 1;
|
||||||
if (derivedPort > 65535) {
|
if (derivedPort > 65535) {
|
||||||
throw new Error(
|
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 = {
|
cdpInfo = {
|
||||||
parsed: derived,
|
parsed: derived,
|
||||||
port: derivedPort,
|
port: derivedPort,
|
||||||
@@ -197,7 +211,10 @@ export function resolveBrowserConfig(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
enabled,
|
enabled,
|
||||||
|
controlUrl: controlInfo.normalized,
|
||||||
|
controlHost: controlInfo.parsed.hostname,
|
||||||
controlPort,
|
controlPort,
|
||||||
|
...(controlToken ? { controlToken } : {}),
|
||||||
cdpProtocol,
|
cdpProtocol,
|
||||||
cdpHost: cdpInfo.parsed.hostname,
|
cdpHost: cdpInfo.parsed.hostname,
|
||||||
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
cdpIsLoopback: isLoopbackHost(cdpInfo.parsed.hostname),
|
||||||
@@ -252,6 +269,6 @@ export function resolveProfile(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shouldStartLocalBrowserServer(_resolved: ResolvedBrowserConfig) {
|
export function shouldStartLocalBrowserServer(resolved: ResolvedBrowserConfig) {
|
||||||
return true;
|
return isLoopbackHost(resolved.controlHost);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
export const DEFAULT_CLAWD_BROWSER_ENABLED = true;
|
export const DEFAULT_CLAWD_BROWSER_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_COLOR = "#FF4500";
|
||||||
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
export const DEFAULT_CLAWD_BROWSER_PROFILE_NAME = "clawd";
|
||||||
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
export const DEFAULT_BROWSER_DEFAULT_PROFILE_NAME = "chrome";
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -49,7 +49,9 @@ function createCtx(resolved: BrowserServerState["resolved"]) {
|
|||||||
|
|
||||||
describe("BrowserProfilesService", () => {
|
describe("BrowserProfilesService", () => {
|
||||||
it("allocates next local port for new profiles", async () => {
|
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);
|
const { ctx, state } = createCtx(resolved);
|
||||||
|
|
||||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||||
@@ -64,7 +66,9 @@ describe("BrowserProfilesService", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts per-profile cdpUrl for remote Chrome", async () => {
|
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);
|
const { ctx } = createCtx(resolved);
|
||||||
|
|
||||||
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
vi.mocked(loadConfig).mockReturnValue({ browser: { profiles: {} } });
|
||||||
@@ -93,6 +97,7 @@ describe("BrowserProfilesService", () => {
|
|||||||
|
|
||||||
it("deletes remote profiles without stopping or removing local data", async () => {
|
it("deletes remote profiles without stopping or removing local data", async () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
profiles: {
|
profiles: {
|
||||||
remote: { cdpUrl: "http://10.0.0.42:9222", color: "#0066CC" },
|
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 () => {
|
it("deletes local profiles and moves data to Trash", async () => {
|
||||||
const resolved = resolveBrowserConfig({
|
const resolved = resolveBrowserConfig({
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
profiles: {
|
profiles: {
|
||||||
work: { cdpPort: 18801, color: "#0066CC" },
|
work: { cdpPort: 18801, color: "#0066CC" },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserFormField } from "../client-actions-core.js";
|
import type { BrowserFormField } from "../client-actions-core.js";
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import {
|
import {
|
||||||
@@ -14,12 +16,8 @@ import {
|
|||||||
SELECTOR_UNSUPPORTED_MESSAGE,
|
SELECTOR_UNSUPPORTED_MESSAGE,
|
||||||
} from "./agent.shared.js";
|
} from "./agent.shared.js";
|
||||||
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserAgentActRoutes(
|
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
app: BrowserRouteRegistrar,
|
|
||||||
ctx: BrowserRouteContext,
|
|
||||||
) {
|
|
||||||
app.post("/act", async (req, res) => {
|
app.post("/act", async (req, res) => {
|
||||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
if (!profileCtx) return;
|
if (!profileCtx) return;
|
||||||
|
|||||||
@@ -2,15 +2,13 @@ import crypto from "node:crypto";
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||||
import { toBoolean, toStringOrEmpty } from "./utils.js";
|
import { toBoolean, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserAgentDebugRoutes(
|
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
app: BrowserRouteRegistrar,
|
|
||||||
ctx: BrowserRouteContext,
|
|
||||||
) {
|
|
||||||
app.get("/console", async (req, res) => {
|
app.get("/console", async (req, res) => {
|
||||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
if (!profileCtx) return;
|
if (!profileCtx) return;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
import type { PwAiModule } from "../pw-ai-module.js";
|
import type { PwAiModule } from "../pw-ai-module.js";
|
||||||
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
|
||||||
import { getProfileContext, jsonError } from "./utils.js";
|
import { getProfileContext, jsonError } from "./utils.js";
|
||||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
|
||||||
|
|
||||||
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
export const SELECTOR_UNSUPPORTED_MESSAGE = [
|
||||||
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
|
"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.",
|
"This is more reliable for modern SPAs.",
|
||||||
].join("\n");
|
].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;
|
const body = req.body as Record<string, unknown> | undefined;
|
||||||
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
|
||||||
return body;
|
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);
|
const mapped = ctx.mapTabError(err);
|
||||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||||
jsonError(res, 500, String(err));
|
jsonError(res, 500, String(err));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveProfileContext(
|
export function resolveProfileContext(
|
||||||
req: BrowserRequest,
|
req: express.Request,
|
||||||
res: BrowserResponse,
|
res: express.Response,
|
||||||
ctx: BrowserRouteContext,
|
ctx: BrowserRouteContext,
|
||||||
): ProfileContext | null {
|
): ProfileContext | null {
|
||||||
const profileCtx = getProfileContext(req, ctx);
|
const profileCtx = getProfileContext(req, ctx);
|
||||||
@@ -44,7 +45,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function requirePwAi(
|
export async function requirePwAi(
|
||||||
res: BrowserResponse,
|
res: express.Response,
|
||||||
feature: string,
|
feature: string,
|
||||||
): Promise<PwAiModule | null> {
|
): Promise<PwAiModule | null> {
|
||||||
const mod = await getPwAiModule();
|
const mod = await getPwAiModule();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
|
import type express from "express";
|
||||||
|
|
||||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||||
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
import { captureScreenshot, snapshotAria } from "../cdp.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,12 +23,8 @@ import {
|
|||||||
resolveProfileContext,
|
resolveProfileContext,
|
||||||
} from "./agent.shared.js";
|
} from "./agent.shared.js";
|
||||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserAgentSnapshotRoutes(
|
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
app: BrowserRouteRegistrar,
|
|
||||||
ctx: BrowserRouteContext,
|
|
||||||
) {
|
|
||||||
app.post("/navigate", async (req, res) => {
|
app.post("/navigate", async (req, res) => {
|
||||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
if (!profileCtx) return;
|
if (!profileCtx) return;
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
|
||||||
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserAgentStorageRoutes(
|
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
app: BrowserRouteRegistrar,
|
|
||||||
ctx: BrowserRouteContext,
|
|
||||||
) {
|
|
||||||
app.get("/cookies", async (req, res) => {
|
app.get("/cookies", async (req, res) => {
|
||||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||||
if (!profileCtx) return;
|
if (!profileCtx) return;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { registerBrowserAgentActRoutes } from "./agent.act.js";
|
import { registerBrowserAgentActRoutes } from "./agent.act.js";
|
||||||
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
|
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
|
||||||
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
|
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
|
||||||
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
|
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
registerBrowserAgentSnapshotRoutes(app, ctx);
|
registerBrowserAgentSnapshotRoutes(app, ctx);
|
||||||
registerBrowserAgentActRoutes(app, ctx);
|
registerBrowserAgentActRoutes(app, ctx);
|
||||||
registerBrowserAgentDebugRoutes(app, ctx);
|
registerBrowserAgentDebugRoutes(app, ctx);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
|
||||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
// List all profiles with their status
|
// List all profiles with their status
|
||||||
app.get("/profiles", async (_req, res) => {
|
app.get("/profiles", async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -52,6 +53,7 @@ export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: Brow
|
|||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
enabled: current.resolved.enabled,
|
enabled: current.resolved.enabled,
|
||||||
|
controlUrl: current.resolved.controlUrl,
|
||||||
profile: profileCtx.profile.name,
|
profile: profileCtx.profile.name,
|
||||||
running: cdpReady,
|
running: cdpReady,
|
||||||
cdpReady,
|
cdpReady,
|
||||||
|
|||||||
@@ -1,122 +0,0 @@
|
|||||||
import type { BrowserRouteContext } from "../server-context.js";
|
|
||||||
import { registerBrowserRoutes } from "./index.js";
|
|
||||||
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
type BrowserDispatchRequest = {
|
|
||||||
method: "GET" | "POST" | "DELETE";
|
|
||||||
path: string;
|
|
||||||
query?: Record<string, unknown>;
|
|
||||||
body?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type BrowserDispatchResponse = {
|
|
||||||
status: number;
|
|
||||||
body: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type RouteEntry = {
|
|
||||||
method: BrowserDispatchRequest["method"];
|
|
||||||
path: string;
|
|
||||||
regex: RegExp;
|
|
||||||
paramNames: string[];
|
|
||||||
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
function escapeRegex(value: string) {
|
|
||||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
}
|
|
||||||
|
|
||||||
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
|
|
||||||
const paramNames: string[] = [];
|
|
||||||
const parts = path.split("/").map((part) => {
|
|
||||||
if (part.startsWith(":")) {
|
|
||||||
const name = part.slice(1);
|
|
||||||
paramNames.push(name);
|
|
||||||
return "([^/]+)";
|
|
||||||
}
|
|
||||||
return escapeRegex(part);
|
|
||||||
});
|
|
||||||
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createRegistry() {
|
|
||||||
const routes: RouteEntry[] = [];
|
|
||||||
const register =
|
|
||||||
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
|
|
||||||
const { regex, paramNames } = compileRoute(path);
|
|
||||||
routes.push({ method, path, regex, paramNames, handler });
|
|
||||||
};
|
|
||||||
const router: BrowserRouteRegistrar = {
|
|
||||||
get: register("GET"),
|
|
||||||
post: register("POST"),
|
|
||||||
delete: register("DELETE"),
|
|
||||||
};
|
|
||||||
return { routes, router };
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePath(path: string) {
|
|
||||||
if (!path) return "/";
|
|
||||||
return path.startsWith("/") ? path : `/${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
|
|
||||||
const registry = createRegistry();
|
|
||||||
registerBrowserRoutes(registry.router, ctx);
|
|
||||||
|
|
||||||
return {
|
|
||||||
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
|
|
||||||
const method = req.method;
|
|
||||||
const path = normalizePath(req.path);
|
|
||||||
const query = req.query ?? {};
|
|
||||||
const body = req.body;
|
|
||||||
|
|
||||||
const match = registry.routes.find((route) => {
|
|
||||||
if (route.method !== method) return false;
|
|
||||||
return route.regex.test(path);
|
|
||||||
});
|
|
||||||
if (!match) {
|
|
||||||
return { status: 404, body: { error: "Not Found" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const exec = match.regex.exec(path);
|
|
||||||
const params: Record<string, string> = {};
|
|
||||||
if (exec) {
|
|
||||||
for (const [idx, name] of match.paramNames.entries()) {
|
|
||||||
const value = exec[idx + 1];
|
|
||||||
if (typeof value === "string") {
|
|
||||||
params[name] = decodeURIComponent(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = 200;
|
|
||||||
let payload: unknown = undefined;
|
|
||||||
const res: BrowserResponse = {
|
|
||||||
status(code) {
|
|
||||||
status = code;
|
|
||||||
return res;
|
|
||||||
},
|
|
||||||
json(bodyValue) {
|
|
||||||
payload = bodyValue;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
try {
|
|
||||||
await match.handler(
|
|
||||||
{
|
|
||||||
params,
|
|
||||||
query,
|
|
||||||
body,
|
|
||||||
},
|
|
||||||
res,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
return { status: 500, body: { error: String(err) } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { status, body: payload };
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export type { BrowserDispatchRequest, BrowserDispatchResponse };
|
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { registerBrowserAgentRoutes } from "./agent.js";
|
import { registerBrowserAgentRoutes } from "./agent.js";
|
||||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
registerBrowserBasicRoutes(app, ctx);
|
registerBrowserBasicRoutes(app, ctx);
|
||||||
registerBrowserTabRoutes(app, ctx);
|
registerBrowserTabRoutes(app, ctx);
|
||||||
registerBrowserAgentRoutes(app, ctx);
|
registerBrowserAgentRoutes(app, ctx);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext } from "../server-context.js";
|
import type { BrowserRouteContext } from "../server-context.js";
|
||||||
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||||
import type { BrowserRouteRegistrar } from "./types.js";
|
|
||||||
|
|
||||||
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
|
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
|
||||||
app.get("/tabs", async (req, res) => {
|
app.get("/tabs", async (req, res) => {
|
||||||
const profileCtx = getProfileContext(req, ctx);
|
const profileCtx = getProfileContext(req, ctx);
|
||||||
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
|
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
export type BrowserRequest = {
|
|
||||||
params: Record<string, string>;
|
|
||||||
query: Record<string, unknown>;
|
|
||||||
body?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserResponse = {
|
|
||||||
status: (code: number) => BrowserResponse;
|
|
||||||
json: (body: unknown) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BrowserRouteHandler = (
|
|
||||||
req: BrowserRequest,
|
|
||||||
res: BrowserResponse,
|
|
||||||
) => void | Promise<void>;
|
|
||||||
|
|
||||||
export type BrowserRouteRegistrar = {
|
|
||||||
get: (path: string, handler: BrowserRouteHandler) => void;
|
|
||||||
post: (path: string, handler: BrowserRouteHandler) => void;
|
|
||||||
delete: (path: string, handler: BrowserRouteHandler) => void;
|
|
||||||
};
|
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
|
import type express from "express";
|
||||||
|
|
||||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||||
import { parseBooleanValue } from "../../utils/boolean.js";
|
import { parseBooleanValue } from "../../utils/boolean.js";
|
||||||
import type { BrowserRequest, BrowserResponse } from "./types.js";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract profile name from query string or body and get profile context.
|
* Extract profile name from query string or body and get profile context.
|
||||||
* Query string takes precedence over body for consistency with GET routes.
|
* Query string takes precedence over body for consistency with GET routes.
|
||||||
*/
|
*/
|
||||||
export function getProfileContext(
|
export function getProfileContext(
|
||||||
req: BrowserRequest,
|
req: express.Request,
|
||||||
ctx: BrowserRouteContext,
|
ctx: BrowserRouteContext,
|
||||||
): ProfileContext | { error: string; status: number } {
|
): ProfileContext | { error: string; status: number } {
|
||||||
let profileName: string | undefined;
|
let profileName: string | undefined;
|
||||||
@@ -32,7 +33,7 @@ export function getProfileContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function jsonError(res: BrowserResponse, status: number, message: string) {
|
export function jsonError(res: express.Response, status: number, message: string) {
|
||||||
res.status(status).json({ error: message });
|
res.status(status).json({ error: message });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,8 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
resolved: {
|
resolved: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 18791,
|
controlPort: 18791,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost: "127.0.0.1",
|
cdpHost: "127.0.0.1",
|
||||||
@@ -119,6 +121,8 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
resolved: {
|
resolved: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 18791,
|
controlPort: 18791,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost: "127.0.0.1",
|
cdpHost: "127.0.0.1",
|
||||||
@@ -166,6 +170,8 @@ describe("browser server-context ensureTabAvailable", () => {
|
|||||||
port: 0,
|
port: 0,
|
||||||
resolved: {
|
resolved: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 18791,
|
controlPort: 18791,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost: "127.0.0.1",
|
cdpHost: "127.0.0.1",
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ function makeState(
|
|||||||
port: 0,
|
port: 0,
|
||||||
resolved: {
|
resolved: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:18791",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 18791,
|
controlPort: 18791,
|
||||||
cdpProtocol: profile === "remote" ? "https" : "http",
|
cdpProtocol: profile === "remote" ? "https" : "http",
|
||||||
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
cdpHost: profile === "remote" ? "browserless.example" : "127.0.0.1",
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export type ProfileRuntimeState = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type BrowserServerState = {
|
export type BrowserServerState = {
|
||||||
server?: Server | null;
|
server: Server;
|
||||||
port: number;
|
port: number;
|
||||||
resolved: ResolvedBrowserConfig;
|
resolved: ResolvedBrowserConfig;
|
||||||
profiles: Map<string, ProfileRuntimeState>;
|
profiles: Map<string, ProfileRuntimeState>;
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ let cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -89,6 +88,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -197,8 +197,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -250,11 +248,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ let cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -90,6 +89,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -198,8 +198,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -251,11 +249,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ let _cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -89,6 +88,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -197,8 +197,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -250,11 +248,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ let _cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -89,6 +88,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -197,8 +197,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -250,11 +248,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ let _cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -89,6 +88,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -197,8 +197,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -250,11 +248,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ let cdpBaseUrl = "";
|
|||||||
let reachable = false;
|
let reachable = false;
|
||||||
let cfgAttachOnly = false;
|
let cfgAttachOnly = false;
|
||||||
let createTargetId: string | null = null;
|
let createTargetId: string | null = null;
|
||||||
let prevGatewayPort: string | undefined;
|
|
||||||
|
|
||||||
const cdpMocks = vi.hoisted(() => ({
|
const cdpMocks = vi.hoisted(() => ({
|
||||||
createTargetViaCdp: vi.fn(async () => {
|
createTargetViaCdp: vi.fn(async () => {
|
||||||
@@ -89,6 +88,7 @@ vi.mock("../config/config.js", async (importOriginal) => {
|
|||||||
loadConfig: () => ({
|
loadConfig: () => ({
|
||||||
browser: {
|
browser: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: `http://127.0.0.1:${testPort}`,
|
||||||
color: "#FF4500",
|
color: "#FF4500",
|
||||||
attachOnly: cfgAttachOnly,
|
attachOnly: cfgAttachOnly,
|
||||||
headless: true,
|
headless: true,
|
||||||
@@ -197,8 +197,6 @@ describe("browser control server", () => {
|
|||||||
|
|
||||||
testPort = await getFreePort();
|
testPort = await getFreePort();
|
||||||
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
|
||||||
prevGatewayPort = process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = String(testPort - 2);
|
|
||||||
|
|
||||||
// Minimal CDP JSON endpoints used by the server.
|
// Minimal CDP JSON endpoints used by the server.
|
||||||
let putNewCalls = 0;
|
let putNewCalls = 0;
|
||||||
@@ -250,11 +248,6 @@ describe("browser control server", () => {
|
|||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
if (prevGatewayPort === undefined) {
|
|
||||||
delete process.env.CLAWDBOT_GATEWAY_PORT;
|
|
||||||
} else {
|
|
||||||
process.env.CLAWDBOT_GATEWAY_PORT = prevGatewayPort;
|
|
||||||
}
|
|
||||||
const { stopBrowserControlServer } = await import("./server.js");
|
const { stopBrowserControlServer } = await import("./server.js");
|
||||||
await stopBrowserControlServer();
|
await stopBrowserControlServer();
|
||||||
});
|
});
|
||||||
@@ -401,6 +394,8 @@ describe("browser control server", () => {
|
|||||||
const bridge = await startBrowserBridgeServer({
|
const bridge = await startBrowserBridgeServer({
|
||||||
resolved: {
|
resolved: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
controlUrl: "http://127.0.0.1:0",
|
||||||
|
controlHost: "127.0.0.1",
|
||||||
controlPort: 0,
|
controlPort: 0,
|
||||||
cdpProtocol: "http",
|
cdpProtocol: "http",
|
||||||
cdpHost: "127.0.0.1",
|
cdpHost: "127.0.0.1",
|
||||||
|
|||||||
+13
-9
@@ -3,10 +3,9 @@ import express from "express";
|
|||||||
|
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { createSubsystemLogger } from "../logging/subsystem.js";
|
import { createSubsystemLogger } from "../logging/subsystem.js";
|
||||||
import { resolveBrowserConfig, resolveProfile } from "./config.js";
|
import { resolveBrowserConfig, resolveProfile, shouldStartLocalBrowserServer } from "./config.js";
|
||||||
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
|
||||||
import { registerBrowserRoutes } from "./routes/index.js";
|
import { registerBrowserRoutes } from "./routes/index.js";
|
||||||
import type { BrowserRouteRegistrar } from "./routes/types.js";
|
|
||||||
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
|
||||||
|
|
||||||
let state: BrowserServerState | null = null;
|
let state: BrowserServerState | null = null;
|
||||||
@@ -17,16 +16,23 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
|
|||||||
if (state) return state;
|
if (state) return state;
|
||||||
|
|
||||||
const cfg = loadConfig();
|
const cfg = loadConfig();
|
||||||
const resolved = resolveBrowserConfig(cfg.browser, cfg);
|
const resolved = resolveBrowserConfig(cfg.browser);
|
||||||
if (!resolved.enabled) return null;
|
if (!resolved.enabled) return null;
|
||||||
|
|
||||||
|
if (!shouldStartLocalBrowserServer(resolved)) {
|
||||||
|
logServer.info(
|
||||||
|
`browser control URL is non-loopback (${resolved.controlUrl}); skipping local server start`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: "1mb" }));
|
app.use(express.json({ limit: "1mb" }));
|
||||||
|
|
||||||
const ctx = createBrowserRouteContext({
|
const ctx = createBrowserRouteContext({
|
||||||
getState: () => state,
|
getState: () => state,
|
||||||
});
|
});
|
||||||
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
|
registerBrowserRoutes(app, ctx);
|
||||||
|
|
||||||
const port = resolved.controlPort;
|
const port = resolved.controlPort;
|
||||||
const server = await new Promise<Server>((resolve, reject) => {
|
const server = await new Promise<Server>((resolve, reject) => {
|
||||||
@@ -83,11 +89,9 @@ export async function stopBrowserControlServer(): Promise<void> {
|
|||||||
logServer.warn(`clawd browser stop failed: ${String(err)}`);
|
logServer.warn(`clawd browser stop failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.server) {
|
await new Promise<void>((resolve) => {
|
||||||
await new Promise<void>((resolve) => {
|
current.server.close(() => resolve());
|
||||||
current.server?.close(() => resolve());
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
state = null;
|
state = null;
|
||||||
|
|
||||||
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
// Optional: Playwright is not always available (e.g. embedded gateway builds).
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
6d63b866aa0e917b278c6bef42229e8cd1f43c8ba31c845a96b4d9d5ce780265
|
a99455ba0c4d0aad0a110bf25440c208b798198d5524b269f0f2d3f984262ae4
|
||||||
File diff suppressed because it is too large.
Load diff
+20
-30
@@ -8,7 +8,6 @@ import type { Duplex } from "node:stream";
|
|||||||
import chokidar from "chokidar";
|
import chokidar from "chokidar";
|
||||||
import { type WebSocket, WebSocketServer } from "ws";
|
import { type WebSocket, WebSocketServer } from "ws";
|
||||||
import { isTruthyEnvValue } from "../infra/env.js";
|
import { isTruthyEnvValue } from "../infra/env.js";
|
||||||
import { SafeOpenError, openFileWithinRoot } from "../infra/fs-safe.js";
|
|
||||||
import { detectMime } from "../media/mime.js";
|
import { detectMime } from "../media/mime.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { ensureDir, resolveUserPath } from "../utils.js";
|
import { ensureDir, resolveUserPath } from "../utils.js";
|
||||||
@@ -146,31 +145,30 @@ async function resolveFilePath(rootReal: string, urlPath: string) {
|
|||||||
const rel = normalized.replace(/^\/+/, "");
|
const rel = normalized.replace(/^\/+/, "");
|
||||||
if (rel.split("/").some((p) => p === "..")) return null;
|
if (rel.split("/").some((p) => p === "..")) return null;
|
||||||
|
|
||||||
const tryOpen = async (relative: string) => {
|
let candidate = path.join(rootReal, rel);
|
||||||
try {
|
|
||||||
return await openFileWithinRoot({ rootDir: rootReal, relativePath: relative });
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof SafeOpenError) return null;
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if (normalized.endsWith("/")) {
|
if (normalized.endsWith("/")) {
|
||||||
return await tryOpen(path.posix.join(rel, "index.html"));
|
candidate = path.join(candidate, "index.html");
|
||||||
}
|
}
|
||||||
|
|
||||||
const candidate = path.join(rootReal, rel);
|
|
||||||
try {
|
try {
|
||||||
const st = await fs.lstat(candidate);
|
const st = await fs.stat(candidate);
|
||||||
if (st.isSymbolicLink()) return null;
|
|
||||||
if (st.isDirectory()) {
|
if (st.isDirectory()) {
|
||||||
return await tryOpen(path.posix.join(rel, "index.html"));
|
candidate = path.join(candidate, "index.html");
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
|
|
||||||
return await tryOpen(rel);
|
const rootPrefix = rootReal.endsWith(path.sep) ? rootReal : `${rootReal}${path.sep}`;
|
||||||
|
try {
|
||||||
|
const lstat = await fs.lstat(candidate);
|
||||||
|
if (lstat.isSymbolicLink()) return null;
|
||||||
|
const real = await fs.realpath(candidate);
|
||||||
|
if (!real.startsWith(rootPrefix)) return null;
|
||||||
|
return real;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDisabledByEnv() {
|
function isDisabledByEnv() {
|
||||||
@@ -313,8 +311,8 @@ export async function createCanvasHostHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const opened = await resolveFilePath(rootReal, urlPath);
|
const filePath = await resolveFilePath(rootReal, urlPath);
|
||||||
if (!opened) {
|
if (!filePath) {
|
||||||
if (urlPath === "/" || urlPath.endsWith("/")) {
|
if (urlPath === "/" || urlPath.endsWith("/")) {
|
||||||
res.statusCode = 404;
|
res.statusCode = 404;
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
@@ -329,30 +327,22 @@ export async function createCanvasHostHandler(
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { handle, realPath } = opened;
|
const lower = filePath.toLowerCase();
|
||||||
let data: Buffer;
|
|
||||||
try {
|
|
||||||
data = await handle.readFile();
|
|
||||||
} finally {
|
|
||||||
await handle.close().catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
const lower = realPath.toLowerCase();
|
|
||||||
const mime =
|
const mime =
|
||||||
lower.endsWith(".html") || lower.endsWith(".htm")
|
lower.endsWith(".html") || lower.endsWith(".htm")
|
||||||
? "text/html"
|
? "text/html"
|
||||||
: ((await detectMime({ filePath: realPath })) ?? "application/octet-stream");
|
: ((await detectMime({ filePath })) ?? "application/octet-stream");
|
||||||
|
|
||||||
res.setHeader("Cache-Control", "no-store");
|
res.setHeader("Cache-Control", "no-store");
|
||||||
if (mime === "text/html") {
|
if (mime === "text/html") {
|
||||||
const html = data.toString("utf8");
|
const html = await fs.readFile(filePath, "utf8");
|
||||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||||
res.end(liveReload ? injectCanvasLiveReload(html) : html);
|
res.end(liveReload ? injectCanvasLiveReload(html) : html);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
res.setHeader("Content-Type", mime);
|
res.setHeader("Content-Type", mime);
|
||||||
res.end(data);
|
res.end(await fs.readFile(filePath));
|
||||||
return true;
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
opts.runtime.error(`canvasHost request failed: ${String(err)}`);
|
opts.runtime.error(`canvasHost request failed: ${String(err)}`);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { browserAct } from "../../browser/client-actions.js";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
import { callBrowserAct, requireRef, resolveBrowserActionContext } from "./shared.js";
|
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||||
|
|
||||||
export function registerBrowserElementCommands(
|
export function registerBrowserElementCommands(
|
||||||
browser: Command,
|
browser: Command,
|
||||||
@@ -17,7 +18,7 @@ export function registerBrowserElementCommands(
|
|||||||
.option("--button <left|right|middle>", "Mouse button to use")
|
.option("--button <left|right|middle>", "Mouse button to use")
|
||||||
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
.option("--modifiers <list>", "Comma-separated modifiers (Shift,Alt,Meta)")
|
||||||
.action(async (ref: string | undefined, opts, cmd) => {
|
.action(async (ref: string | undefined, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
const refValue = requireRef(ref);
|
const refValue = requireRef(ref);
|
||||||
if (!refValue) return;
|
if (!refValue) return;
|
||||||
const modifiers = opts.modifiers
|
const modifiers = opts.modifiers
|
||||||
@@ -27,10 +28,9 @@ export function registerBrowserElementCommands(
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
: undefined;
|
: undefined;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct<{ url?: string }>({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "click",
|
kind: "click",
|
||||||
ref: refValue,
|
ref: refValue,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
@@ -38,7 +38,8 @@ export function registerBrowserElementCommands(
|
|||||||
button: opts.button?.trim() || undefined,
|
button: opts.button?.trim() || undefined,
|
||||||
modifiers,
|
modifiers,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -60,14 +61,13 @@ export function registerBrowserElementCommands(
|
|||||||
.option("--slowly", "Type slowly (human-like)", false)
|
.option("--slowly", "Type slowly (human-like)", false)
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
.action(async (ref: string | undefined, text: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
const refValue = requireRef(ref);
|
const refValue = requireRef(ref);
|
||||||
if (!refValue) return;
|
if (!refValue) return;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "type",
|
kind: "type",
|
||||||
ref: refValue,
|
ref: refValue,
|
||||||
text,
|
text,
|
||||||
@@ -75,7 +75,8 @@ export function registerBrowserElementCommands(
|
|||||||
slowly: Boolean(opts.slowly),
|
slowly: Boolean(opts.slowly),
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -93,13 +94,13 @@ export function registerBrowserElementCommands(
|
|||||||
.argument("<key>", "Key to press (e.g. Enter)")
|
.argument("<key>", "Key to press (e.g. Enter)")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (key: string, opts, cmd) => {
|
.action(async (key: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{ kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
||||||
body: { kind: "press", key, targetId: opts.targetId?.trim() || undefined },
|
{ profile },
|
||||||
});
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -117,13 +118,13 @@ export function registerBrowserElementCommands(
|
|||||||
.argument("<ref>", "Ref id from snapshot")
|
.argument("<ref>", "Ref id from snapshot")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (ref: string, opts, cmd) => {
|
.action(async (ref: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{ kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
||||||
body: { kind: "hover", ref, targetId: opts.targetId?.trim() || undefined },
|
{ profile },
|
||||||
});
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -144,21 +145,20 @@ export function registerBrowserElementCommands(
|
|||||||
Number(v),
|
Number(v),
|
||||||
)
|
)
|
||||||
.action(async (ref: string | undefined, opts, cmd) => {
|
.action(async (ref: string | undefined, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
const refValue = requireRef(ref);
|
const refValue = requireRef(ref);
|
||||||
if (!refValue) return;
|
if (!refValue) return;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "scrollIntoView",
|
kind: "scrollIntoView",
|
||||||
ref: refValue,
|
ref: refValue,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
},
|
},
|
||||||
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
{ profile },
|
||||||
});
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -177,18 +177,18 @@ export function registerBrowserElementCommands(
|
|||||||
.argument("<endRef>", "End ref id")
|
.argument("<endRef>", "End ref id")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
.action(async (startRef: string, endRef: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "drag",
|
kind: "drag",
|
||||||
startRef,
|
startRef,
|
||||||
endRef,
|
endRef,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -207,18 +207,18 @@ export function registerBrowserElementCommands(
|
|||||||
.argument("<values...>", "Option values to select")
|
.argument("<values...>", "Option values to select")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (ref: string, values: string[], opts, cmd) => {
|
.action(async (ref: string, values: string[], opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "select",
|
kind: "select",
|
||||||
ref,
|
ref,
|
||||||
values,
|
values,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import {
|
||||||
|
browserArmDialog,
|
||||||
|
browserArmFileChooser,
|
||||||
|
browserDownload,
|
||||||
|
browserWaitForDownload,
|
||||||
|
} from "../../browser/client-actions.js";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
import { resolveBrowserActionContext } from "./shared.js";
|
import { resolveBrowserActionContext } from "./shared.js";
|
||||||
import { shortenHomePath } from "../../utils.js";
|
import { shortenHomePath } from "../../utils.js";
|
||||||
|
|
||||||
@@ -23,26 +29,17 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
(v: string) => Number(v),
|
(v: string) => Number(v),
|
||||||
)
|
)
|
||||||
.action(async (paths: string[], opts, cmd) => {
|
.action(async (paths: string[], opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserArmFileChooser(baseUrl, {
|
||||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
paths,
|
||||||
parent,
|
ref: opts.ref?.trim() || undefined,
|
||||||
{
|
inputRef: opts.inputRef?.trim() || undefined,
|
||||||
method: "POST",
|
element: opts.element?.trim() || undefined,
|
||||||
path: "/hooks/file-chooser",
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
query: profile ? { profile } : undefined,
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
body: {
|
profile,
|
||||||
paths,
|
});
|
||||||
ref: opts.ref?.trim() || undefined,
|
|
||||||
inputRef: opts.inputRef?.trim() || undefined,
|
|
||||||
element: opts.element?.trim() || undefined,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
timeoutMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -65,23 +62,14 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
(v: string) => Number(v),
|
(v: string) => Number(v),
|
||||||
)
|
)
|
||||||
.action(async (outPath: string | undefined, opts, cmd) => {
|
.action(async (outPath: string | undefined, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserWaitForDownload(baseUrl, {
|
||||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
path: outPath?.trim() || undefined,
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/wait/download",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
path: outPath?.trim() || undefined,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
timeoutMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -105,24 +93,15 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
(v: string) => Number(v),
|
(v: string) => Number(v),
|
||||||
)
|
)
|
||||||
.action(async (ref: string, outPath: string, opts, cmd) => {
|
.action(async (ref: string, outPath: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserDownload(baseUrl, {
|
||||||
const result = await callBrowserRequest<{ download: { path: string } }>(
|
ref,
|
||||||
parent,
|
path: outPath,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
path: "/download",
|
profile,
|
||||||
query: profile ? { profile } : undefined,
|
});
|
||||||
body: {
|
|
||||||
ref,
|
|
||||||
path: outPath,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
timeoutMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -147,7 +126,7 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
(v: string) => Number(v),
|
(v: string) => Number(v),
|
||||||
)
|
)
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
const accept = opts.accept ? true : opts.dismiss ? false : undefined;
|
||||||
if (accept === undefined) {
|
if (accept === undefined) {
|
||||||
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
defaultRuntime.error(danger("Specify --accept or --dismiss"));
|
||||||
@@ -155,22 +134,13 @@ export function registerBrowserFilesAndDownloadsCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserArmDialog(baseUrl, {
|
||||||
const result = await callBrowserRequest(
|
accept,
|
||||||
parent,
|
promptText: opts.prompt?.trim() || undefined,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
path: "/hooks/dialog",
|
profile,
|
||||||
query: profile ? { profile } : undefined,
|
});
|
||||||
body: {
|
|
||||||
accept,
|
|
||||||
promptText: opts.prompt?.trim() || undefined,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
timeoutMs,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { browserAct } from "../../browser/client-actions.js";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
import { callBrowserAct, readFields, resolveBrowserActionContext } from "./shared.js";
|
import { readFields, resolveBrowserActionContext } from "./shared.js";
|
||||||
|
|
||||||
export function registerBrowserFormWaitEvalCommands(
|
export function registerBrowserFormWaitEvalCommands(
|
||||||
browser: Command,
|
browser: Command,
|
||||||
@@ -15,21 +16,21 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
.option("--fields-file <path>", "Read JSON array from a file")
|
.option("--fields-file <path>", "Read JSON array from a file")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const fields = await readFields({
|
const fields = await readFields({
|
||||||
fields: opts.fields,
|
fields: opts.fields,
|
||||||
fieldsFile: opts.fieldsFile,
|
fieldsFile: opts.fieldsFile,
|
||||||
});
|
});
|
||||||
const result = await callBrowserAct<{ result?: unknown }>({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "fill",
|
kind: "fill",
|
||||||
fields,
|
fields,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -58,18 +59,16 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
)
|
)
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (selector: string | undefined, opts, cmd) => {
|
.action(async (selector: string | undefined, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const sel = selector?.trim() || undefined;
|
const sel = selector?.trim() || undefined;
|
||||||
const load =
|
const load =
|
||||||
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
|
opts.load === "load" || opts.load === "domcontentloaded" || opts.load === "networkidle"
|
||||||
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
? (opts.load as "load" | "domcontentloaded" | "networkidle")
|
||||||
: undefined;
|
: undefined;
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserAct(
|
||||||
const result = await callBrowserAct<{ result?: unknown }>({
|
baseUrl,
|
||||||
parent,
|
{
|
||||||
profile,
|
|
||||||
body: {
|
|
||||||
kind: "wait",
|
kind: "wait",
|
||||||
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
timeMs: Number.isFinite(opts.time) ? opts.time : undefined,
|
||||||
text: opts.text?.trim() || undefined,
|
text: opts.text?.trim() || undefined,
|
||||||
@@ -79,10 +78,10 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
loadState: load,
|
loadState: load,
|
||||||
fn: opts.fn?.trim() || undefined,
|
fn: opts.fn?.trim() || undefined,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
timeoutMs,
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
},
|
},
|
||||||
timeoutMs,
|
{ profile },
|
||||||
});
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -101,23 +100,23 @@ export function registerBrowserFormWaitEvalCommands(
|
|||||||
.option("--ref <id>", "Ref from snapshot")
|
.option("--ref <id>", "Ref from snapshot")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
if (!opts.fn) {
|
if (!opts.fn) {
|
||||||
defaultRuntime.error(danger("Missing --fn"));
|
defaultRuntime.error(danger("Missing --fn"));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserAct<{ result?: unknown }>({
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
profile,
|
{
|
||||||
body: {
|
|
||||||
kind: "evaluate",
|
kind: "evaluate",
|
||||||
fn: opts.fn,
|
fn: opts.fn,
|
||||||
ref: opts.ref?.trim() || undefined,
|
ref: opts.ref?.trim() || undefined,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
},
|
},
|
||||||
});
|
{ profile },
|
||||||
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { browserAct, browserNavigate } from "../../browser/client-actions.js";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
import { requireRef, resolveBrowserActionContext } from "./shared.js";
|
||||||
|
|
||||||
export function registerBrowserNavigationCommands(
|
export function registerBrowserNavigationCommands(
|
||||||
@@ -14,21 +15,13 @@ export function registerBrowserNavigationCommands(
|
|||||||
.argument("<url>", "URL to navigate to")
|
.argument("<url>", "URL to navigate to")
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (url: string, opts, cmd) => {
|
.action(async (url: string, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest<{ url?: string }>(
|
const result = await browserNavigate(baseUrl, {
|
||||||
parent,
|
url,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/navigate",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
url,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -47,27 +40,22 @@ export function registerBrowserNavigationCommands(
|
|||||||
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
.argument("<height>", "Viewport height", (v: string) => Number(v))
|
||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (width: number, height: number, opts, cmd) => {
|
.action(async (width: number, height: number, opts, cmd) => {
|
||||||
const { parent, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
const { parent, baseUrl, profile } = resolveBrowserActionContext(cmd, parentOpts);
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||||
defaultRuntime.error(danger("width and height must be numbers"));
|
defaultRuntime.error(danger("width and height must be numbers"));
|
||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
{
|
{
|
||||||
method: "POST",
|
kind: "resize",
|
||||||
path: "/act",
|
width,
|
||||||
query: profile ? { profile } : undefined,
|
height,
|
||||||
body: {
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
kind: "resize",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ timeoutMs: 20000 },
|
{ profile },
|
||||||
);
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { resolveBrowserControlUrl } from "../../browser/client.js";
|
||||||
import type { BrowserFormField } from "../../browser/client-actions-core.js";
|
import type { BrowserFormField } from "../../browser/client-actions-core.js";
|
||||||
import { danger } from "../../globals.js";
|
import { danger } from "../../globals.js";
|
||||||
import { defaultRuntime } from "../../runtime.js";
|
import { defaultRuntime } from "../../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "../browser-cli-shared.js";
|
import type { BrowserParentOpts } from "../browser-cli-shared.js";
|
||||||
|
|
||||||
export type BrowserActionContext = {
|
export type BrowserActionContext = {
|
||||||
parent: BrowserParentOpts;
|
parent: BrowserParentOpts;
|
||||||
|
baseUrl: string;
|
||||||
profile: string | undefined;
|
profile: string | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -14,26 +16,9 @@ export function resolveBrowserActionContext(
|
|||||||
parentOpts: (cmd: Command) => BrowserParentOpts,
|
parentOpts: (cmd: Command) => BrowserParentOpts,
|
||||||
): BrowserActionContext {
|
): BrowserActionContext {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
return { parent, profile };
|
return { parent, baseUrl, profile };
|
||||||
}
|
|
||||||
|
|
||||||
export async function callBrowserAct<T = unknown>(params: {
|
|
||||||
parent: BrowserParentOpts;
|
|
||||||
profile?: string;
|
|
||||||
body: Record<string, unknown>;
|
|
||||||
timeoutMs?: number;
|
|
||||||
}): Promise<T> {
|
|
||||||
return await callBrowserRequest<T>(
|
|
||||||
params.parent,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/act",
|
|
||||||
query: params.profile ? { profile: params.profile } : undefined,
|
|
||||||
body: params.body,
|
|
||||||
},
|
|
||||||
{ timeoutMs: params.timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function requireRef(ref: string | undefined) {
|
export function requireRef(ref: string | undefined) {
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
|
import {
|
||||||
|
browserConsoleMessages,
|
||||||
|
browserPdfSave,
|
||||||
|
browserResponseBody,
|
||||||
|
} from "../browser/client-actions.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
import { shortenHomePath } from "../utils.js";
|
||||||
|
|
||||||
@@ -23,21 +29,14 @@ export function registerBrowserActionObserveCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserObserve(async () => {
|
await runBrowserObserve(async () => {
|
||||||
const result = await callBrowserRequest<{ messages: unknown[] }>(
|
const result = await browserConsoleMessages(baseUrl, {
|
||||||
parent,
|
level: opts.level?.trim() || undefined,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "GET",
|
profile,
|
||||||
path: "/console",
|
});
|
||||||
query: {
|
|
||||||
level: opts.level?.trim() || undefined,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -52,18 +51,13 @@ export function registerBrowserActionObserveCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserObserve(async () => {
|
await runBrowserObserve(async () => {
|
||||||
const result = await callBrowserRequest<{ path: string }>(
|
const result = await browserPdfSave(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
profile,
|
||||||
method: "POST",
|
});
|
||||||
path: "/pdf",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { targetId: opts.targetId?.trim() || undefined },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -87,25 +81,16 @@ export function registerBrowserActionObserveCommands(
|
|||||||
)
|
)
|
||||||
.action(async (url: string, opts, cmd) => {
|
.action(async (url: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserObserve(async () => {
|
await runBrowserObserve(async () => {
|
||||||
const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined;
|
const result = await browserResponseBody(baseUrl, {
|
||||||
const maxChars = Number.isFinite(opts.maxChars) ? opts.maxChars : undefined;
|
url,
|
||||||
const result = await callBrowserRequest<{ response: { body: string } }>(
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
parent,
|
timeoutMs: Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : undefined,
|
||||||
{
|
maxChars: Number.isFinite(opts.maxChars) ? opts.maxChars : undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/response/body",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
url,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
timeoutMs,
|
|
||||||
maxChars,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: timeoutMs ?? 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,8 +1,16 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
|
import {
|
||||||
|
browserHighlight,
|
||||||
|
browserPageErrors,
|
||||||
|
browserRequests,
|
||||||
|
browserTraceStart,
|
||||||
|
browserTraceStop,
|
||||||
|
} from "../browser/client-actions.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
import { shortenHomePath } from "../utils.js";
|
||||||
|
|
||||||
@@ -24,21 +32,14 @@ export function registerBrowserDebugCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (ref: string, opts, cmd) => {
|
.action(async (ref: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserDebug(async () => {
|
await runBrowserDebug(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserHighlight(baseUrl, {
|
||||||
parent,
|
ref: ref.trim(),
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/highlight",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
ref: ref.trim(),
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -54,23 +55,14 @@ export function registerBrowserDebugCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserDebug(async () => {
|
await runBrowserDebug(async () => {
|
||||||
const result = await callBrowserRequest<{
|
const result = await browserPageErrors(baseUrl, {
|
||||||
errors: Array<{ timestamp: string; name?: string; message: string }>;
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
}>(
|
clear: Boolean(opts.clear),
|
||||||
parent,
|
profile,
|
||||||
{
|
});
|
||||||
method: "GET",
|
|
||||||
path: "/errors",
|
|
||||||
query: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
clear: Boolean(opts.clear),
|
|
||||||
profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -95,31 +87,15 @@ export function registerBrowserDebugCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserDebug(async () => {
|
await runBrowserDebug(async () => {
|
||||||
const result = await callBrowserRequest<{
|
const result = await browserRequests(baseUrl, {
|
||||||
requests: Array<{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
timestamp: string;
|
filter: opts.filter?.trim() || undefined,
|
||||||
method: string;
|
clear: Boolean(opts.clear),
|
||||||
status?: number;
|
profile,
|
||||||
ok?: boolean;
|
});
|
||||||
url: string;
|
|
||||||
failureText?: string;
|
|
||||||
}>;
|
|
||||||
}>(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/requests",
|
|
||||||
query: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
filter: opts.filter?.trim() || undefined,
|
|
||||||
clear: Boolean(opts.clear),
|
|
||||||
profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -152,23 +128,16 @@ export function registerBrowserDebugCommands(
|
|||||||
.option("--sources", "Include sources (bigger traces)", false)
|
.option("--sources", "Include sources (bigger traces)", false)
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserDebug(async () => {
|
await runBrowserDebug(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserTraceStart(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
screenshots: Boolean(opts.screenshots),
|
||||||
method: "POST",
|
snapshots: Boolean(opts.snapshots),
|
||||||
path: "/trace/start",
|
sources: Boolean(opts.sources),
|
||||||
query: profile ? { profile } : undefined,
|
profile,
|
||||||
body: {
|
});
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
screenshots: Boolean(opts.screenshots),
|
|
||||||
snapshots: Boolean(opts.snapshots),
|
|
||||||
sources: Boolean(opts.sources),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -184,21 +153,14 @@ export function registerBrowserDebugCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserDebug(async () => {
|
await runBrowserDebug(async () => {
|
||||||
const result = await callBrowserRequest<{ path: string }>(
|
const result = await browserTraceStop(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
path: opts.out?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/trace/stop",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
path: opts.out?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
import type { SnapshotResult } from "../browser/client.js";
|
import { browserSnapshot, resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
|
import { browserScreenshotAction } from "../browser/client-actions.js";
|
||||||
import { loadConfig } from "../config/config.js";
|
import { loadConfig } from "../config/config.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
import { shortenHomePath } from "../utils.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
|
|
||||||
export function registerBrowserInspectCommands(
|
export function registerBrowserInspectCommands(
|
||||||
browser: Command,
|
browser: Command,
|
||||||
@@ -21,24 +22,17 @@ export function registerBrowserInspectCommands(
|
|||||||
.option("--type <png|jpeg>", "Output type (default: png)", "png")
|
.option("--type <png|jpeg>", "Output type (default: png)", "png")
|
||||||
.action(async (targetId: string | undefined, opts, cmd) => {
|
.action(async (targetId: string | undefined, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest<{ path: string }>(
|
const result = await browserScreenshotAction(baseUrl, {
|
||||||
parent,
|
targetId: targetId?.trim() || undefined,
|
||||||
{
|
fullPage: Boolean(opts.fullPage),
|
||||||
method: "POST",
|
ref: opts.ref?.trim() || undefined,
|
||||||
path: "/screenshot",
|
element: opts.element?.trim() || undefined,
|
||||||
query: profile ? { profile } : undefined,
|
type: opts.type === "jpeg" ? "jpeg" : "png",
|
||||||
body: {
|
profile,
|
||||||
targetId: targetId?.trim() || undefined,
|
});
|
||||||
fullPage: Boolean(opts.fullPage),
|
|
||||||
ref: opts.ref?.trim() || undefined,
|
|
||||||
element: opts.element?.trim() || undefined,
|
|
||||||
type: opts.type === "jpeg" ? "jpeg" : "png",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -67,6 +61,7 @@ export function registerBrowserInspectCommands(
|
|||||||
.option("--out <path>", "Write snapshot to a file")
|
.option("--out <path>", "Write snapshot to a file")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const format = opts.format === "aria" ? "aria" : "ai";
|
const format = opts.format === "aria" ? "aria" : "ai";
|
||||||
const configMode =
|
const configMode =
|
||||||
@@ -75,28 +70,19 @@ export function registerBrowserInspectCommands(
|
|||||||
: undefined;
|
: undefined;
|
||||||
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
const mode = opts.efficient === true || opts.mode === "efficient" ? "efficient" : configMode;
|
||||||
try {
|
try {
|
||||||
const query: Record<string, string | number | boolean | undefined> = {
|
const result = await browserSnapshot(baseUrl, {
|
||||||
format,
|
format,
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
|
limit: Number.isFinite(opts.limit) ? opts.limit : undefined,
|
||||||
interactive: opts.interactive ? true : undefined,
|
interactive: Boolean(opts.interactive) || undefined,
|
||||||
compact: opts.compact ? true : undefined,
|
compact: Boolean(opts.compact) || undefined,
|
||||||
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
|
depth: Number.isFinite(opts.depth) ? opts.depth : undefined,
|
||||||
selector: opts.selector?.trim() || undefined,
|
selector: opts.selector?.trim() || undefined,
|
||||||
frame: opts.frame?.trim() || undefined,
|
frame: opts.frame?.trim() || undefined,
|
||||||
labels: opts.labels ? true : undefined,
|
labels: Boolean(opts.labels) || undefined,
|
||||||
mode,
|
mode,
|
||||||
profile,
|
profile,
|
||||||
};
|
});
|
||||||
const result = await callBrowserRequest<SnapshotResult>(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/snapshot",
|
|
||||||
query,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
if (opts.out) {
|
if (opts.out) {
|
||||||
const fs = await import("node:fs/promises");
|
const fs = await import("node:fs/promises");
|
||||||
|
|||||||
+72
-186
@@ -1,16 +1,25 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
import type {
|
import type { BrowserTab } from "../browser/client.js";
|
||||||
BrowserCreateProfileResult,
|
import {
|
||||||
BrowserDeleteProfileResult,
|
browserCloseTab,
|
||||||
BrowserResetProfileResult,
|
browserCreateProfile,
|
||||||
BrowserStatus,
|
browserDeleteProfile,
|
||||||
BrowserTab,
|
browserFocusTab,
|
||||||
ProfileStatus,
|
browserOpenTab,
|
||||||
|
browserProfiles,
|
||||||
|
browserResetProfile,
|
||||||
|
browserStart,
|
||||||
|
browserStatus,
|
||||||
|
browserStop,
|
||||||
|
browserTabAction,
|
||||||
|
browserTabs,
|
||||||
|
resolveBrowserControlUrl,
|
||||||
} from "../browser/client.js";
|
} from "../browser/client.js";
|
||||||
|
import { browserAct } from "../browser/client-actions-core.js";
|
||||||
import { danger, info } from "../globals.js";
|
import { danger, info } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { shortenHomePath } from "../utils.js";
|
import { shortenHomePath } from "../utils.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
|
|
||||||
function runBrowserCommand(action: () => Promise<void>) {
|
function runBrowserCommand(action: () => Promise<void>) {
|
||||||
@@ -29,18 +38,11 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Show browser status")
|
.description("Show browser status")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const status = await callBrowserRequest<BrowserStatus>(
|
const status = await browserStatus(baseUrl, {
|
||||||
parent,
|
profile: parent?.browserProfile,
|
||||||
{
|
});
|
||||||
method: "GET",
|
|
||||||
path: "/",
|
|
||||||
query: parent?.browserProfile ? { profile: parent.browserProfile } : undefined,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
timeoutMs: 1500,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -52,6 +54,7 @@ export function registerBrowserManageCommands(
|
|||||||
`profile: ${status.profile ?? "clawd"}`,
|
`profile: ${status.profile ?? "clawd"}`,
|
||||||
`enabled: ${status.enabled}`,
|
`enabled: ${status.enabled}`,
|
||||||
`running: ${status.running}`,
|
`running: ${status.running}`,
|
||||||
|
`controlUrl: ${status.controlUrl}`,
|
||||||
`cdpPort: ${status.cdpPort}`,
|
`cdpPort: ${status.cdpPort}`,
|
||||||
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
`cdpUrl: ${status.cdpUrl ?? `http://127.0.0.1:${status.cdpPort}`}`,
|
||||||
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
`browser: ${status.chosenBrowser ?? "unknown"}`,
|
||||||
@@ -69,26 +72,11 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Start the browser (no-op if already running)")
|
.description("Start the browser (no-op if already running)")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
await callBrowserRequest(
|
await browserStart(baseUrl, { profile });
|
||||||
parent,
|
const status = await browserStatus(baseUrl, { profile });
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/start",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 15000 },
|
|
||||||
);
|
|
||||||
const status = await callBrowserRequest<BrowserStatus>(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 1500 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -103,26 +91,11 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Stop the browser (best-effort)")
|
.description("Stop the browser (best-effort)")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
await callBrowserRequest(
|
await browserStop(baseUrl, { profile });
|
||||||
parent,
|
const status = await browserStatus(baseUrl, { profile });
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/stop",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 15000 },
|
|
||||||
);
|
|
||||||
const status = await callBrowserRequest<BrowserStatus>(
|
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 1500 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(status, null, 2));
|
defaultRuntime.log(JSON.stringify(status, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -137,17 +110,10 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Reset browser profile (moves it to Trash)")
|
.description("Reset browser profile (moves it to Trash)")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<BrowserResetProfileResult>(
|
const result = await browserResetProfile(baseUrl, { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/reset-profile",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -166,18 +132,10 @@ export function registerBrowserManageCommands(
|
|||||||
.description("List open tabs")
|
.description("List open tabs")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<{ running: boolean; tabs: BrowserTab[] }>(
|
const tabs = await browserTabs(baseUrl, { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/tabs",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 3000 },
|
|
||||||
);
|
|
||||||
const tabs = result.tabs ?? [];
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -201,20 +159,13 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Tab shortcuts (index-based)")
|
.description("Tab shortcuts (index-based)")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<{ ok: true; tabs: BrowserTab[] }>(
|
const result = (await browserTabAction(baseUrl, {
|
||||||
parent,
|
action: "list",
|
||||||
{
|
profile,
|
||||||
method: "POST",
|
})) as { ok: true; tabs: BrowserTab[] };
|
||||||
path: "/tabs/action",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
action: "list",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 10_000 },
|
|
||||||
);
|
|
||||||
const tabs = result.tabs ?? [];
|
const tabs = result.tabs ?? [];
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
defaultRuntime.log(JSON.stringify({ tabs }, null, 2));
|
||||||
@@ -239,18 +190,13 @@ export function registerBrowserManageCommands(
|
|||||||
.description("Open a new tab (about:blank)")
|
.description("Open a new tab (about:blank)")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserTabAction(baseUrl, {
|
||||||
parent,
|
action: "new",
|
||||||
{
|
profile,
|
||||||
method: "POST",
|
});
|
||||||
path: "/tabs/action",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { action: "new" },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 10_000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -265,6 +211,7 @@ export function registerBrowserManageCommands(
|
|||||||
.argument("<index>", "Tab index (1-based)", (v: string) => Number(v))
|
.argument("<index>", "Tab index (1-based)", (v: string) => Number(v))
|
||||||
.action(async (index: number, _opts, cmd) => {
|
.action(async (index: number, _opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
if (!Number.isFinite(index) || index < 1) {
|
if (!Number.isFinite(index) || index < 1) {
|
||||||
defaultRuntime.error(danger("index must be a positive number"));
|
defaultRuntime.error(danger("index must be a positive number"));
|
||||||
@@ -272,16 +219,11 @@ export function registerBrowserManageCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserTabAction(baseUrl, {
|
||||||
parent,
|
action: "select",
|
||||||
{
|
index: Math.floor(index) - 1,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/tabs/action",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { action: "select", index: Math.floor(index) - 1 },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 10_000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -296,6 +238,7 @@ export function registerBrowserManageCommands(
|
|||||||
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
|
.argument("[index]", "Tab index (1-based)", (v: string) => Number(v))
|
||||||
.action(async (index: number | undefined, _opts, cmd) => {
|
.action(async (index: number | undefined, _opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const idx =
|
const idx =
|
||||||
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
|
typeof index === "number" && Number.isFinite(index) ? Math.floor(index) - 1 : undefined;
|
||||||
@@ -305,16 +248,11 @@ export function registerBrowserManageCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserTabAction(baseUrl, {
|
||||||
parent,
|
action: "close",
|
||||||
{
|
index: idx,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/tabs/action",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { action: "close", index: idx },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 10_000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -329,18 +267,10 @@ export function registerBrowserManageCommands(
|
|||||||
.argument("<url>", "URL to open")
|
.argument("<url>", "URL to open")
|
||||||
.action(async (url: string, _opts, cmd) => {
|
.action(async (url: string, _opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const tab = await callBrowserRequest<BrowserTab>(
|
const tab = await browserOpenTab(baseUrl, url, { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/tabs/open",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { url },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 15000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(tab, null, 2));
|
defaultRuntime.log(JSON.stringify(tab, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -355,18 +285,10 @@ export function registerBrowserManageCommands(
|
|||||||
.argument("<targetId>", "Target id or unique prefix")
|
.argument("<targetId>", "Target id or unique prefix")
|
||||||
.action(async (targetId: string, _opts, cmd) => {
|
.action(async (targetId: string, _opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
await callBrowserRequest(
|
await browserFocusTab(baseUrl, targetId, { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/tabs/focus",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { targetId },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 5000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -381,29 +303,13 @@ export function registerBrowserManageCommands(
|
|||||||
.argument("[targetId]", "Target id or unique prefix (optional)")
|
.argument("[targetId]", "Target id or unique prefix (optional)")
|
||||||
.action(async (targetId: string | undefined, _opts, cmd) => {
|
.action(async (targetId: string | undefined, _opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
if (targetId?.trim()) {
|
if (targetId?.trim()) {
|
||||||
await callBrowserRequest(
|
await browserCloseTab(baseUrl, targetId.trim(), { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
path: `/tabs/${encodeURIComponent(targetId.trim())}`,
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 5000 },
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
await callBrowserRequest(
|
await browserAct(baseUrl, { kind: "close" }, { profile });
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "POST",
|
|
||||||
path: "/act",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: { kind: "close" },
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
defaultRuntime.log(JSON.stringify({ ok: true }, null, 2));
|
||||||
@@ -419,16 +325,9 @@ export function registerBrowserManageCommands(
|
|||||||
.description("List all browser profiles")
|
.description("List all browser profiles")
|
||||||
.action(async (_opts, cmd) => {
|
.action(async (_opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<{ profiles: ProfileStatus[] }>(
|
const profiles = await browserProfiles(baseUrl);
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "GET",
|
|
||||||
path: "/profiles",
|
|
||||||
},
|
|
||||||
{ timeoutMs: 3000 },
|
|
||||||
);
|
|
||||||
const profiles = result.profiles ?? [];
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
|
defaultRuntime.log(JSON.stringify({ profiles }, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -462,21 +361,14 @@ export function registerBrowserManageCommands(
|
|||||||
.action(
|
.action(
|
||||||
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
|
async (opts: { name: string; color?: string; cdpUrl?: string; driver?: string }, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<BrowserCreateProfileResult>(
|
const result = await browserCreateProfile(baseUrl, {
|
||||||
parent,
|
name: opts.name,
|
||||||
{
|
color: opts.color,
|
||||||
method: "POST",
|
cdpUrl: opts.cdpUrl,
|
||||||
path: "/profiles/create",
|
driver: opts.driver === "extension" ? "extension" : undefined,
|
||||||
body: {
|
});
|
||||||
name: opts.name,
|
|
||||||
color: opts.color,
|
|
||||||
cdpUrl: opts.cdpUrl,
|
|
||||||
driver: opts.driver === "extension" ? "extension" : undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 10_000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -499,15 +391,9 @@ export function registerBrowserManageCommands(
|
|||||||
.requiredOption("--name <name>", "Profile name to delete")
|
.requiredOption("--name <name>", "Profile name to delete")
|
||||||
.action(async (opts: { name: string }, cmd) => {
|
.action(async (opts: { name: string }, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest<BrowserDeleteProfileResult>(
|
const result = await browserDeleteProfile(baseUrl, opts.name);
|
||||||
parent,
|
|
||||||
{
|
|
||||||
method: "DELETE",
|
|
||||||
path: `/profiles/${encodeURIComponent(opts.name)}`,
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20_000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { loadConfig } from "../config/config.js";
|
||||||
|
import { danger, info } from "../globals.js";
|
||||||
|
import { defaultRuntime } from "../runtime.js";
|
||||||
|
import { resolveBrowserConfig, resolveProfile } from "../browser/config.js";
|
||||||
|
import { startBrowserBridgeServer, stopBrowserBridgeServer } from "../browser/bridge-server.js";
|
||||||
|
import { ensureChromeExtensionRelayServer } from "../browser/extension-relay.js";
|
||||||
|
|
||||||
|
function isLoopbackBindHost(host: string) {
|
||||||
|
const h = host.trim().toLowerCase();
|
||||||
|
return h === "localhost" || h === "127.0.0.1" || h === "::1" || h === "[::1]";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePort(raw: unknown): number | null {
|
||||||
|
const v = typeof raw === "string" ? raw.trim() : "";
|
||||||
|
if (!v) return null;
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 65535) return null;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerBrowserServeCommands(
|
||||||
|
browser: Command,
|
||||||
|
_parentOpts: (cmd: Command) => unknown,
|
||||||
|
) {
|
||||||
|
browser
|
||||||
|
.command("serve")
|
||||||
|
.description("Run a standalone browser control server (for remote gateways)")
|
||||||
|
.option("--bind <host>", "Bind host (default: 127.0.0.1)")
|
||||||
|
.option("--port <port>", "Bind port (default: from browser.controlUrl)")
|
||||||
|
.option(
|
||||||
|
"--token <token>",
|
||||||
|
"Require Authorization: Bearer <token> (required when binding non-loopback)",
|
||||||
|
)
|
||||||
|
.action(async (opts: { bind?: string; port?: string; token?: string }) => {
|
||||||
|
const cfg = loadConfig();
|
||||||
|
const resolved = resolveBrowserConfig(cfg.browser);
|
||||||
|
if (!resolved.enabled) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger("Browser control is disabled. Set browser.enabled=true and try again."),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const host = (opts.bind ?? "127.0.0.1").trim();
|
||||||
|
const port = parsePort(opts.port) ?? resolved.controlPort;
|
||||||
|
|
||||||
|
const envToken = process.env.CLAWDBOT_BROWSER_CONTROL_TOKEN?.trim();
|
||||||
|
const authToken = (opts.token ?? envToken ?? resolved.controlToken)?.trim();
|
||||||
|
if (!isLoopbackBindHost(host) && !authToken) {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(
|
||||||
|
`Refusing to bind browser control on ${host} without --token (or CLAWDBOT_BROWSER_CONTROL_TOKEN, or browser.controlToken).`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
defaultRuntime.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bridge = await startBrowserBridgeServer({
|
||||||
|
resolved,
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
...(authToken ? { authToken } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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) => {
|
||||||
|
defaultRuntime.error(
|
||||||
|
danger(`Chrome extension relay init failed for profile "${name}": ${String(err)}`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultRuntime.log(
|
||||||
|
info(
|
||||||
|
[
|
||||||
|
`🦞 Browser control listening on ${bridge.baseUrl}/`,
|
||||||
|
authToken ? "Auth: Bearer token required." : "Auth: off (loopback only).",
|
||||||
|
"",
|
||||||
|
"Paste on the Gateway (clawdbot.json):",
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
browser: {
|
||||||
|
enabled: true,
|
||||||
|
controlUrl: bridge.baseUrl,
|
||||||
|
...(authToken ? { controlToken: authToken } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
...(authToken
|
||||||
|
? [
|
||||||
|
"",
|
||||||
|
"Or use env on the Gateway (instead of controlToken in config):",
|
||||||
|
`export CLAWDBOT_BROWSER_CONTROL_TOKEN=${JSON.stringify(authToken)}`,
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
].join("\n"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = async (signal: string) => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
defaultRuntime.log(info(`Shutting down (${signal})...`));
|
||||||
|
await stopBrowserBridgeServer(bridge.server).catch(() => {});
|
||||||
|
process.exit(0);
|
||||||
|
};
|
||||||
|
process.once("SIGINT", () => void shutdown("SIGINT"));
|
||||||
|
process.once("SIGTERM", () => void shutdown("SIGTERM"));
|
||||||
|
|
||||||
|
await new Promise(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,58 +1,5 @@
|
|||||||
import type { GatewayRpcOpts } from "./gateway-rpc.js";
|
export type BrowserParentOpts = {
|
||||||
import { callGatewayFromCli } from "./gateway-rpc.js";
|
url?: string;
|
||||||
|
|
||||||
export type BrowserParentOpts = GatewayRpcOpts & {
|
|
||||||
json?: boolean;
|
json?: boolean;
|
||||||
browserProfile?: string;
|
browserProfile?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type BrowserRequestParams = {
|
|
||||||
method: "GET" | "POST" | "DELETE";
|
|
||||||
path: string;
|
|
||||||
query?: Record<string, string | number | boolean | undefined>;
|
|
||||||
body?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
function normalizeQuery(query: BrowserRequestParams["query"]): Record<string, string> | undefined {
|
|
||||||
if (!query) return undefined;
|
|
||||||
const out: Record<string, string> = {};
|
|
||||||
for (const [key, value] of Object.entries(query)) {
|
|
||||||
if (value === undefined) continue;
|
|
||||||
out[key] = String(value);
|
|
||||||
}
|
|
||||||
return Object.keys(out).length ? out : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function callBrowserRequest<T>(
|
|
||||||
opts: BrowserParentOpts,
|
|
||||||
params: BrowserRequestParams,
|
|
||||||
extra?: { timeoutMs?: number; progress?: boolean },
|
|
||||||
): Promise<T> {
|
|
||||||
const resolvedTimeoutMs =
|
|
||||||
typeof extra?.timeoutMs === "number" && Number.isFinite(extra.timeoutMs)
|
|
||||||
? Math.max(1, Math.floor(extra.timeoutMs))
|
|
||||||
: typeof opts.timeout === "string"
|
|
||||||
? Number.parseInt(opts.timeout, 10)
|
|
||||||
: undefined;
|
|
||||||
const resolvedTimeout =
|
|
||||||
typeof resolvedTimeoutMs === "number" && Number.isFinite(resolvedTimeoutMs)
|
|
||||||
? resolvedTimeoutMs
|
|
||||||
: undefined;
|
|
||||||
const timeout = typeof resolvedTimeout === "number" ? String(resolvedTimeout) : opts.timeout;
|
|
||||||
const payload = await callGatewayFromCli(
|
|
||||||
"browser.request",
|
|
||||||
{ ...opts, timeout },
|
|
||||||
{
|
|
||||||
method: params.method,
|
|
||||||
path: params.path,
|
|
||||||
query: normalizeQuery(params.query),
|
|
||||||
body: params.body,
|
|
||||||
timeoutMs: resolvedTimeout,
|
|
||||||
},
|
|
||||||
{ progress: extra?.progress },
|
|
||||||
);
|
|
||||||
if (payload === undefined) {
|
|
||||||
throw new Error("Unexpected browser.request response");
|
|
||||||
}
|
|
||||||
return payload as T;
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,17 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
|
import {
|
||||||
|
browserCookies,
|
||||||
|
browserCookiesClear,
|
||||||
|
browserCookiesSet,
|
||||||
|
browserStorageClear,
|
||||||
|
browserStorageGet,
|
||||||
|
browserStorageSet,
|
||||||
|
} from "../browser/client-actions.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
|
|
||||||
export function registerBrowserCookiesAndStorageCommands(
|
export function registerBrowserCookiesAndStorageCommands(
|
||||||
browser: Command,
|
browser: Command,
|
||||||
@@ -14,20 +23,13 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest<{ cookies?: unknown[] }>(
|
const result = await browserCookies(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
profile,
|
||||||
method: "GET",
|
});
|
||||||
path: "/cookies",
|
|
||||||
query: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -48,21 +50,14 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (name: string, value: string, opts, cmd) => {
|
.action(async (name: string, value: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserCookiesSet(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
cookie: { name, value, url: opts.url },
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/cookies/set",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
cookie: { name, value, url: opts.url },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -80,20 +75,13 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserCookiesClear(baseUrl, {
|
||||||
parent,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
{
|
profile,
|
||||||
method: "POST",
|
});
|
||||||
path: "/cookies/clear",
|
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -117,21 +105,15 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (key: string | undefined, opts, cmd2) => {
|
.action(async (key: string | undefined, opts, cmd2) => {
|
||||||
const parent = parentOpts(cmd2);
|
const parent = parentOpts(cmd2);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest<{ values?: Record<string, string> }>(
|
const result = await browserStorageGet(baseUrl, {
|
||||||
parent,
|
kind,
|
||||||
{
|
key: key?.trim() || undefined,
|
||||||
method: "GET",
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
path: `/storage/${kind}`,
|
profile,
|
||||||
query: {
|
});
|
||||||
key: key?.trim() || undefined,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
profile,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -151,22 +133,16 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (key: string, value: string, opts, cmd2) => {
|
.action(async (key: string, value: string, opts, cmd2) => {
|
||||||
const parent = parentOpts(cmd2);
|
const parent = parentOpts(cmd2);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserStorageSet(baseUrl, {
|
||||||
parent,
|
kind,
|
||||||
{
|
key,
|
||||||
method: "POST",
|
value,
|
||||||
path: `/storage/${kind}/set`,
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
query: profile ? { profile } : undefined,
|
profile,
|
||||||
body: {
|
});
|
||||||
key,
|
|
||||||
value,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -184,20 +160,14 @@ export function registerBrowserCookiesAndStorageCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd2) => {
|
.action(async (opts, cmd2) => {
|
||||||
const parent = parentOpts(cmd2);
|
const parent = parentOpts(cmd2);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
try {
|
try {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserStorageClear(baseUrl, {
|
||||||
parent,
|
kind,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: `/storage/${kind}/clear`,
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
+75
-123
@@ -1,9 +1,21 @@
|
|||||||
import type { Command } from "commander";
|
import type { Command } from "commander";
|
||||||
|
|
||||||
|
import { resolveBrowserControlUrl } from "../browser/client.js";
|
||||||
|
import {
|
||||||
|
browserSetDevice,
|
||||||
|
browserSetGeolocation,
|
||||||
|
browserSetHeaders,
|
||||||
|
browserSetHttpCredentials,
|
||||||
|
browserSetLocale,
|
||||||
|
browserSetMedia,
|
||||||
|
browserSetOffline,
|
||||||
|
browserSetTimezone,
|
||||||
|
} from "../browser/client-actions.js";
|
||||||
|
import { browserAct } from "../browser/client-actions-core.js";
|
||||||
import { danger } from "../globals.js";
|
import { danger } from "../globals.js";
|
||||||
import { defaultRuntime } from "../runtime.js";
|
import { defaultRuntime } from "../runtime.js";
|
||||||
import { parseBooleanValue } from "../utils/boolean.js";
|
import { parseBooleanValue } from "../utils/boolean.js";
|
||||||
import { callBrowserRequest, type BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
|
import { registerBrowserCookiesAndStorageCommands } from "./browser-cli-state.cookies-storage.js";
|
||||||
import { runCommandWithRuntime } from "./cli-utils.js";
|
import { runCommandWithRuntime } from "./cli-utils.js";
|
||||||
|
|
||||||
@@ -35,6 +47,7 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (width: number, height: number, opts, cmd) => {
|
.action(async (width: number, height: number, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
if (!Number.isFinite(width) || !Number.isFinite(height)) {
|
||||||
defaultRuntime.error(danger("width and height must be numbers"));
|
defaultRuntime.error(danger("width and height must be numbers"));
|
||||||
@@ -42,20 +55,15 @@ export function registerBrowserStateCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserAct(
|
||||||
parent,
|
baseUrl,
|
||||||
{
|
{
|
||||||
method: "POST",
|
kind: "resize",
|
||||||
path: "/act",
|
width,
|
||||||
query: profile ? { profile } : undefined,
|
height,
|
||||||
body: {
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
kind: "resize",
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
{ timeoutMs: 20000 },
|
{ profile },
|
||||||
);
|
);
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
@@ -72,6 +80,7 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (value: string, opts, cmd) => {
|
.action(async (value: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const offline = parseOnOff(value);
|
const offline = parseOnOff(value);
|
||||||
if (offline === null) {
|
if (offline === null) {
|
||||||
@@ -80,19 +89,11 @@ export function registerBrowserStateCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetOffline(baseUrl, {
|
||||||
parent,
|
offline,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/offline",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
offline,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -108,6 +109,7 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (opts, cmd) => {
|
.action(async (opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const parsed = JSON.parse(String(opts.json)) as unknown;
|
const parsed = JSON.parse(String(opts.json)) as unknown;
|
||||||
@@ -118,19 +120,11 @@ export function registerBrowserStateCommands(
|
|||||||
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
|
||||||
if (typeof v === "string") headers[k] = v;
|
if (typeof v === "string") headers[k] = v;
|
||||||
}
|
}
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetHeaders(baseUrl, {
|
||||||
parent,
|
headers,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/headers",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
headers,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -148,23 +142,16 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
|
.action(async (username: string | undefined, password: string | undefined, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetHttpCredentials(baseUrl, {
|
||||||
parent,
|
username: username?.trim() || undefined,
|
||||||
{
|
password,
|
||||||
method: "POST",
|
clear: Boolean(opts.clear),
|
||||||
path: "/set/credentials",
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
query: profile ? { profile } : undefined,
|
profile,
|
||||||
body: {
|
});
|
||||||
username: username?.trim() || undefined,
|
|
||||||
password,
|
|
||||||
clear: Boolean(opts.clear),
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -184,25 +171,18 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
|
.action(async (latitude: number | undefined, longitude: number | undefined, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetGeolocation(baseUrl, {
|
||||||
parent,
|
latitude: Number.isFinite(latitude) ? latitude : undefined,
|
||||||
{
|
longitude: Number.isFinite(longitude) ? longitude : undefined,
|
||||||
method: "POST",
|
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
|
||||||
path: "/set/geolocation",
|
origin: opts.origin?.trim() || undefined,
|
||||||
query: profile ? { profile } : undefined,
|
clear: Boolean(opts.clear),
|
||||||
body: {
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
latitude: Number.isFinite(latitude) ? latitude : undefined,
|
profile,
|
||||||
longitude: Number.isFinite(longitude) ? longitude : undefined,
|
});
|
||||||
accuracy: Number.isFinite(opts.accuracy) ? opts.accuracy : undefined,
|
|
||||||
origin: opts.origin?.trim() || undefined,
|
|
||||||
clear: Boolean(opts.clear),
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -218,6 +198,7 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (value: string, opts, cmd) => {
|
.action(async (value: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
const v = value.trim().toLowerCase();
|
const v = value.trim().toLowerCase();
|
||||||
const colorScheme =
|
const colorScheme =
|
||||||
@@ -228,19 +209,11 @@ export function registerBrowserStateCommands(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetMedia(baseUrl, {
|
||||||
parent,
|
colorScheme,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/media",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
colorScheme,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -256,21 +229,14 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (timezoneId: string, opts, cmd) => {
|
.action(async (timezoneId: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetTimezone(baseUrl, {
|
||||||
parent,
|
timezoneId,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/timezone",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
timezoneId,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -286,21 +252,14 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (locale: string, opts, cmd) => {
|
.action(async (locale: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetLocale(baseUrl, {
|
||||||
parent,
|
locale,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/locale",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
locale,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
@@ -316,21 +275,14 @@ export function registerBrowserStateCommands(
|
|||||||
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
.option("--target-id <id>", "CDP target id (or unique prefix)")
|
||||||
.action(async (name: string, opts, cmd) => {
|
.action(async (name: string, opts, cmd) => {
|
||||||
const parent = parentOpts(cmd);
|
const parent = parentOpts(cmd);
|
||||||
|
const baseUrl = resolveBrowserControlUrl(parent?.url);
|
||||||
const profile = parent?.browserProfile;
|
const profile = parent?.browserProfile;
|
||||||
await runBrowserCommand(async () => {
|
await runBrowserCommand(async () => {
|
||||||
const result = await callBrowserRequest(
|
const result = await browserSetDevice(baseUrl, {
|
||||||
parent,
|
name,
|
||||||
{
|
targetId: opts.targetId?.trim() || undefined,
|
||||||
method: "POST",
|
profile,
|
||||||
path: "/set/device",
|
});
|
||||||
query: profile ? { profile } : undefined,
|
|
||||||
body: {
|
|
||||||
name,
|
|
||||||
targetId: opts.targetId?.trim() || undefined,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ timeoutMs: 20000 },
|
|
||||||
);
|
|
||||||
if (parent?.json) {
|
if (parent?.json) {
|
||||||
defaultRuntime.log(JSON.stringify(result, null, 2));
|
defaultRuntime.log(JSON.stringify(result, null, 2));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -13,14 +13,15 @@ import { browserActionExamples, browserCoreExamples } from "./browser-cli-exampl
|
|||||||
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
|
import { registerBrowserExtensionCommands } from "./browser-cli-extension.js";
|
||||||
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
import { registerBrowserInspectCommands } from "./browser-cli-inspect.js";
|
||||||
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
import { registerBrowserManageCommands } from "./browser-cli-manage.js";
|
||||||
|
import { registerBrowserServeCommands } from "./browser-cli-serve.js";
|
||||||
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
import type { BrowserParentOpts } from "./browser-cli-shared.js";
|
||||||
import { registerBrowserStateCommands } from "./browser-cli-state.js";
|
import { registerBrowserStateCommands } from "./browser-cli-state.js";
|
||||||
import { addGatewayClientOptions } from "./gateway-rpc.js";
|
|
||||||
|
|
||||||
export function registerBrowserCli(program: Command) {
|
export function registerBrowserCli(program: Command) {
|
||||||
const browser = program
|
const browser = program
|
||||||
.command("browser")
|
.command("browser")
|
||||||
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
|
.description("Manage clawd's dedicated browser (Chrome/Chromium)")
|
||||||
|
.option("--url <url>", "Override browser control URL (default from ~/.clawdbot/clawdbot.json)")
|
||||||
.option("--browser-profile <name>", "Browser profile name (default from config)")
|
.option("--browser-profile <name>", "Browser profile name (default from config)")
|
||||||
.option("--json", "Output machine-readable JSON", false)
|
.option("--json", "Output machine-readable JSON", false)
|
||||||
.addHelpText(
|
.addHelpText(
|
||||||
@@ -42,12 +43,11 @@ export function registerBrowserCli(program: Command) {
|
|||||||
defaultRuntime.exit(1);
|
defaultRuntime.exit(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
addGatewayClientOptions(browser);
|
|
||||||
|
|
||||||
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
|
const parentOpts = (cmd: Command) => cmd.parent?.opts?.() as BrowserParentOpts;
|
||||||
|
|
||||||
registerBrowserManageCommands(browser, parentOpts);
|
registerBrowserManageCommands(browser, parentOpts);
|
||||||
registerBrowserExtensionCommands(browser, parentOpts);
|
registerBrowserExtensionCommands(browser, parentOpts);
|
||||||
|
registerBrowserServeCommands(browser, parentOpts);
|
||||||
registerBrowserInspectCommands(browser, parentOpts);
|
registerBrowserInspectCommands(browser, parentOpts);
|
||||||
registerBrowserActionInputCommands(browser, parentOpts);
|
registerBrowserActionInputCommands(browser, parentOpts);
|
||||||
registerBrowserActionObserveCommands(browser, parentOpts);
|
registerBrowserActionObserveCommands(browser, parentOpts);
|
||||||
|
|||||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user