Compare commits

...
17 Commits
Author SHA1 Message Date
BB-fatandCursor eda8b22c43 chore: bump CLI to 0.1.7 and extension to 0.1.3
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:11:22 +08:00
BB-fat c987e42da9 Merge pull request #7 from Tencent/feat/console-command
feat: add bsk console command
2026-07-07 11:27:09 +08:00
BB-fatandCursor 7f5093d97a feat: add console observation command
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 11:08:12 +08:00
BB-fat ce29ddaa53 Merge pull request #5 from hobostay/fix/borrow-confirmation-tabs-get-fail-open
fix(extension): don't fail-open the borrow gate on a transient tabs.get error
2026-07-06 11:46:39 +08:00
BB-fat 2e7998477d Merge pull request #4 from hobostay/fix/ws-event-ownership
fix(daemon): reject cross-browser session events from non-owners
2026-07-06 11:44:19 +08:00
BB-fat c9e00bb641 Merge pull request #3 from hobostay/fix/tab-return-fallback-into-agent-window
fix(extension): never return a borrowed tab into another session's Agent Window
2026-07-06 11:33:38 +08:00
Test UserandClaude ad2a12e57e fix(extension): don't fail-open the borrow gate on a transient tabs.get error
requestBorrowConfirmation returned `true` (approve the borrow) when
`chrome.tabs.get(tabId)` threw. That fail-open was not one of the
documented fail-open cases in the function's own doc comment (no
candidate window; abort signal; timeout), and the tab was already
fetched successfully moments earlier by the borrow pipeline
(validateBorrowTarget), so a failure here is a transient SW/page hiccup
— not a missing tab. Silently approving a borrow with no confirmation
UI shown defeats the entire human-in-the-loop gate for seizing a user's
logged-in tab.

On a tabs.get failure, fall back to a generic title (the numeric tab id)
and still run the overlay + OS-notification confirmation flow so the
user gets a real chance to approve or deny.

Adds two regression tests (both fail on the unpatched code). Full
extension suite green (361 tests).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 21:02:54 +08:00
Test UserandClaude 61096110c0 fix(daemon): reject cross-browser session events from non-owners
The daemon dispatched inbound `session.window_closed` and
`session.user_interrupt` events using only the payload — the sending
browser's identity was never checked against the session's owner.

Any connected extension could therefore emit window_closed with
another browser's session_id to tear down its session (forgetting it,
dropping its tool queue + interrupt marker), or emit user_interrupt
to cancel every inflight tool of another browser's session and arm
its interrupt marker. This is a cross-tenant integrity/DoS hole that
is exploitable even between two legitimate extensions, independent of
the (separately tracked) WS Origin allow-list gap.

Thread the sender's BrowserId (already in scope as `client.id` in
handle_inbound_text) into both handlers and ignore the event when the
named session is live and owned by a different browser. A session
that is already gone is left to the existing no-op forget path.

Adds four ownership tests (owner honoured / non-owner ignored for each
event); updated two existing tests for the new signature. Full bsk
suite green (165 lib + integration), no new clippy warnings.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 20:47:42 +08:00
Test UserandClaude 8e8dd3840b fix(extension): never return a borrowed tab into another session's Agent Window
When a borrowed tab's original window is gone, tab_return's fallback
picker called getLastFocused({windowTypes:["normal"]}) and only
excluded the *current* session's Agent Window. Agent Windows are
created with type:"normal", so the picker could move a returned user
tab into *another* session's Agent Window. That session could then
write to the tab (it passes enforceAgentWindow), and the tab would be
destroyed when that session stops and closes its window — a
cross-session sandbox break plus silent user-data loss.

Thread an isAgentWindowId predicate (derived from SessionManager in
the production handlers, mirroring the existing filter in
borrow-confirmation's listConfirmationCandidates) through
returnBorrowedTab -> chooseFallbackWindow so every live Agent Window
is skipped as a fallback target.

Adds two regression tests (both fail on the unpatched code).

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-02 20:23:02 +08:00
BB-fatandCursor 9c229b2489 chore: bump CLI version to 0.1.6
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-30 18:14:26 +08:00
BB-fatandCursor 26e5f98c16 docs: open extension install page from agent guide
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 15:55:20 +08:00
BB-fatandCursor dc8a6d38b3 Fix browser wait during daemon startup
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 15:04:20 +08:00
BB-fat ab6aa16dfc feat: add Git Bash PATH support to installer
- Add Add-ToBashProfile helper to append install dir to ~/.bashrc
- Converts Windows paths to Unix-style for Git Bash compatibility
- Integrate into Main install flow alongside Add-ToUserPath
- Update completion message to mention Git Bash
2026-06-23 11:00:47 +08:00
BB-fat 45c85fa64a feat: add PowerShell install script for Windows
- Add install.ps1 mirrored from install.sh patterns
  - Same env var overrides (BSK_REPO, BSK_VERSION, BSK_INSTALL_DIR)
  - Default install dir ~/.local/bin
  - Fetches latest version from version.json via Invoke-RestMethod
  - Downloads, extracts zip, installs bsk.exe
  - Adds to user PATH persistently with idempotent check
  - Supports x64 and ARM64 architecture detection
- Update README.md Windows section with irm | iex one-liner
- Update AGENT_INSTALL.md with Windows PowerShell instructions
2026-06-23 10:45:29 +08:00
BB-fat b8c4be4362 docs: move video link after intro paragraph in both READMEs 2026-06-22 17:55:42 +08:00
BB-fat 335391c2bf docs: sync video link to Chinese README 2026-06-22 17:51:20 +08:00
BB-fat 3cf81d509b chore: add demo video to README 2026-06-22 17:49:52 +08:00
47 changed files with 2636 additions and 176 deletions
+17 -4
View File
@@ -6,13 +6,22 @@ extension yourself (Step 3 is the user's job).
## 1. Install the CLI and skill ## 1. Install the CLI and skill
Run the install command from [`README.md`](README.md) (macOS/Linux): Run the install command from [`README.md`](README.md):
**macOS / Linux:**
```bash ```bash
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
bsk install-skill --yes bsk install-skill --yes
``` ```
**Windows (PowerShell):**
```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
bsk install-skill --yes
```
## 2. Run `bsk doctor` ## 2. Run `bsk doctor`
```bash ```bash
@@ -22,12 +31,16 @@ bsk doctor
Each `fail` row prints a `hint` — follow it and re-run once. A fresh install Each `fail` row prints a `hint` — follow it and re-run once. A fresh install
where only `extension connected` fails is expected; go to Step 3. where only `extension connected` fails is expected; go to Step 3.
## 3. Ask the user to install the extension ## 3. Open the extension install page
If `extension connected` is `FAIL` (`0 browsers connected`), tell the user: If `extension connected` is `FAIL` (`0 browsers connected`), the user likely
has not installed or enabled the browser extension yet. Open the
[Chrome Web Store install page](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi)
in the user's browser, then tell the user:
> Install **BrowserSkill** from the > Install **BrowserSkill** from the
> [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi), > [Chrome Web Store](https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi),
> open the popup, and wait until it turns green. Reply when done. > open the popup, and wait until it turns green. Reply when done.
Then run `bsk doctor` once more. All `ok`/`na` → tell the user it's ready. If opening the page fails, give the user the same link. Then run `bsk doctor`
once more. All `ok`/`na` → tell the user it's ready.
Generated
+2 -2
View File
@@ -84,7 +84,7 @@ dependencies = [
[[package]] [[package]]
name = "bsk" name = "bsk"
version = "0.1.5" version = "0.1.7"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bsk-protocol", "bsk-protocol",
@@ -113,7 +113,7 @@ dependencies = [
[[package]] [[package]]
name = "bsk-protocol" name = "bsk-protocol"
version = "0.1.5" version = "0.1.7"
dependencies = [ dependencies = [
"schemars", "schemars",
"semver", "semver",
+2 -2
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"] default-members = ["crates/bsk-cli"]
[workspace.package] [workspace.package]
version = "0.1.5" version = "0.1.7"
edition = "2024" edition = "2024"
rust-version = "1.85" rust-version = "1.85"
license = "MIT" license = "MIT"
@@ -37,7 +37,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] } uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents) # Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.1.5", path = "crates/bsk-protocol" } bsk-protocol = { version = "0.1.7", path = "crates/bsk-protocol" }
[profile.release] [profile.release]
strip = "symbols" strip = "symbols"
+7 -3
View File
@@ -20,6 +20,8 @@ Need the agent to touch a tab you already have open? It must borrow that tab
explicitly, return it when the task is done, and leave the rest of your browser explicitly, return it when the task is done, and leave the rest of your browser
alone. alone.
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill Advantages ## BrowserSkill Advantages
- **Reuse real login state**: Agents can work with sites you are already signed - **Reuse real login state**: Agents can work with sites you are already signed
@@ -75,9 +77,11 @@ Install the CLI, then install the extension from the [Chrome Web Store](https://
curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh curl -fsSL https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.sh | sh
``` ```
**Windows**: download `bsk-v<version>-x86_64-pc-windows-msvc.zip` from the **Windows** (PowerShell — installs to `~/.local/bin`):
[latest CLI release](https://github.com/Tencent/BrowserSkill/releases/latest)
and add the extracted `bsk.exe` to your `PATH`. ```powershell
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
```
Verify the binary: Verify the binary:
+2
View File
@@ -16,6 +16,8 @@
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。 需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
## BrowserSkill 的优势 ## BrowserSkill 的优势
- **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。 - **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@browser-skill/extension", "name": "@browser-skill/extension",
"version": "0.1.2", "version": "0.1.3",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -119,27 +119,41 @@ describe("ChromiumCdp", () => {
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {}); expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 9 }, "Page.enable", {});
}); });
it("enables console capture domains best-effort during attach", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Log.enable") throw new Error("restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(12)).resolves.toBeUndefined();
expect(cdp.isAttached(12)).toBe(true);
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Runtime.enable", {});
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 12 }, "Log.enable", {});
await cdp.ensureConsoleCapture(12);
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Runtime.enable" || method === "Log.enable",
);
expect(enableCalls).toHaveLength(2);
});
it("records javascriptDialogOpening and auto-accepts", async () => { it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi(); const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(3); await cdp.ensureAttached(3);
const cursor = cdp.dialogCursor(3); const cursor = cdp.dialogCursor(3);
onEvent.fire( onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", {
{ tabId: 3 }, type: "alert",
"Page.javascriptDialogOpening", message: "hello",
{ url: "https://example.com/",
type: "alert", hasBrowserHandler: false,
message: "hello", });
url: "https://example.com/",
hasBrowserHandler: false,
},
);
await Promise.resolve(); await Promise.resolve();
expect(api.sendCommand).toHaveBeenCalledWith( expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", {
{ tabId: 3 }, accept: true,
"Page.handleJavaScriptDialog", });
{ accept: true },
);
const dialogs = cdp.dialogsSince(3, cursor); const dialogs = cdp.dialogsSince(3, cursor);
expect(dialogs).toHaveLength(1); expect(dialogs).toHaveLength(1);
expect(dialogs[0]).toMatchObject({ expect(dialogs[0]).toMatchObject({
@@ -170,11 +184,11 @@ describe("ChromiumCdp", () => {
await cdp.ensureAttached(5); await cdp.ensureAttached(5);
const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" }); const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" });
await Promise.resolve(); await Promise.resolve();
onEvent.fire( onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", {
{ tabId: 5 }, type: "alert",
"Page.javascriptDialogOpening", message: "blocked",
{ type: "alert", message: "blocked", url: "https://example.com/" }, url: "https://example.com/",
); });
releaseEvaluate(); releaseEvaluate();
await expect(pending).resolves.toEqual({ result: { value: 2 } }); await expect(pending).resolves.toEqual({ result: { value: 2 } });
}); });
@@ -183,17 +197,145 @@ describe("ChromiumCdp", () => {
const { api, onEvent } = fakeApi(); const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(11); await cdp.ensureAttached(11);
onEvent.fire( onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", {
{ tabId: 11 }, type: "alert",
"Page.javascriptDialogOpening", message: "x",
{ type: "alert", message: "x", url: "https://example.com/" }, url: "https://example.com/",
); });
await Promise.resolve(); await Promise.resolve();
expect(cdp.dialogsSince(11, 0)).toHaveLength(1); expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
await cdp.detach(11); await cdp.detach(11);
expect(cdp.dialogsSince(11, 0)).toHaveLength(0); expect(cdp.dialogsSince(11, 0)).toHaveLength(0);
}); });
it("records Runtime console calls with bounded text and optional stack frames", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(21);
onEvent.fire({ tabId: 21 }, "Runtime.consoleAPICalled", {
type: "warn",
args: [{ value: "abcdefghi" }, { description: "Object { ok: true }" }],
timestamp: 1234.5,
stackTrace: {
callFrames: [
{
functionName: "render",
url: "https://example.test/app.js",
lineNumber: 4,
columnNumber: 8,
},
],
},
});
const withoutStack = cdp.consoleEntriesSince(21, 0, 50, 5, false);
expect(withoutStack).toMatchObject({
next_since: 1,
truncated: true,
entries: [
{
sequence: 1,
kind: "console",
level: "warn",
text: "abcde",
url: "https://example.test/app.js",
line: 5,
column: 9,
truncated: true,
},
],
});
expect(withoutStack.entries[0].stack_trace).toBeUndefined();
const withStack = cdp.consoleEntriesSince(21, 0, 50, 1000, true);
expect(withStack.entries[0].stack_trace).toEqual([
{
function_name: "render",
url: "https://example.test/app.js",
line: 5,
column: 9,
},
]);
});
it("records exceptions and engine log entries", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(22);
onEvent.fire({ tabId: 22 }, "Runtime.exceptionThrown", {
timestamp: 2000,
exceptionDetails: {
text: "Uncaught",
url: "https://example.test/app.js",
lineNumber: 40,
columnNumber: 6,
exception: { description: "TypeError: boom\n at render (app.js:41:7)" },
},
});
onEvent.fire({ tabId: 22 }, "Log.entryAdded", {
entry: {
level: "error",
text: "Failed to load resource: 404",
url: "https://example.test/missing.png",
lineNumber: 0,
timestamp: 2100,
},
});
const result = cdp.consoleEntriesSince(22, 0, 50, 1000, false);
expect(result.next_since).toBe(2);
expect(result.entries).toMatchObject([
{
sequence: 1,
kind: "exception",
level: "error",
text: "TypeError: boom",
url: "https://example.test/app.js",
line: 41,
column: 7,
},
{
sequence: 2,
kind: "log",
level: "error",
text: "Failed to load resource: 404",
url: "https://example.test/missing.png",
},
]);
});
it("filters console entries by cursor and caps result size", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(23);
for (let i = 1; i <= 3; i += 1) {
onEvent.fire({ tabId: 23 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
const result = cdp.consoleEntriesSince(23, 1, 1, 1000, false);
expect(result).toMatchObject({
next_since: 2,
truncated: true,
entries: [{ sequence: 2, text: "message 2" }],
});
});
it("clears console state on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(24);
onEvent.fire({ tabId: 24 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: "x" }],
});
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(1);
await cdp.detach(24);
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
});
it("detachSession only detaches tabs no other session owns", async () => { it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi(); const { api } = fakeApi();
const cdp = new ChromiumCdp(api); const cdp = new ChromiumCdp(api);
@@ -20,7 +20,14 @@
// record the payload for tool results, and auto-accept so automation // record the payload for tool results, and auto-accept so automation
// can continue. // can continue.
import type { JavaScriptDialogInfo, JavaScriptDialogType } from "@/transport/types"; import type {
ConsoleEntry,
ConsoleEntryKind,
ConsoleResult,
ConsoleStackFrame,
JavaScriptDialogInfo,
JavaScriptDialogType,
} from "@/transport/types";
/** /**
* Minimal slice of `chrome.debugger` the rest of the extension * Minimal slice of `chrome.debugger` the rest of the extension
@@ -77,6 +84,9 @@ export type DialogCursor = number;
const MAX_DIALOG_BUFFER = 32; const MAX_DIALOG_BUFFER = 32;
const MAX_DIALOG_FIELD_LENGTH = 4096; const MAX_DIALOG_FIELD_LENGTH = 4096;
const MAX_CONSOLE_BUFFER = 200;
const MAX_CONSOLE_FIELD_LENGTH = 4096;
const MAX_CONSOLE_STACK_FRAMES = 20;
interface ParsedDialogOpening { interface ParsedDialogOpening {
type: JavaScriptDialogType; type: JavaScriptDialogType;
@@ -86,6 +96,8 @@ interface ParsedDialogOpening {
hasBrowserHandler?: boolean; hasBrowserHandler?: boolean;
} }
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
/** /**
* Wrapper around `chrome.debugger` that owns the "attach once per * Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`. * tabId" cache and exposes typed `send<T>()`.
@@ -97,13 +109,18 @@ export class ChromiumCdp {
private readonly tabOwners = new Map<number, Set<string>>(); private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>(); private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>(); private readonly dialogSequences = new Map<number, number>();
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
private readonly consoleSequences = new Map<number, number>();
private readonly consoleDomainsAttemptedTabs = new Set<number>();
private detachSubscription: { dispose(): void } | null = null; private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null; private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) { constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api; this.api = api;
this.bindAutoDetach(); this.bindAutoDetach();
this.bindDialogHandler(); this.bindDialogHandler();
this.bindConsoleHandler();
} }
/** Attach to `tabId` if we haven't already in this driver. */ /** Attach to `tabId` if we haven't already in this driver. */
@@ -117,6 +134,7 @@ export class ChromiumCdp {
const attach = (async () => { const attach = (async () => {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION); await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId); await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId);
this.attachedTabs.add(tabId); this.attachedTabs.add(tabId);
})() })()
.catch((err) => { .catch((err) => {
@@ -159,12 +177,52 @@ export class ChromiumCdp {
return buf.filter((entry) => entry.sequence > cursor); return buf.filter((entry) => entry.sequence > cursor);
} }
/** Ensure CDP domains for console capture are enabled for this tab. */
async ensureConsoleCapture(tabId: number): Promise<void> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
return;
}
await this.enableConsoleDomains(tabId);
}
/** Console entries observed on `tabId`, bounded for agent context safety. */
consoleEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
includeStack: boolean,
): ConsoleResult {
const buf = this.consoleBuffers.get(tabId) ?? [];
const hasCursor = typeof since === "number";
const candidates = hasCursor
? buf.filter((entry) => entry.sequence > since)
: buf.slice(Math.max(0, buf.length - limit));
const limited = hasCursor ? candidates.slice(0, limit) : candidates;
const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack));
const lastReturned = entries.at(-1)?.sequence;
const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0;
const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
return {
tab_id: tabId,
entries,
next_since: nextSince,
truncated,
};
}
/** Detach if attached; never throws. */ /** Detach if attached; never throws. */
async detach(tabId: number): Promise<void> { async detach(tabId: number): Promise<void> {
this.attachInFlight.delete(tabId); this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return; if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId); this.attachedTabs.delete(tabId);
this.clearDialogState(tabId); this.clearDialogState(tabId);
this.clearConsoleState(tabId);
try { try {
await this.api.detach({ tabId }); await this.api.detach({ tabId });
} catch (err) { } catch (err) {
@@ -204,6 +262,9 @@ export class ChromiumCdp {
this.attachedTabs.clear(); this.attachedTabs.clear();
this.dialogBuffers.clear(); this.dialogBuffers.clear();
this.dialogSequences.clear(); this.dialogSequences.clear();
this.consoleBuffers.clear();
this.consoleSequences.clear();
this.consoleDomainsAttemptedTabs.clear();
await Promise.all( await Promise.all(
tabs.map(async (tabId) => { tabs.map(async (tabId) => {
try { try {
@@ -219,6 +280,18 @@ export class ChromiumCdp {
await this.api.sendCommand({ tabId }, "Page.enable", {}); await this.api.sendCommand({ tabId }, "Page.enable", {});
} }
private async enableConsoleDomains(tabId: number): Promise<void> {
if (this.consoleDomainsAttemptedTabs.has(tabId)) return;
this.consoleDomainsAttemptedTabs.add(tabId);
for (const method of ["Runtime.enable", "Log.enable"]) {
try {
await this.api.sendCommand({ tabId }, method, {});
} catch (err) {
console.debug("[bsk cdp] console domain enable failed", { tabId, method, err });
}
}
}
private bindDialogHandler(): void { private bindDialogHandler(): void {
if (this.dialogSubscription) return; if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => { const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
@@ -272,6 +345,47 @@ export class ChromiumCdp {
this.dialogSequences.delete(tabId); this.dialogSequences.delete(tabId);
} }
private bindConsoleHandler(): void {
if (this.consoleSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
const tabId = source.tabId;
if (typeof tabId !== "number") return;
switch (method) {
case "Runtime.consoleAPICalled":
this.appendConsole(tabId, parseConsoleApiCalled(params));
break;
case "Runtime.exceptionThrown":
this.appendConsole(tabId, parseExceptionThrown(params));
break;
case "Log.entryAdded":
this.appendConsole(tabId, parseLogEntry(params));
break;
}
};
this.api.onEvent.addListener(listener);
this.consoleSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private appendConsole(tabId: number, entry: ParsedConsoleEntry | null): void {
if (!entry) return;
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
this.consoleSequences.set(tabId, sequence);
const buf = this.consoleBuffers.get(tabId) ?? [];
buf.push({ ...entry, sequence });
while (buf.length > MAX_CONSOLE_BUFFER) {
buf.shift();
}
this.consoleBuffers.set(tabId, buf);
}
private clearConsoleState(tabId: number): void {
this.consoleBuffers.delete(tabId);
this.consoleSequences.delete(tabId);
this.consoleDomainsAttemptedTabs.delete(tabId);
}
private bindAutoDetach(): void { private bindAutoDetach(): void {
if (this.detachSubscription) return; if (this.detachSubscription) return;
const listener = (source: chrome.debugger.Debuggee, _reason: string) => { const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
@@ -280,6 +394,7 @@ export class ChromiumCdp {
this.attachInFlight.delete(source.tabId); this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId); this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId); this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId);
} }
}; };
this.api.onDetach.addListener(listener); this.api.onDetach.addListener(listener);
@@ -294,6 +409,8 @@ export class ChromiumCdp {
this.detachSubscription = null; this.detachSubscription = null;
this.dialogSubscription?.dispose(); this.dialogSubscription?.dispose();
this.dialogSubscription = null; this.dialogSubscription = null;
this.consoleSubscription?.dispose();
this.consoleSubscription = null;
} }
/** Detach tabs only when no other live session has claimed them. */ /** Detach tabs only when no other live session has claimed them. */
@@ -339,6 +456,154 @@ function truncateDialogField(value: string): string {
return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`; return `${value.slice(0, MAX_DIALOG_FIELD_LENGTH)}... [truncated]`;
} }
function parseConsoleApiCalled(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const args = Array.isArray(raw.args) ? raw.args : [];
const text = args.map(remoteObjectToText).filter(Boolean).join(" ");
const stackTrace = parseStackTrace(raw.stackTrace);
const top = stackTrace[0];
return makeConsoleEntry({
kind: "console",
level: typeof raw.type === "string" ? raw.type : "log",
text,
url: top?.url,
line: top?.line,
column: top?.column,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
stack_trace: stackTrace,
});
}
function parseExceptionThrown(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const details = (raw.exceptionDetails ?? {}) as Record<string, unknown>;
const exception = (details.exception ?? {}) as Record<string, unknown>;
const text =
firstLine(typeof exception.description === "string" ? exception.description : undefined) ||
firstLine(typeof details.text === "string" ? details.text : undefined) ||
"Uncaught (unknown error)";
return makeConsoleEntry({
kind: "exception",
level: "error",
text,
url: typeof details.url === "string" ? details.url : undefined,
line: typeof details.lineNumber === "number" ? details.lineNumber + 1 : undefined,
column: typeof details.columnNumber === "number" ? details.columnNumber + 1 : undefined,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
stack_trace: parseStackTrace(details.stackTrace),
});
}
function parseLogEntry(params: unknown): ParsedConsoleEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const entry = (raw.entry ?? {}) as Record<string, unknown>;
return makeConsoleEntry({
kind: "log",
level: typeof entry.level === "string" ? entry.level : "info",
text: typeof entry.text === "string" ? entry.text : "",
url: typeof entry.url === "string" ? entry.url : undefined,
line: typeof entry.lineNumber === "number" ? entry.lineNumber : undefined,
timestamp: typeof entry.timestamp === "number" ? entry.timestamp : undefined,
stack_trace: [],
});
}
function makeConsoleEntry(input: {
kind: ConsoleEntryKind;
level: string;
text: string;
url?: string;
line?: number;
column?: number;
timestamp?: number;
stack_trace: ConsoleStackFrame[];
}): ParsedConsoleEntry {
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
return {
kind: input.kind,
level: input.level,
text: projected.text,
url: truncateOptionalConsoleField(input.url),
line: input.line,
column: input.column,
timestamp: input.timestamp,
stack_trace: input.stack_trace.slice(0, MAX_CONSOLE_STACK_FRAMES).map((frame) => ({
function_name: truncateOptionalConsoleField(frame.function_name),
url: truncateOptionalConsoleField(frame.url),
line: frame.line,
column: frame.column,
})),
truncated: projected.truncated || input.stack_trace.length > MAX_CONSOLE_STACK_FRAMES,
};
}
function projectConsoleEntry(
entry: ConsoleEntry,
maxTextChars: number,
includeStack: boolean,
): ConsoleEntry {
const projected = truncateConsoleText(entry.text, maxTextChars);
return {
sequence: entry.sequence,
kind: entry.kind,
level: entry.level,
text: projected.text,
url: entry.url,
line: entry.line,
column: entry.column,
timestamp: entry.timestamp,
...(includeStack && entry.stack_trace && entry.stack_trace.length > 0
? { stack_trace: entry.stack_trace }
: {}),
truncated: entry.truncated || projected.truncated,
};
}
function remoteObjectToText(value: unknown): string {
const raw = (value ?? {}) as Record<string, unknown>;
if ("value" in raw && raw.value !== undefined) return String(raw.value);
if (typeof raw.description === "string") return raw.description;
if (typeof raw.unserializableValue === "string") return raw.unserializableValue;
if (typeof raw.type === "string") return `[${raw.type}]`;
return "";
}
function parseStackTrace(value: unknown): ConsoleStackFrame[] {
const raw = (value ?? {}) as Record<string, unknown>;
const frames = Array.isArray(raw.callFrames) ? raw.callFrames : [];
return frames
.filter(
(frame): frame is Record<string, unknown> => frame !== null && typeof frame === "object",
)
.map((frame) => ({
function_name:
typeof frame.functionName === "string" && frame.functionName.length > 0
? frame.functionName
: undefined,
url: typeof frame.url === "string" && frame.url.length > 0 ? frame.url : undefined,
line: typeof frame.lineNumber === "number" ? frame.lineNumber + 1 : undefined,
column: typeof frame.columnNumber === "number" ? frame.columnNumber + 1 : undefined,
}));
}
function firstLine(value: string | undefined): string | undefined {
if (!value) return undefined;
return value.split(/\r?\n/, 1)[0] || undefined;
}
function truncateConsoleText(
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
}
function normalizeError(err: unknown): Error { function normalizeError(err: unknown): Error {
if (err instanceof Error) return err; if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err); if (typeof err === "string") return new Error(err);
@@ -510,6 +510,48 @@ describe("requestBorrowConfirmation", () => {
expect(cancelMessage.type).toBe("borrow-cancel"); expect(cancelMessage.type).toBe("borrow-cancel");
expect(cancelMessage.requestId).toBe(requestMessage.requestId); expect(cancelMessage.requestId).toBe(requestMessage.requestId);
}); });
it("does not fail-open when tabs.get rejects — still surfaces confirmation to a candidate", async () => {
// Regression: a transient tabs.get failure (SW teardown / page hiccup)
// used to return true immediately, approving the borrow with no
// confirmation UI. The tab was already fetched by the borrow pipeline
// moments earlier, so the failure is transient — we must fall back to a
// generic title and still ask the user.
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: false });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
const allowed = await pending;
// The user's explicit deny must be honoured — NOT auto-allowed by the
// earlier tabs.get rejection.
expect(allowed).toBe(false);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
expect(notifications.create).toHaveBeenCalledTimes(1);
});
it("still allows an explicit overlay allow after a tabs.get rejection", async () => {
tabs.get.mockRejectedValueOnce(new Error("tab info unavailable"));
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 50, tabId: 7, url: "https://app.example/" }),
);
tabs.sendMessage.mockResolvedValueOnce({ type: "borrow-response", allowed: true });
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
const allowed = await pending;
expect(allowed).toBe(true);
expect(tabs.sendMessage).toHaveBeenCalledTimes(1);
});
}); });
describe("isInjectableContentScriptUrl", () => { describe("isInjectableContentScriptUrl", () => {
@@ -0,0 +1,124 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import type { ConsoleResult } from "@/transport/types";
import { handleConsole } from "../console";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
function makeDeps(
opts: {
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
result?: ConsoleResult;
} = {},
) {
const result =
opts.result ??
({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
} satisfies ConsoleResult);
const ensureConsoleCapture = vi.fn(async () => {});
const consoleEntriesSince = vi.fn(() => result);
const cdp = {
send: vi.fn(),
ensureConsoleCapture,
consoleEntriesSince,
} as unknown as CdpRunner;
const tabsApi = {
get:
opts.get ??
vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query:
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, ensureConsoleCapture, consoleEntriesSince };
}
describe("handleConsole", () => {
it("reads the Agent Window active tab with safe defaults", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
result: {
tab_id: 7,
entries: [{ sequence: 3, kind: "console", level: "log", text: "hello", truncated: false }],
next_since: 3,
truncated: false,
},
});
const res = await handleConsole(sm, { session_id: "aa11" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.entries[0].text).toBe("hello");
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(7);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
});
it("reads an explicit tab and forwards bounded options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
});
await handleConsole(
sm,
{
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 500,
max_text_chars: 9999,
include_stack: true,
},
deps,
);
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true);
});
it("rejects invalid bounds before touching CDP", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps();
const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
expect(deps.consoleEntriesSince).not.toHaveBeenCalled();
});
it("hides other sessions' Agent Window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
await sm.start("aa11");
await sm.start("bb22");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
});
const res = await handleConsole(sm, { session_id: "aa11", tab_id: 9 }, deps);
expect(res).toMatchObject({ code: "not_found" });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
});
});
@@ -1,7 +1,12 @@
import { describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager"; import { SessionManager } from "@/session-manager/manager";
import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport"; import type { ConnectionStateHandler, FrameHandler, Transport } from "@/transport/transport";
import type { ConnectionState, ProtocolFrame, RequestFrame } from "@/transport/types"; import type {
ConnectionState,
ConsoleResult,
ProtocolFrame,
RequestFrame,
} from "@/transport/types";
import { ToolDispatcher } from "../dispatcher"; import { ToolDispatcher } from "../dispatcher";
function fakeTransport() { function fakeTransport() {
@@ -36,6 +41,10 @@ function makeRequest(method: string, params: unknown): RequestFrame {
} }
describe("ToolDispatcher", () => { describe("ToolDispatcher", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("routes tool.session_start to the SessionManager and replies with the window id", async () => { it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
const { transport, sent, deliver } = fakeTransport(); const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
@@ -78,6 +87,55 @@ describe("ToolDispatcher", () => {
expect(sent[0]).toEqual({ id: "r-1", result: {} }); expect(sent[0]).toEqual({ id: "r-1", result: {} });
}); });
it("routes tool.console through the CDP console buffer", async () => {
vi.stubGlobal("chrome", {
tabs: {
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureConsoleCapture: vi.fn(async () => {}),
consoleEntriesSince: vi.fn(
() =>
({
tab_id: 7,
entries: [
{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false },
],
next_since: 1,
truncated: false,
}) satisfies ConsoleResult,
),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest("tool.console", { session_id: "aa11" }));
await flushMicrotasks();
expect(cdp.ensureConsoleCapture).toHaveBeenCalledWith(7);
expect(sent[0]).toEqual({
id: "r-1",
result: {
tab_id: 7,
entries: [{ sequence: 1, kind: "console", level: "log", text: "hello", truncated: false }],
next_since: 1,
truncated: false,
},
});
});
it("detaches CDP state before stopping a session", async () => { it("detaches CDP state before stopping a session", async () => {
const { transport, sent, deliver } = fakeTransport(); const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({ const sessions = new SessionManager({
@@ -613,6 +613,65 @@ describe("handleTabReturn", () => {
expect(winSpies.create).toHaveBeenCalledOnce(); expect(winSpies.create).toHaveBeenCalledOnce();
}); });
it("does not fall back into another session's Agent Window (creates a new window instead)", async () => {
// Regression: a returned borrowed tab whose original window is gone
// must never be parked in *another* session's Agent Window. Agent
// Windows are `type: "normal"`, so getLastFocused({windowTypes:
// ["normal"]}) can return session bb22's window (200). Without the
// isAgentWindowId guard the tab would be moved into window 200,
// letting bb22 write to it and destroying it when bb22 stops.
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 200]) });
const ctx = await sm.start("aa11");
await sm.start("bb22"); // owns Agent Window 200
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([300]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 200, // session bb22's Agent Window
createWindowId: 777,
});
const res = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{ tabs: api, windows: windowsApi },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.returned_to_window_id).toBe(777);
expect(res.fallback).toBe(true);
expect(winSpies.create).toHaveBeenCalledOnce();
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 200 }));
});
it("returnBorrowedTab honours an explicit isAgentWindowId predicate", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 300, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set([300]),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi, spies: winSpies } = makeWindowsApi(state, {
lastFocused: 555,
createWindowId: 777,
});
const res = await returnBorrowedTab(ctx, 7, {
tabs: api,
windows: windowsApi,
isAgentWindowId: (windowId) => windowId === 555,
});
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.toWindowId).toBe(777);
expect(res.fallback).toBe(true);
expect(winSpies.create).toHaveBeenCalledOnce();
expect(spies.move).not.toHaveBeenCalledWith(7, expect.objectContaining({ windowId: 555 }));
});
it("returns not_found when the tab is not borrowed by this session", async () => { it("returns not_found when the tab is not borrowed by this session", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) }); const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11"); await sm.start("aa11");
@@ -383,17 +383,25 @@ export async function requestBorrowConfirmation(
const isAgentWindowId = deps.isAgentWindowId ?? (() => false); const isAgentWindowId = deps.isAgentWindowId ?? (() => false);
const copy = deps.notificationCopy ?? DEFAULT_NOTIFICATION_COPY; const copy = deps.notificationCopy ?? DEFAULT_NOTIFICATION_COPY;
let tab: chrome.tabs.Tab; // The borrow pipeline already fetched this tab moments earlier
// (validateBorrowTarget in tabs.ts), so a failure here is a transient
// SW/page hiccup, not a missing tab. Do NOT fail-open: silently allowing
// a borrow with no confirmation UI contradicts the gate's purpose and is
// not one of the documented fail-open cases (no candidate window; abort;
// timeout — see this function's doc comment). Fall back to a generic
// title and still surface the overlay + notification so the user gets a
// real chance to approve or deny.
let tabTitle: string;
try { try {
tab = await tabsApi.get(tabId); const tab = await tabsApi.get(tabId);
tabTitle = tab.title ?? tab.url ?? String(tabId);
} catch (err) { } catch (err) {
console.warn("[bsk borrow] could not get tab info — proceeding", { console.warn("[bsk borrow] could not get tab info — using fallback title", {
tabId, tabId,
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
return true; tabTitle = String(tabId);
} }
const tabTitle = tab.title ?? tab.url ?? String(tabId);
const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId); const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId);
if (candidates.length === 0) { if (candidates.length === 0) {
+108
View File
@@ -0,0 +1,108 @@
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { ConsoleParams, ConsoleResult, RpcError } from "@/transport/types";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
const DEFAULT_CONSOLE_LIMIT = 50;
const MAX_CONSOLE_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
export interface ConsoleDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
}
function defaultConsoleDeps(): ConsoleDeps {
return {
cdp: new ChromiumCdp(),
tabsApi: chromeTabsApi,
};
}
export async function handleConsole(
manager: SessionManager,
params: ConsoleParams,
deps: ConsoleDeps = defaultConsoleDeps(),
): Promise<ConsoleResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseConsoleBounds(params);
if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
if (!deps.cdp.ensureConsoleCapture || !deps.cdp.consoleEntriesSince) {
return { code: "cdp_failed", message: "console capture requires CDP console support" };
}
try {
await deps.cdp.ensureConsoleCapture(target.tabId);
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
return deps.cdp.consoleEntriesSince(
target.tabId,
bounds.since,
bounds.limit,
bounds.maxTextChars,
bounds.includeStack,
);
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
function parseConsoleBounds(params: ConsoleParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
includeStack: boolean;
}
| RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_CONSOLE_LIMIT,
MAX_CONSOLE_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return {
since,
limit,
maxTextChars,
includeStack: params.include_stack === true,
};
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
+13 -5
View File
@@ -1,7 +1,9 @@
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
import type { SessionManager } from "@/session-manager/manager"; import type { SessionManager } from "@/session-manager/manager";
import type { Transport } from "@/transport/transport"; import type { Transport } from "@/transport/transport";
import type { import type {
ClickParams, ClickParams,
ConsoleParams,
EvaluateParams, EvaluateParams,
FillParams, FillParams,
GetHtmlParams, GetHtmlParams,
@@ -11,17 +13,19 @@ import type {
PressParams, PressParams,
ProtocolFrame, ProtocolFrame,
ReloadParams, ReloadParams,
RequestHelpParams,
RequestFrame, RequestFrame,
RequestHelpParams,
ResponseFrame, ResponseFrame,
RpcError, RpcError,
SelectParams,
ScreenshotParams, ScreenshotParams,
SelectParams,
SnapshotParams, SnapshotParams,
WaitForNavigationParams, WaitForNavigationParams,
} from "@/transport/types"; } from "@/transport/types";
import { isRequestFrame } from "@/transport/types"; import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleEvaluate } from "./evaluate"; import { handleEvaluate } from "./evaluate";
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction"; import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
import { import {
handleNavigate, handleNavigate,
@@ -42,16 +46,15 @@ import {
type SessionStartParams, type SessionStartParams,
type SessionStopParams, type SessionStopParams,
} from "./session"; } from "./session";
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
import { chromeTabsApi } from "./shared"; import { chromeTabsApi } from "./shared";
import { import {
type BorrowConfirmationApprover,
handleTabBorrow, handleTabBorrow,
handleTabClose, handleTabClose,
handleTabCreate, handleTabCreate,
handleTabList, handleTabList,
handleTabReturn, handleTabReturn,
handleTabSelect, handleTabSelect,
type BorrowConfirmationApprover,
type TabBorrowParams, type TabBorrowParams,
type TabCloseParams, type TabCloseParams,
type TabCreateParams, type TabCreateParams,
@@ -59,7 +62,6 @@ import {
type TabReturnParams, type TabReturnParams,
type TabSelectParams, type TabSelectParams,
} from "./tabs"; } from "./tabs";
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
import { handleWaitForNavigation } from "./waits"; import { handleWaitForNavigation } from "./waits";
export interface DispatcherDeps { export interface DispatcherDeps {
@@ -277,6 +279,12 @@ export class ToolDispatcher {
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi } ? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
: undefined, : undefined,
); );
case "tool.console":
return handleConsole(
this.sessions,
req.params as ConsoleParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.snapshot": case "tool.snapshot":
return handleSnapshot( return handleSnapshot(
this.sessions, this.sessions,
+7 -1
View File
@@ -113,7 +113,13 @@ export async function handleSessionStop(
const borrowedIds = Array.from(ctx.borrowedTabs.keys()); const borrowedIds = Array.from(ctx.borrowedTabs.keys());
for (const tabId of borrowedIds) { for (const tabId of borrowedIds) {
try { try {
const outcome = await returnBorrowedTab(ctx, tabId, deps.tabManagement ?? {}); const tabManagement = {
...(deps.tabManagement ?? {}),
isAgentWindowId:
deps.tabManagement?.isAgentWindowId ??
((windowId: number) => manager.findByWindowId(windowId) !== null),
};
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
if (typeof outcome === "object" && "code" in outcome) { if (typeof outcome === "object" && "code" in outcome) {
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome); console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
returnFailures?.push({ returnFailures?.push({
+11 -4
View File
@@ -6,11 +6,10 @@
// exactly the same sandbox + visibility rules as the M6 observation // exactly the same sandbox + visibility rules as the M6 observation
// handlers (review parity). // handlers (review parity).
import { normaliseRef } from "@/session-manager/ref-store";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import type { JavaScriptDialogInfo } from "@/transport/types";
import type { DialogCursor } from "@/browser-driver/chromium-cdp"; import type { DialogCursor } from "@/browser-driver/chromium-cdp";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import { normaliseRef } from "@/session-manager/ref-store";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
import { rpcError } from "./errors"; import { rpcError } from "./errors";
/** /**
@@ -46,6 +45,14 @@ export interface CdpRunner {
}; };
dialogCursor?(tabId: number): DialogCursor; dialogCursor?(tabId: number): DialogCursor;
dialogsSince?(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[]; dialogsSince?(tabId: number, cursor: DialogCursor): JavaScriptDialogInfo[];
ensureConsoleCapture?(tabId: number): Promise<void>;
consoleEntriesSince?(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
includeStack: boolean,
): ConsoleResult;
} }
/** /**
+31 -4
View File
@@ -282,6 +282,16 @@ export interface TabManagementDeps {
approveBorrow?: BorrowConfirmationApprover; approveBorrow?: BorrowConfirmationApprover;
/** Clears Agent-scoped overlays after a borrowed tab is returned. */ /** Clears Agent-scoped overlays after a borrowed tab is returned. */
agentOverlayReset?: AgentOverlayResetApi; agentOverlayReset?: AgentOverlayResetApi;
/**
* Reports whether `windowId` is any live session's Agent Window.
* `tab_return`'s fallback window picker uses this to avoid moving a
* returned borrowed tab into *another* session's Agent Window (Agent
* Windows are created with `type: "normal"`, so they otherwise match
* the `getLastFocused({ windowTypes: ["normal"] })` query). Production
* callers derive this from the `SessionManager`; the `() => false`
* default only preserves legacy behaviour for direct/test callers.
*/
isAgentWindowId?: (windowId: number) => boolean;
} }
function getTabsApi(deps: TabManagementDeps): TabMutationApi { function getTabsApi(deps: TabManagementDeps): TabMutationApi {
@@ -292,6 +302,10 @@ function getWindowsApi(deps: TabManagementDeps): ChromeWindowsApi {
return deps.windows ?? chromeWindowsApi; return deps.windows ?? chromeWindowsApi;
} }
function getIsAgentWindowId(deps: TabManagementDeps): (windowId: number) => boolean {
return deps.isAgentWindowId ?? (() => false);
}
function getAgentOverlayResetApi(deps: TabManagementDeps): AgentOverlayResetApi { function getAgentOverlayResetApi(deps: TabManagementDeps): AgentOverlayResetApi {
return deps.agentOverlayReset ?? chromeAgentOverlayResetApi; return deps.agentOverlayReset ?? chromeAgentOverlayResetApi;
} }
@@ -818,12 +832,20 @@ function describeError(err: unknown): string {
async function chooseFallbackWindow( async function chooseFallbackWindow(
ctx: SessionContext, ctx: SessionContext,
windowsApi: ChromeWindowsApi, windowsApi: ChromeWindowsApi,
isAgentWindowId: (windowId: number) => boolean,
): Promise<{ windowId: number; index: number } | RpcError> { ): Promise<{ windowId: number; index: number } | RpcError> {
let lastFocusedError: unknown; let lastFocusedError: unknown;
try { try {
const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] }); const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] });
const lastId = typeof last?.id === "number" ? last.id : null; const lastId = typeof last?.id === "number" ? last.id : null;
if (lastId !== null && lastId !== ctx.agentWindowId) { // Never relocate a user tab into *any* session's Agent Window: this
// session's is excluded explicitly, and other sessions' are excluded
// via `isAgentWindowId` (Agent Windows are `type: "normal"`, so they
// otherwise survive the `windowTypes: ["normal"]` filter). Parking a
// returned tab in another session's Agent Window would let that
// session write to it and, worse, see it destroyed when that session
// stops and closes its window.
if (lastId !== null && lastId !== ctx.agentWindowId && !isAgentWindowId(lastId)) {
return { windowId: lastId, index: -1 }; return { windowId: lastId, index: -1 };
} }
} catch (err) { } catch (err) {
@@ -888,6 +910,7 @@ export async function returnBorrowedTab(
} }
const tabsApi = getTabsApi(deps); const tabsApi = getTabsApi(deps);
const windowsApi = getWindowsApi(deps); const windowsApi = getWindowsApi(deps);
const isAgentWindowId = getIsAgentWindowId(deps);
let targetWindowId = entry.originalWindowId; let targetWindowId = entry.originalWindowId;
let targetIndex = entry.originalIndex; let targetIndex = entry.originalIndex;
@@ -903,7 +926,7 @@ export async function returnBorrowedTab(
} }
if (!originalAlive) { if (!originalAlive) {
fallback = true; fallback = true;
const target = await chooseFallbackWindow(ctx, windowsApi); const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
if ("code" in target) return target; if ("code" in target) return target;
targetWindowId = target.windowId; targetWindowId = target.windowId;
targetIndex = target.index; targetIndex = target.index;
@@ -925,7 +948,7 @@ export async function returnBorrowedTab(
}; };
} catch (err) { } catch (err) {
if (!fallback) { if (!fallback) {
const target = await chooseFallbackWindow(ctx, windowsApi); const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
if ("code" in target) { if ("code" in target) {
return { return {
code: "cdp_failed", code: "cdp_failed",
@@ -979,7 +1002,11 @@ export async function handleTabReturn(
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`, message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
}; };
} }
const outcome = await returnBorrowedTab(ctx, params.tab_id, deps); const outcome = await returnBorrowedTab(ctx, params.tab_id, {
...deps,
isAgentWindowId:
deps.isAgentWindowId ?? ((windowId) => manager.findByWindowId(windowId) !== null),
});
if (isRpcError(outcome)) return outcome; if (isRpcError(outcome)) return outcome;
ctx.borrowedTabs.delete(params.tab_id); ctx.borrowedTabs.delete(params.tab_id);
const result: TabReturnResult = { const result: TabReturnResult = {
+38
View File
@@ -134,6 +134,44 @@ export interface JavaScriptDialogInfo {
sequence: number; sequence: number;
} }
export type ConsoleEntryKind = "console" | "exception" | "log";
export interface ConsoleStackFrame {
function_name?: string;
url?: string;
line?: number;
column?: number;
}
export interface ConsoleEntry {
sequence: number;
kind: ConsoleEntryKind;
level: string;
text: string;
url?: string;
line?: number;
column?: number;
timestamp?: number;
stack_trace?: ConsoleStackFrame[];
truncated: boolean;
}
export interface ConsoleParams {
session_id: string;
tab_id?: number;
since?: number;
limit?: number;
max_text_chars?: number;
include_stack?: boolean;
}
export interface ConsoleResult {
tab_id: number;
entries: ConsoleEntry[];
next_since: number;
truncated: boolean;
}
export type TabScopeFilter = "user" | "agent" | "all"; export type TabScopeFilter = "user" | "agent" | "all";
export interface TabInfo { export interface TabInfo {
+56
View File
@@ -0,0 +1,56 @@
//! Shared browser-connect wait durations for status/doctor/browsers commands.
use std::time::Duration;
use crate::daemon::browsers::EXTENSION_CONNECT_WAIT;
/// Default wait passed to daemon status/list RPCs when the registry is empty.
pub const DEFAULT_BROWSER_CONNECT_WAIT: Duration = EXTENSION_CONNECT_WAIT;
/// Keep CLI-side waits aligned with the daemon's IPC clamp so a bad env value
/// cannot inflate read timeouts after the daemon has already returned.
const MAX_BROWSER_CONNECT_WAIT: Duration = Duration::from_secs(60);
/// Browser wait for `bsk status` / `bsk browsers`. Override in tests via
/// `BSK_BROWSER_WAIT_MS`.
pub fn browser_connect_wait() -> Duration {
wait_from_env("BSK_BROWSER_WAIT_MS").unwrap_or(DEFAULT_BROWSER_CONNECT_WAIT)
}
/// Browser wait for `bsk doctor`. Falls back to [`browser_connect_wait`] when
/// `BSK_DOCTOR_BROWSER_WAIT_MS` is unset so tests can shorten only doctor.
pub fn doctor_browser_connect_wait() -> Duration {
wait_from_env("BSK_DOCTOR_BROWSER_WAIT_MS").unwrap_or_else(browser_connect_wait)
}
/// Encodes a wait duration for protocol params. A zero duration preserves the
/// legacy immediate-snapshot semantics.
pub fn wait_for_browser_ms(wait: Duration) -> Option<u64> {
let wait = clamp_browser_wait(wait);
if wait.is_zero() {
None
} else {
Some(wait.as_millis() as u64)
}
}
/// IPC call budget: the command's normal timeout, or browser wait plus
/// scheduling slack when a wait is requested.
pub fn browser_query_ipc_timeout(wait: Duration, default_timeout: Duration) -> Duration {
let wait = clamp_browser_wait(wait);
if wait.is_zero() {
default_timeout
} else {
wait.saturating_add(Duration::from_secs(2))
}
}
fn wait_from_env(key: &str) -> Option<Duration> {
std::env::var(key)
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(|ms| clamp_browser_wait(Duration::from_millis(ms)))
}
fn clamp_browser_wait(wait: Duration) -> Duration {
wait.min(MAX_BROWSER_CONNECT_WAIT)
}
+16 -3
View File
@@ -5,9 +5,13 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use bsk_protocol::Method; use bsk_protocol::Method;
use bsk_protocol::system::BrowserListParams;
use bsk_protocol::system::BrowserStatusEntry; use bsk_protocol::system::BrowserStatusEntry;
use serde::Deserialize; use serde::Deserialize;
use crate::cli::browser_wait::{
browser_connect_wait, browser_query_ipc_timeout, wait_for_browser_ms,
};
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format}; use crate::cli::error::{CliError, Format};
@@ -22,7 +26,12 @@ pub fn dispatch(format: Format) -> Result<(), CliError> {
} }
fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> { fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> {
let reply: ListReply = call(sock, Duration::from_secs(5))?; let wait = browser_connect_wait();
let params = BrowserListParams {
wait_for_browser_ms: wait_for_browser_ms(wait),
};
let timeout = browser_query_ipc_timeout(wait, Duration::from_secs(5));
let reply: ListReply = call(sock, params, timeout)?;
match format { match format {
Format::Json => { Format::Json => {
println!( println!(
@@ -92,7 +101,11 @@ fn run_list(sock: PathBuf, format: Format) -> Result<(), CliError> {
Ok(()) Ok(())
} }
fn call(sock: PathBuf, timeout: Duration) -> Result<ListReply, CliError> { fn call(
sock: PathBuf,
params: BrowserListParams,
timeout: Duration,
) -> Result<ListReply, CliError> {
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -101,7 +114,7 @@ fn call(sock: PathBuf, timeout: Duration) -> Result<ListReply, CliError> {
rt.block_on(async move { rt.block_on(async move {
let mut client = crate::ipc_client::IpcClient::connect(sock).await?; let mut client = crate::ipc_client::IpcClient::connect(sock).await?;
let outcome = client let outcome = client
.call::<(), ListReply>("browser-list-1", Method::BrowserList, None, timeout) .call("browser-list-1", Method::BrowserList, Some(params), timeout)
.await?; .await?;
outcome.map_err(CliError::from_rpc) outcome.map_err(CliError::from_rpc)
}) })
+131
View File
@@ -0,0 +1,131 @@
//! `bsk console` — read buffered page console/log/exception messages.
use std::path::PathBuf;
use anyhow::Context;
use bsk_protocol::Method;
use bsk_protocol::tools::{ConsoleEntry, ConsoleParams, ConsoleResult};
use clap::Args;
use crate::cli::TOOL_IPC_TIMEOUT;
use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format};
#[derive(Debug, Clone, Args)]
pub struct ConsoleArgs {
/// Session id (must be active).
#[arg(long)]
pub session: String,
/// Target tab. Defaults to the Agent Window's active tab.
#[arg(long = "tab-id")]
pub tab_id: Option<i64>,
/// Return entries with sequence greater than this cursor.
#[arg(long)]
pub since: Option<u64>,
/// Maximum number of entries to return. Defaults to 50; extension caps at 200.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
pub limit: Option<u32>,
/// Maximum characters per entry text. Defaults to 1000; extension caps at 4096.
#[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))]
pub max_text_chars: Option<u32>,
/// Include structured stack frames in JSON/human output.
#[arg(long = "include-stack", default_value_t = false)]
pub include_stack: bool,
}
pub fn dispatch(args: ConsoleArgs, format: Format) -> Result<(), CliError> {
let info = ensure_daemon().context("ensure daemon is running")?;
run(info.sock_path, args, format)
}
fn run(sock: PathBuf, args: ConsoleArgs, format: Format) -> Result<(), CliError> {
let params = ConsoleParams {
session_id: args.session,
tab_id: args.tab_id,
since: args.since,
limit: args.limit,
max_text_chars: args.max_text_chars,
include_stack: args.include_stack.then_some(true),
};
let reply: ConsoleResult = call(sock, params)?;
render(&reply, format)
}
fn call(sock: PathBuf, params: ConsoleParams) -> Result<ConsoleResult, CliError> {
crate::cli::business_rpc::call::<ConsoleParams, ConsoleResult>(
sock,
"console",
Method::ToolConsole,
Some(params),
TOOL_IPC_TIMEOUT,
)
}
fn render(reply: &ConsoleResult, format: Format) -> Result<(), CliError> {
match format {
Format::Json => {
let json = serde_json::to_string_pretty(reply)
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
println!("{json}");
}
Format::Human => {
if reply.entries.is_empty() {
println!("(no console messages captured)");
} else {
for entry in &reply.entries {
println!("{}", render_entry(entry));
for frame in &entry.stack_trace {
let loc = render_location(frame.url.as_deref(), frame.line, frame.column);
let name = frame.function_name.as_deref().unwrap_or("<anonymous>");
println!(" at {name} {loc}");
}
}
}
if reply.truncated {
eprintln!(
"warning: console output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.",
reply.next_since
);
}
}
}
Ok(())
}
fn render_entry(entry: &ConsoleEntry) -> String {
let loc = render_location(entry.url.as_deref(), entry.line, entry.column);
if loc.is_empty() {
format!(
"#{} {} {} {}",
entry.sequence,
entry.level,
entry.kind.as_str(),
entry.text
)
} else {
format!(
"#{} {} {} {} {}",
entry.sequence,
entry.level,
entry.kind.as_str(),
loc,
entry.text
)
}
}
fn render_location(url: Option<&str>, line: Option<i64>, column: Option<i64>) -> String {
let Some(url) = url else {
return String::new();
};
match (line, column) {
(Some(line), Some(column)) => format!("{url}:{line}:{column}"),
(Some(line), None) => format!("{url}:{line}"),
_ => url.to_string(),
}
}
+35 -67
View File
@@ -1,23 +1,19 @@
//! `bsk doctor` — guided diagnostics + repair hints. //! `bsk doctor` — guided diagnostics + repair hints.
use std::time::{Duration, Instant}; use std::time::Duration;
use anyhow::Result; use anyhow::Result;
use bsk_protocol::StatusResult; use bsk_protocol::StatusResult;
use console::style; use console::style;
use serde::Serialize; use serde::Serialize;
use crate::cli::browser_wait::doctor_browser_connect_wait;
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::status::{self, Output}; use crate::cli::status::{self, Output};
use crate::daemon::browsers::EXTENSION_CONNECT_WAIT;
use crate::daemon::info::{self, DaemonInfo}; use crate::daemon::info::{self, DaemonInfo};
use crate::daemon::paths; use crate::daemon::paths;
use crate::daemon::state::PROTOCOL_VERSION; use crate::daemon::state::PROTOCOL_VERSION;
/// How often `warm_up` polls `system.status` while waiting for the
/// browser extension to complete its WS handshake.
const BROWSER_CONNECT_POLL: Duration = Duration::from_millis(200);
/// Chrome Web Store listing for the browser-skill extension. /// Chrome Web Store listing for the browser-skill extension.
const EXTENSION_STORE_URL: &str = const EXTENSION_STORE_URL: &str =
"https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi"; "https://chromewebstore.google.com/detail/hhcmgoofomhgciiibhipgmgkgnoenaoi";
@@ -99,8 +95,8 @@ impl CheckResult {
} }
pub fn run(output: Output) -> Result<Vec<CheckResult>> { pub fn run(output: Output) -> Result<Vec<CheckResult>> {
warm_up(output); let state = resolve_daemon_state(output);
let checks = collect_checks(); let checks = collect_checks(state);
match output { match output {
Output::Human => render_human(&checks), Output::Human => render_human(&checks),
Output::Json => render_json(&checks)?, Output::Json => render_json(&checks)?,
@@ -108,74 +104,47 @@ pub fn run(output: Output) -> Result<Vec<CheckResult>> {
Ok(checks) Ok(checks)
} }
/// Ensure the daemon is up and give the browser extension time to connect. /// Ensure the daemon is reachable and give the browser extension time to
/// /// connect before checks run. Returns a single [`DaemonState`] snapshot
/// All errors are silently swallowed — failures are reported by the /// for check evaluation.
/// individual checks in `collect_checks()` with actionable hints. fn resolve_daemon_state(output: Output) -> DaemonState {
/// let mut state = current_state(Duration::ZERO);
/// Flow:
/// 1. Read the current daemon state. **Only** proceed for `Missing` and if matches!(state, DaemonState::Missing | DaemonState::StaleDead(_)) && ensure_daemon().is_err()
/// `StaleDead`: broken states (`ReadError`, `IpcUnreachable`, {
/// `PidMismatch`) are real diagnostic findings that `collect_checks()` return current_state(Duration::ZERO);
/// should surface — fixing them here would mask the real problem.
/// 2. Call `ensure_daemon()` to auto-start the daemon (spawns a detached
/// child and waits up to 3 s for it to write a valid `daemon.json`).
/// 3. Poll `system.status` for up to [`EXTENSION_CONNECT_WAIT`] so the MV3
/// service worker has time to wake up, discover the WS port, and finish
/// its handshake before the checks are evaluated.
/// The wait duration can be shortened for tests by setting the env var
/// `BSK_DOCTOR_BROWSER_WAIT_MS` to a millisecond count.
fn warm_up(output: Output) {
// Only spawn for states where the daemon is genuinely absent or dead.
match current_state() {
DaemonState::Missing | DaemonState::StaleDead(_) => {}
_ => return,
} }
let info = match ensure_daemon() { if matches!(state, DaemonState::Missing | DaemonState::StaleDead(_)) {
Ok(info) => info, state = current_state(Duration::ZERO);
Err(_) => return, }
let wait = if needs_browser_wait(&state) {
doctor_browser_connect_wait()
} else {
Duration::ZERO
}; };
// Daemon was freshly spawned. Give the MV3 service worker time to if wait > Duration::ZERO && output == Output::Human {
// wake up, discover the WS port, and complete its handshake.
if output == Output::Human {
eprintln!("waiting for browser extension to connect…"); eprintln!("waiting for browser extension to connect…");
} }
let wait = browser_connect_wait(); if wait.is_zero() {
let deadline = Instant::now() + wait; state
loop { } else {
// Check deadline before sleeping so a zero-duration wait exits current_state(wait)
// immediately and we never start a query_sock call past the deadline.
if Instant::now() >= deadline {
break;
}
std::thread::sleep(BROWSER_CONNECT_POLL);
if Instant::now() >= deadline {
break;
}
if let Ok(s) = status::query_sock(info.sock_path.clone()) {
if !s.browsers.is_empty() {
break;
}
}
} }
} }
/// The browser connect wait duration for `warm_up`. Defaults to fn needs_browser_wait(state: &DaemonState) -> bool {
/// [`EXTENSION_CONNECT_WAIT`]; can be overridden in tests via the match state {
/// `BSK_DOCTOR_BROWSER_WAIT_MS` environment variable. DaemonState::Verified { status } => status.browsers.is_empty(),
fn browser_connect_wait() -> Duration { _ => false,
std::env::var("BSK_DOCTOR_BROWSER_WAIT_MS") }
.ok()
.and_then(|v| v.parse::<u64>().ok())
.map(Duration::from_millis)
.unwrap_or(EXTENSION_CONNECT_WAIT)
} }
/// What the disk + IPC says about a possibly-running daemon. Threaded /// What the disk + IPC says about a possibly-running daemon. Threaded
/// through every check so they share one IPC round-trip. /// through every check so they share one snapshot.
enum DaemonState { enum DaemonState {
/// No `daemon.json` at all. /// No `daemon.json` at all.
Missing, Missing,
@@ -208,8 +177,7 @@ impl DaemonState {
} }
} }
fn collect_checks() -> Vec<CheckResult> { fn collect_checks(state: DaemonState) -> Vec<CheckResult> {
let state = current_state();
vec![ vec![
check_home_writable(), check_home_writable(),
check_skill_up_to_date(), check_skill_up_to_date(),
@@ -220,7 +188,7 @@ fn collect_checks() -> Vec<CheckResult> {
] ]
} }
fn current_state() -> DaemonState { fn current_state(browser_wait: Duration) -> DaemonState {
let info = match info::read() { let info = match info::read() {
Ok(Some(info)) => info, Ok(Some(info)) => info,
Ok(None) => return DaemonState::Missing, Ok(None) => return DaemonState::Missing,
@@ -229,7 +197,7 @@ fn current_state() -> DaemonState {
if !crate::daemon::lockfile::pid_alive(info.pid) { if !crate::daemon::lockfile::pid_alive(info.pid) {
return DaemonState::StaleDead(info); return DaemonState::StaleDead(info);
} }
match status::query_sock(info.sock_path.clone()) { match status::query_sock_with_wait(info.sock_path.clone(), browser_wait) {
Ok(status) => { Ok(status) => {
if status.pid == info.pid { if status.pid == info.pid {
DaemonState::Verified { status } DaemonState::Verified { status }
+6
View File
@@ -2,8 +2,10 @@
use std::time::Duration; use std::time::Duration;
pub mod browser_wait;
pub mod browsers; pub mod browsers;
pub mod business_rpc; pub mod business_rpc;
pub mod console;
pub mod daemon; pub mod daemon;
pub mod dialogs; pub mod dialogs;
pub mod doctor; pub mod doctor;
@@ -26,6 +28,7 @@ pub mod waits;
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use crate::cli::console::ConsoleArgs;
use crate::cli::daemon::DaemonCmd; use crate::cli::daemon::DaemonCmd;
use crate::cli::evaluate::EvaluateArgs; use crate::cli::evaluate::EvaluateArgs;
use crate::cli::get_html::GetHtmlArgs; use crate::cli::get_html::GetHtmlArgs;
@@ -109,6 +112,9 @@ pub enum Command {
/// Produce an indented aria-snapshot with @eN refs. /// Produce an indented aria-snapshot with @eN refs.
Snapshot(SnapshotArgs), Snapshot(SnapshotArgs),
/// Read buffered console/log/exception messages.
Console(ConsoleArgs),
/// Dump raw HTML for a tab or a snapshot ref. /// Dump raw HTML for a tab or a snapshot ref.
#[command(name = "get-html")] #[command(name = "get-html")]
GetHtml(GetHtmlArgs), GetHtml(GetHtmlArgs),
+14 -7
View File
@@ -5,6 +5,9 @@ use std::time::Duration;
use anyhow::Context; use anyhow::Context;
use bsk_protocol::{Method, StatusParams, StatusResult}; use bsk_protocol::{Method, StatusParams, StatusResult};
use crate::cli::browser_wait::{
browser_connect_wait, browser_query_ipc_timeout, wait_for_browser_ms,
};
use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::CliError; use crate::cli::error::CliError;
@@ -19,7 +22,8 @@ pub enum Output {
/// reuse it. /// reuse it.
pub fn run(output: Output) -> Result<StatusResult, CliError> { pub fn run(output: Output) -> Result<StatusResult, CliError> {
let info = ensure_daemon().context("ensure daemon is running")?; let info = ensure_daemon().context("ensure daemon is running")?;
let result = query_sock(info.sock_path)?; let wait = browser_connect_wait();
let result = query_sock_with_wait(info.sock_path, wait)?;
match output { match output {
Output::Human => render_human(&result), Output::Human => render_human(&result),
Output::Json => render_json(&result).map_err(CliError::Local)?, Output::Json => render_json(&result).map_err(CliError::Local)?,
@@ -27,7 +31,14 @@ pub fn run(output: Output) -> Result<StatusResult, CliError> {
Ok(result) Ok(result)
} }
pub(crate) fn query_sock(sock: std::path::PathBuf) -> Result<StatusResult, CliError> { pub(crate) fn query_sock_with_wait(
sock: std::path::PathBuf,
wait: Duration,
) -> Result<StatusResult, CliError> {
let params = StatusParams {
wait_for_browser_ms: wait_for_browser_ms(wait),
};
let timeout = browser_query_ipc_timeout(wait, Duration::from_secs(2));
let rt = tokio::runtime::Builder::new_current_thread() let rt = tokio::runtime::Builder::new_current_thread()
.enable_all() .enable_all()
.build() .build()
@@ -36,11 +47,7 @@ pub(crate) fn query_sock(sock: std::path::PathBuf) -> Result<StatusResult, CliEr
rt.block_on(async move { rt.block_on(async move {
let mut client = crate::ipc_client::Client::connect_path(sock).await?; let mut client = crate::ipc_client::Client::connect_path(sock).await?;
let outcome = client let outcome = client
.call::<_, StatusResult>( .call::<_, StatusResult>(Method::SystemStatus, &params, timeout)
Method::SystemStatus,
&StatusParams::default(),
Duration::from_secs(2),
)
.await?; .await?;
outcome.map_err(CliError::from_rpc) outcome.map_err(CliError::from_rpc)
}) })
+37 -11
View File
@@ -214,6 +214,24 @@ impl BrowserRegistry {
self.len() == 0 self.len() == 0
} }
/// Poll until at least one browser is registered or `connect_wait` elapses.
/// Returns immediately when the registry is already non-empty.
pub async fn wait_for_any_connected(&self, connect_wait: Duration) {
if connect_wait.is_zero() || !self.is_empty() {
return;
}
let deadline = Instant::now() + connect_wait;
loop {
if !self.is_empty() {
return;
}
if Instant::now() >= deadline {
return;
}
tokio::time::sleep(EXTENSION_CONNECT_POLL).await;
}
}
/// Pick the only connected browser when exactly one is online, or /// Pick the only connected browser when exactly one is online, or
/// match a specific id / label when supplied. Returns an error code /// match a specific id / label when supplied. Returns an error code
/// aligned with §4.5. /// aligned with §4.5.
@@ -511,21 +529,29 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn select_with_connect_wait_does_not_wait_on_ambiguous_label() { async fn wait_for_any_connected_returns_when_browser_registers_late() {
let reg = std::sync::Arc::new(BrowserRegistry::new());
let reg_bg = reg.clone();
let waiter = tokio::spawn(async move {
reg_bg
.wait_for_any_connected(Duration::from_millis(500))
.await;
});
tokio::time::sleep(Duration::from_millis(80)).await;
reg.insert(fake_client("late", ""));
waiter.await.expect("join");
assert_eq!(reg.len(), 1);
}
#[tokio::test]
async fn wait_for_any_connected_skips_when_already_populated() {
let reg = BrowserRegistry::new(); let reg = BrowserRegistry::new();
reg.insert(fake_client("alpha", "Shared")); reg.insert(fake_client("a", ""));
reg.insert(fake_client("beta", "Shared"));
let started = Instant::now(); let started = Instant::now();
match reg reg.wait_for_any_connected(Duration::from_secs(1)).await;
.select_with_connect_wait(Some("Shared"), Duration::from_secs(1))
.await
{
Err(SelectError::AmbiguousLabel { label, .. }) => assert_eq!(label, "Shared"),
other => panic!("expected AmbiguousLabel, got {other:?}"),
}
assert!( assert!(
started.elapsed() < Duration::from_millis(50), started.elapsed() < Duration::from_millis(50),
"ambiguous label should fail immediately" "should return immediately when browsers already connected"
); );
} }
} }
+44 -8
View File
@@ -23,12 +23,14 @@ use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
use bsk_protocol::system::{ use bsk_protocol::system::{
BrowserStatusEntry, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry, BrowserListParams, BrowserStatusEntry, SessionStatusEntry, StatusParams, StatusResult,
VersionSkewEntry,
}; };
use bsk_protocol::tools::{ReturnFailure, WaitMsParams, WaitMsResult}; use bsk_protocol::tools::{ReturnFailure, WaitMsParams, WaitMsResult};
use bsk_protocol::{ use bsk_protocol::{
CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId, CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId,
}; };
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use tokio::sync::Notify; use tokio::sync::Notify;
@@ -57,6 +59,8 @@ pub type RpcHandler = Arc<
>; >;
const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15); const DEFAULT_RPC_TIMEOUT: Duration = Duration::from_secs(15);
/// Upper bound on `wait_for_browser_ms` accepted over IPC.
const MAX_BROWSER_WAIT: Duration = Duration::from_secs(60);
// `session.stop` fast-fails while another tool is active; this budget // `session.stop` fast-fails while another tool is active; this budget
// only needs to cover the stop RPC itself and IPC scheduling grace. // only needs to cover the stop RPC itself and IPC scheduling grace.
const DEFAULT_SESSION_STOP_TIMEOUT: Duration = const DEFAULT_SESSION_STOP_TIMEOUT: Duration =
@@ -213,7 +217,10 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
Err(e) => ResponseBody::Err(e), Err(e) => ResponseBody::Err(e),
}, },
Method::SessionList => handle_session_list(&state), Method::SessionList => handle_session_list(&state),
Method::BrowserList => handle_browser_list(&state), Method::BrowserList => match handle_browser_list(&state, params).await {
Ok(v) => ResponseBody::Ok(v),
Err(e) => ResponseBody::Err(e),
},
Method::ToolTabList Method::ToolTabList
| Method::ToolTabCreate | Method::ToolTabCreate
| Method::ToolTabClose | Method::ToolTabClose
@@ -221,6 +228,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolTabBorrow | Method::ToolTabBorrow
| Method::ToolTabReturn | Method::ToolTabReturn
| Method::ToolScreenshot | Method::ToolScreenshot
| Method::ToolConsole
| Method::ToolSnapshot | Method::ToolSnapshot
| Method::ToolGetHtml | Method::ToolGetHtml
| Method::ToolNavigate | Method::ToolNavigate
@@ -554,14 +562,40 @@ async fn handle_status(
state: &Arc<DaemonState>, state: &Arc<DaemonState>,
params: Value, params: Value,
) -> Result<Value, RpcError> { ) -> Result<Value, RpcError> {
if !params.is_null() { let params: StatusParams = parse_params_or_default(params)?;
let _: StatusParams = serde_json::from_value(params).map_err(|err| RpcError { maybe_wait_for_browser(state, params.wait_for_browser_ms).await;
Ok(serde_json::to_value(status.snapshot_with(state)).unwrap_or(Value::Null))
}
fn parse_params_or_default<T>(params: Value) -> Result<T, RpcError>
where
T: DeserializeOwned + Default,
{
if params.is_null() {
Ok(T::default())
} else {
serde_json::from_value(params).map_err(|err| RpcError {
code: ErrorCode::InvalidParams, code: ErrorCode::InvalidParams,
message: err.to_string(), message: err.to_string(),
data: None, data: None,
})?; })
} }
Ok(serde_json::to_value(status.snapshot_with(state)).unwrap_or(Value::Null)) }
async fn maybe_wait_for_browser(state: &Arc<DaemonState>, wait_ms: Option<u64>) {
if let Some(wait) = clamp_browser_wait(wait_ms) {
state.browsers.wait_for_any_connected(wait).await;
}
}
fn clamp_browser_wait(wait_ms: Option<u64>) -> Option<Duration> {
let ms = wait_ms?;
if ms == 0 {
return None;
}
Some(Duration::from_millis(
ms.min(MAX_BROWSER_WAIT.as_millis() as u64),
))
} }
async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> { async fn handle_session_start(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
@@ -788,14 +822,16 @@ fn handle_session_list(state: &Arc<DaemonState>) -> ResponseBody {
ResponseBody::Ok(serde_json::to_value(SessionListResult { sessions }).unwrap_or(Value::Null)) ResponseBody::Ok(serde_json::to_value(SessionListResult { sessions }).unwrap_or(Value::Null))
} }
fn handle_browser_list(state: &Arc<DaemonState>) -> ResponseBody { async fn handle_browser_list(state: &Arc<DaemonState>, params: Value) -> Result<Value, RpcError> {
let params: BrowserListParams = parse_params_or_default(params)?;
maybe_wait_for_browser(state, params.wait_for_browser_ms).await;
// Reuse the same helper that produces the // Reuse the same helper that produces the
// `multiple_browsers_online.error.data.browsers` payload so the // `multiple_browsers_online.error.data.browsers` payload so the
// two surfaces always agree on order — sorted by `connected_at_ms` // two surfaces always agree on order — sorted by `connected_at_ms`
// ascending, with `instance_id` as a deterministic tiebreaker // ascending, with `instance_id` as a deterministic tiebreaker
// (review I1). // (review I1).
let browsers = snapshot_status_entries(&state.browsers, &state.sessions); let browsers = snapshot_status_entries(&state.browsers, &state.sessions);
ResponseBody::Ok(serde_json::to_value(BrowserListResult { browsers }).unwrap_or(Value::Null)) Ok(serde_json::to_value(BrowserListResult { browsers }).unwrap_or(Value::Null))
} }
// ----- Test-helper IpcServer wrapper around the transport layer ----- // ----- Test-helper IpcServer wrapper around the transport layer -----
+140 -6
View File
@@ -416,10 +416,10 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
} }
Frame::Event(ev) => match ev.event { Frame::Event(ev) => match ev.event {
bsk_protocol::EventKind::SessionWindowClosed => { bsk_protocol::EventKind::SessionWindowClosed => {
handle_session_window_closed(state, &ev.payload); handle_session_window_closed(state, &client.id, &ev.payload);
} }
bsk_protocol::EventKind::SessionUserInterrupt => { bsk_protocol::EventKind::SessionUserInterrupt => {
handle_session_user_interrupt(state, &ev.payload); handle_session_user_interrupt(state, &client.id, &ev.payload);
} }
other => { other => {
debug!(event = ?other, "event received (no handler yet)"); debug!(event = ?other, "event received (no handler yet)");
@@ -432,7 +432,11 @@ async fn handle_inbound_text(state: &Arc<DaemonState>, client: &Arc<BrowserClien
} }
} }
fn handle_session_window_closed(state: &Arc<DaemonState>, payload: &serde_json::Value) { fn handle_session_window_closed(
state: &Arc<DaemonState>,
sender: &BrowserId,
payload: &serde_json::Value,
) {
let Some(session_id) = payload.get("session_id").and_then(|v| v.as_str()) else { let Some(session_id) = payload.get("session_id").and_then(|v| v.as_str()) else {
warn!("session.window_closed event missing session_id"); warn!("session.window_closed event missing session_id");
return; return;
@@ -448,6 +452,24 @@ fn handle_session_window_closed(state: &Arc<DaemonState>, payload: &serde_json::
}) })
.unwrap_or_default(); .unwrap_or_default();
let session_id = super::sessions::SessionId(session_id.to_string()); let session_id = super::sessions::SessionId(session_id.to_string());
// Reject events that target a session owned by a *different* browser.
// Without this, any connected extension (or any same-machine process
// that completed the WS handshake) could tear down another browser's
// session by emitting window_closed with its session_id. A session
// that is already gone cannot be verified but is harmless to "forget"
// (forget_session is a no-op), so we only reject when a live owner
// disagrees.
if let Some(session) = state.sessions.get(&session_id)
&& session.browser_id != *sender
{
warn!(
session = %session_id,
sender = %sender,
owner = %session.browser_id,
"ignoring session.window_closed from a browser that does not own this session"
);
return;
}
if super::sessions::forget_session( if super::sessions::forget_session(
&state.sessions, &state.sessions,
&state.tool_queues, &state.tool_queues,
@@ -485,11 +507,29 @@ fn extract_session_id(payload: &serde_json::Value) -> Option<super::sessions::Se
/// forwarded over WS we additionally push a per-RPC cancel frame so /// forwarded over WS we additionally push a per-RPC cancel frame so
/// the extension's dispatcher can abort its `AbortController` — /// the extension's dispatcher can abort its `AbortController` —
/// this is the same shape `handle_cancel` uses, just iterated. /// this is the same shape `handle_cancel` uses, just iterated.
fn handle_session_user_interrupt(state: &Arc<DaemonState>, payload: &serde_json::Value) { fn handle_session_user_interrupt(
state: &Arc<DaemonState>,
sender: &BrowserId,
payload: &serde_json::Value,
) {
let Some(sid) = extract_session_id(payload) else { let Some(sid) = extract_session_id(payload) else {
warn!("session.user_interrupt event missing session_id"); warn!("session.user_interrupt event missing session_id");
return; return;
}; };
// Same ownership guard as window_closed: a browser must not be able
// to cancel another browser's inflight tools or trip its interrupt
// marker by sending user_interrupt with a foreign session_id.
if let Some(session) = state.sessions.get(&sid)
&& session.browser_id != *sender
{
warn!(
session = %sid,
sender = %sender,
owner = %session.browser_id,
"ignoring session.user_interrupt from a browser that does not own this session"
);
return;
}
let snapshots = state.tool_inflight.cancel_session(&sid); let snapshots = state.tool_inflight.cancel_session(&sid);
info!(session = %sid, count = snapshots.len(), "user-interrupt: cancelled inflight tools"); info!(session = %sid, count = snapshots.len(), "user-interrupt: cancelled inflight tools");
@@ -547,6 +587,7 @@ mod tests {
#[cfg(test)] #[cfg(test)]
mod session_user_interrupt_tests { mod session_user_interrupt_tests {
use super::*; use super::*;
use crate::daemon::browsers::BrowserId;
use crate::daemon::inflight::CancelReason; use crate::daemon::inflight::CancelReason;
use crate::daemon::sessions::SessionId; use crate::daemon::sessions::SessionId;
@@ -587,7 +628,13 @@ mod session_user_interrupt_tests {
.register("b".into(), sid_b.clone()) .register("b".into(), sid_b.clone())
.unwrap(); .unwrap();
handle_session_user_interrupt(&state, &serde_json::json!({"session_id": "A"})); // No live session is registered, so the ownership guard treats the
// session as unknown and lets the interrupt through (legacy path).
handle_session_user_interrupt(
&state,
&BrowserId("sender".into()),
&serde_json::json!({"session_id": "A"}),
);
assert_eq!(g_a.entry().cancel_reason(), Some(CancelReason::UserAborted)); assert_eq!(g_a.entry().cancel_reason(), Some(CancelReason::UserAborted));
assert!(g_b.entry().cancel_reason().is_none()); assert!(g_b.entry().cancel_reason().is_none());
@@ -599,7 +646,11 @@ mod session_user_interrupt_tests {
let sid_a = SessionId("A".into()); let sid_a = SessionId("A".into());
let sid_b = SessionId("B".into()); let sid_b = SessionId("B".into());
handle_session_user_interrupt(&state, &serde_json::json!({"session_id": "A"})); handle_session_user_interrupt(
&state,
&BrowserId("sender".into()),
&serde_json::json!({"session_id": "A"}),
);
assert!( assert!(
state.session_interrupts.try_consume(&sid_a), state.session_interrupts.try_consume(&sid_a),
@@ -611,6 +662,89 @@ mod session_user_interrupt_tests {
); );
} }
#[test]
fn handle_session_user_interrupt_honoured_from_owning_browser() {
let state = test_only_daemon_state();
let owner = BrowserId("owner-browser".into());
let sid = state
.sessions
.reserve_id(owner.clone(), 8, || 0)
.expect("reserved session id");
let guard = state
.tool_inflight
.register("rpc-1".into(), sid.clone())
.unwrap();
handle_session_user_interrupt(&state, &owner, &serde_json::json!({"session_id": sid.0}));
assert_eq!(
guard.entry().cancel_reason(),
Some(CancelReason::UserAborted),
"owning browser may interrupt its own session"
);
}
#[test]
fn handle_session_user_interrupt_ignored_from_non_owning_browser() {
let state = test_only_daemon_state();
let owner = BrowserId("owner-browser".into());
let attacker = BrowserId("attacker-browser".into());
let sid = state
.sessions
.reserve_id(owner.clone(), 8, || 0)
.expect("reserved session id");
let guard = state
.tool_inflight
.register("rpc-1".into(), sid.clone())
.unwrap();
handle_session_user_interrupt(&state, &attacker, &serde_json::json!({"session_id": sid.0}));
assert!(
guard.entry().cancel_reason().is_none(),
"non-owning browser must not cancel another session's tools"
);
assert!(
!state.session_interrupts.try_consume(&sid),
"interrupt marker must not be set by a non-owning browser"
);
}
#[test]
fn handle_session_window_closed_honoured_from_owning_browser() {
let state = test_only_daemon_state();
let owner = BrowserId("owner-browser".into());
let sid = state
.sessions
.reserve_id(owner.clone(), 8, || 0)
.expect("reserved session id");
handle_session_window_closed(&state, &owner, &serde_json::json!({"session_id": sid.0}));
assert!(
state.sessions.get(&sid).is_none(),
"owning browser may close its own session"
);
}
#[test]
fn handle_session_window_closed_ignored_from_non_owning_browser() {
let state = test_only_daemon_state();
let owner = BrowserId("owner-browser".into());
let attacker = BrowserId("attacker-browser".into());
let sid = state
.sessions
.reserve_id(owner.clone(), 8, || 0)
.expect("reserved session id");
handle_session_window_closed(&state, &attacker, &serde_json::json!({"session_id": sid.0}));
assert!(
state.sessions.get(&sid).is_some(),
"non-owning browser must not be able to tear down another session"
);
}
fn test_only_daemon_state() -> std::sync::Arc<DaemonState> { fn test_only_daemon_state() -> std::sync::Arc<DaemonState> {
use crate::daemon::start::DaemonConfig; use crate::daemon::start::DaemonConfig;
std::sync::Arc::new(DaemonState::new(DaemonConfig::new(0))) std::sync::Arc::new(DaemonState::new(DaemonConfig::new(0)))
+1
View File
@@ -77,6 +77,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
Command::Tab(cmd) => cli::tab::dispatch(cmd, format), Command::Tab(cmd) => cli::tab::dispatch(cmd, format),
Command::Screenshot(args) => cli::screenshot::dispatch(args, format), Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
Command::Snapshot(args) => cli::snapshot::dispatch(args, format), Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
Command::Console(args) => cli::console::dispatch(args, format),
Command::GetHtml(args) => cli::get_html::dispatch(args, format), Command::GetHtml(args) => cli::get_html::dispatch(args, format),
Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format), Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format), Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
+321
View File
@@ -0,0 +1,321 @@
//! Integration tests: daemon status/list RPCs wait for a late extension handshake.
#![cfg(unix)]
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Duration;
use bsk::cli::doctor;
use bsk::cli::status::Output;
use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{BrowserListParams, HandshakeParams, HandshakeResult, StatusParams};
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
static DOCTOR_ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvRestore(Vec<(&'static str, Option<OsString>)>);
impl Drop for EnvRestore {
fn drop(&mut self) {
for (key, value) in self.0.drain(..) {
unsafe {
match value {
Some(value) => std::env::set_var(key, value),
None => std::env::remove_var(key),
}
}
}
}
}
fn with_doctor_env<R>(
bsk_home: &Path,
overrides: &[(&'static str, &'static str)],
f: impl FnOnce() -> R,
) -> R {
let _lock = DOCTOR_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let saved = [
"BSK_HOME",
"BSK_BROWSER_WAIT_MS",
"BSK_DOCTOR_BROWSER_WAIT_MS",
]
.into_iter()
.map(|key| (key, std::env::var_os(key)))
.collect();
let _restore = EnvRestore(saved);
unsafe {
std::env::set_var("BSK_HOME", bsk_home);
std::env::remove_var("BSK_BROWSER_WAIT_MS");
std::env::remove_var("BSK_DOCTOR_BROWSER_WAIT_MS");
for (key, value) in overrides {
std::env::set_var(key, value);
}
}
f()
}
fn tempfile_path(prefix: &str) -> PathBuf {
let mut p = std::env::temp_dir();
let mut rng = rand::thread_rng();
let suffix: String = (0..8)
.map(|_| char::from_digit(rng.gen_range(0..16), 16).unwrap())
.collect();
p.push(format!("{prefix}-{}-{suffix}.sock", std::process::id()));
p
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-wait");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
let origin = format!("chrome-extension://{TEST_EXT_ID}");
let url = format!("ws://{addr}/");
let req = Request::builder()
.method("GET")
.uri(&url)
.header("Host", addr.to_string())
.header("Upgrade", "websocket")
.header("Connection", "Upgrade")
.header("Sec-WebSocket-Version", "13")
.header("Sec-WebSocket-Key", generate_key())
.header("Origin", origin)
.body(())
.unwrap();
let (ws, _resp) = tokio_tungstenite::connect_async(req).await.unwrap();
ws
}
async fn handshake_as_ext(
ws: &mut tokio_tungstenite::WebSocketStream<
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
>,
) -> HandshakeResult {
let params = HandshakeParams {
client: "browser-skill-extension".into(),
version: "0.1.0-dev.0".parse().unwrap(),
protocol_version: "1.0".into(),
instance_id: TEST_EXT_ID.into(),
browser: BrowserPeerInfo {
name: "chrome".into(),
version: "131.0".into(),
},
min_compatible_peer: Some("0.1.0-dev.0".parse().unwrap()),
min_compatible_protocol: Some("1.0".into()),
label: "Test".into(),
};
let req = RequestFrame {
id: "hs".into(),
method: Method::SystemHandshake,
params: Some(serde_json::to_value(params).unwrap()),
};
ws.send(Message::Text(serde_json::to_string(&req).unwrap()))
.await
.unwrap();
let resp = ws.next().await.unwrap().unwrap();
let text = match resp {
Message::Text(t) => t,
_ => panic!("expected text frame"),
};
let resp: ResponseFrame = serde_json::from_str(&text).unwrap();
match resp.body {
ResponseBody::Ok(v) => serde_json::from_value(v).unwrap(),
ResponseBody::Err(e) => panic!("handshake rejected: {e:?}"),
}
}
#[tokio::test]
async fn system_status_waits_for_late_extension_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let sock_for_ipc = sock.clone();
let status_task = tokio::spawn(async move {
let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap();
ipc.call::<StatusParams, bsk_protocol::StatusResult>(
"status-wait",
Method::SystemStatus,
Some(StatusParams {
wait_for_browser_ms: Some(500),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("system.status should succeed")
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let status = status_task.await.expect("join");
assert_eq!(status.browsers.len(), 1);
assert_eq!(status.browsers[0].instance_id, TEST_EXT_ID);
handle.shutdown().await;
}
#[tokio::test]
async fn browser_list_waits_for_late_extension_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
#[derive(serde::Deserialize)]
struct ListReply {
browsers: Vec<bsk_protocol::system::BrowserStatusEntry>,
}
let sock_for_ipc = sock.clone();
let list_task = tokio::spawn(async move {
let mut ipc = IpcClient::connect(&sock_for_ipc).await.unwrap();
ipc.call::<BrowserListParams, ListReply>(
"browser-list-wait",
Method::BrowserList,
Some(BrowserListParams {
wait_for_browser_ms: Some(500),
}),
Duration::from_secs(5),
)
.await
.unwrap()
.expect("browser.list should succeed")
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let list = list_task.await.expect("join");
assert_eq!(list.browsers.len(), 1);
assert_eq!(list.browsers[0].instance_id, TEST_EXT_ID);
handle.shutdown().await;
}
#[tokio::test]
async fn system_status_without_wait_returns_immediately_when_empty() {
let (handle, sock) = spawn_daemon().await;
let mut ipc = IpcClient::connect(&sock).await.unwrap();
let started = std::time::Instant::now();
let status = ipc
.call::<StatusParams, bsk_protocol::StatusResult>(
"status-now",
Method::SystemStatus,
Some(StatusParams::default()),
Duration::from_secs(2),
)
.await
.unwrap()
.expect("system.status should succeed");
assert!(started.elapsed() < Duration::from_millis(200));
assert!(status.browsers.is_empty());
handle.shutdown().await;
}
#[tokio::test]
async fn doctor_reports_extension_connected_after_handshake() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let home = tempfile::TempDir::new().unwrap();
let bsk_home = home.path().join("bsk");
std::fs::create_dir_all(&bsk_home).unwrap();
let info = serde_json::json!({
"pid": std::process::id(),
"sock_path": sock,
"ws_port": ws_addr.port(),
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
bsk_home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let bsk_home_for_doctor = bsk_home.clone();
let checks = tokio::task::spawn_blocking(move || {
with_doctor_env(
&bsk_home_for_doctor,
&[("BSK_BROWSER_WAIT_MS", "0")],
|| doctor::run(Output::Json),
)
})
.await
.expect("doctor join")
.expect("doctor ok");
let ext = checks
.iter()
.find(|check| check.name == "extension connected")
.expect("extension connected check");
assert_eq!(ext.status, doctor::CheckStatus::Ok);
handle.shutdown().await;
}
#[tokio::test]
async fn doctor_waits_for_late_extension_on_verified_daemon() {
let (handle, sock) = spawn_daemon().await;
let ws_addr = handle.ws_addr();
let home = tempfile::TempDir::new().unwrap();
let bsk_home = home.path().join("bsk");
std::fs::create_dir_all(&bsk_home).unwrap();
let info = serde_json::json!({
"pid": std::process::id(),
"sock_path": sock,
"ws_port": ws_addr.port(),
"version": env!("CARGO_PKG_VERSION"),
"started_at_epoch_secs": 1
});
std::fs::write(
bsk_home.join("daemon.json"),
serde_json::to_vec_pretty(&info).unwrap(),
)
.unwrap();
let bsk_home_for_doctor = bsk_home.clone();
let doctor_task = tokio::task::spawn_blocking(move || {
with_doctor_env(
&bsk_home_for_doctor,
&[("BSK_DOCTOR_BROWSER_WAIT_MS", "500")],
|| doctor::run(Output::Json),
)
});
tokio::time::sleep(Duration::from_millis(80)).await;
let mut ws = connect_ext(ws_addr).await;
let _ = handshake_as_ext(&mut ws).await;
let checks = doctor_task.await.expect("doctor join").expect("doctor ok");
let ext = checks
.iter()
.find(|check| check.name == "extension connected")
.expect("extension connected check");
assert_eq!(ext.status, doctor::CheckStatus::Ok);
handle.shutdown().await;
}
+37
View File
@@ -67,6 +67,43 @@ fn parses_top_level_status_and_doctor() {
assert!(matches!(cli.command, Command::Doctor)); assert!(matches!(cli.command, Command::Doctor));
} }
#[test]
fn parses_console_command_with_context_safety_flags() {
let cli = parse(&[
"bsk",
"console",
"--session",
"s1",
"--tab-id",
"9",
"--since",
"12",
"--limit",
"75",
"--max-text-chars",
"2048",
"--include-stack",
]);
let Command::Console(args) = cli.command else {
panic!("expected console command");
};
assert_eq!(args.session, "s1");
assert_eq!(args.tab_id, Some(9));
assert_eq!(args.since, Some(12));
assert_eq!(args.limit, Some(75));
assert_eq!(args.max_text_chars, Some(2048));
assert!(args.include_stack);
}
#[test]
fn rejects_zero_console_bounds() {
assert!(Cli::try_parse_from(["bsk", "console", "--session", "s1", "--limit", "0"]).is_err());
assert!(
Cli::try_parse_from(["bsk", "console", "--session", "s1", "--max-text-chars", "0"])
.is_err()
);
}
#[test] #[test]
fn parses_install_skill_subcommand() { fn parses_install_skill_subcommand() {
let cli = parse(&["bsk", "install-skill", "--list"]); let cli = parse(&["bsk", "install-skill", "--list"]);
+26 -4
View File
@@ -428,8 +428,8 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
let (ws_sink, ws_stream) = ws.split(); let (ws_sink, ws_stream) = ws.split();
let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink)); let ws_sink = Arc::new(tokio::sync::Mutex::new(ws_sink));
// Fake extension: replies to session_start AND to snapshot // Fake extension: replies to session_start, console, and snapshot
// (snapshot must succeed transparently). Records click count // (read-only tools must succeed transparently). Records click count
// — must remain 0 because the daemon should reject the click // — must remain 0 because the daemon should reject the click
// without forwarding. // without forwarding.
let ws_sink_for_responder = Arc::clone(&ws_sink); let ws_sink_for_responder = Arc::clone(&ws_sink);
@@ -454,6 +454,12 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
}) })
.unwrap(), .unwrap(),
), ),
Method::ToolConsole => ResponseBody::Ok(json!({
"tab_id": 7,
"entries": [],
"next_since": 0,
"truncated": false
})),
Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})), Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})),
Method::ToolClick => { Method::ToolClick => {
observed_click_count_clone observed_click_count_clone
@@ -511,8 +517,24 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
let state = handle.state(); let state = handle.state();
wait_for_session_interrupt_pending(&state, &start.session_id).await; wait_for_session_interrupt_pending(&state, &start.session_id).await;
// Read-only snapshot must succeed AND must not consume the // Read-only console must succeed AND must not consume the marker.
// marker. let console_outcome = ipc
.call::<_, serde_json::Value>(
"console-after-interrupt",
Method::ToolConsole,
Some(json!({"session_id": start.session_id.clone()})),
Duration::from_secs(3),
)
.await
.unwrap();
assert!(
console_outcome.is_ok(),
"read-only tool.console must pass through transparently (got {:?})",
console_outcome
);
// Existing read-only snapshot must still succeed after console,
// proving console did not consume the marker.
let snapshot_outcome = ipc let snapshot_outcome = ipc
.call::<_, serde_json::Value>( .call::<_, serde_json::Value>(
"snap-after-interrupt", "snap-after-interrupt",
+1
View File
@@ -44,6 +44,7 @@ fn bsk_status_json_returns_structured_payload() {
let out = Command::new(bsk_bin()) let out = Command::new(bsk_bin())
.args(["--json", "status"]) .args(["--json", "status"])
.env("BSK_HOME", &home) .env("BSK_HOME", &home)
.env("BSK_BROWSER_WAIT_MS", "0")
.env("RUST_LOG", "warn") .env("RUST_LOG", "warn")
.output() .output()
.expect("run bsk status"); .expect("run bsk status");
+60 -3
View File
@@ -10,9 +10,9 @@ use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient; use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult}; use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{ use bsk_protocol::tools::{
GetHtmlParams, GetHtmlResult, ScreenshotParams, ScreenshotResult, SessionStartParams, ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
SessionStartResult, SnapshotParams, SnapshotResult, TabInfo, TabListParams, TabListResult, ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
TabScope, SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
}; };
use bsk_protocol::{ use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError, BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
@@ -311,6 +311,63 @@ async fn screenshot_forwards_ref_to_extension() {
handle.shutdown().await; handle.shutdown().await;
} }
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn console_returns_buffered_entries() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |req| {
assert_eq!(req.method, Method::ToolConsole);
let params: ConsoleParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(params.tab_id, Some(7));
assert_eq!(params.since, Some(3));
assert_eq!(params.limit, Some(50));
assert_eq!(params.max_text_chars, Some(1000));
assert_eq!(params.include_stack, Some(false));
ResponseBody::Ok(
serde_json::to_value(ConsoleResult {
tab_id: 7,
entries: vec![ConsoleEntry {
sequence: 4,
kind: ConsoleEntryKind::Console,
level: "warn".into(),
text: "deprecated API".into(),
url: Some("https://example.test/app.js".into()),
line: Some(10),
column: Some(2),
timestamp: None,
stack_trace: vec![],
truncated: false,
}],
next_since: 4,
truncated: false,
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: ConsoleResult = ipc_tool_call(
&sock,
Method::ToolConsole,
ConsoleParams {
session_id,
tab_id: Some(7),
since: Some(3),
limit: Some(50),
max_text_chars: Some(1000),
include_stack: Some(false),
},
)
.await
.expect("console ok");
assert_eq!(result.tab_id, 7);
assert_eq!(result.next_since, 4);
assert_eq!(result.entries[0].text, "deprecated API");
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_returns_text_and_ref_count() { async fn snapshot_returns_text_and_ref_count() {
let (handle, sock) = spawn_daemon().await; let (handle, sock) = spawn_daemon().await;
@@ -0,0 +1,17 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "BrowserListParams",
"description": "`browser.list` request payload.",
"type": "object",
"properties": {
"wait_for_browser_ms": {
"description": "When set, the daemon polls the browser registry for up to this many milliseconds before returning a list. Only applies while zero browsers are registered; an existing connection returns immediately.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
}
}
}
@@ -1,6 +1,17 @@
{ {
"$schema": "http://json-schema.org/draft-07/schema#", "$schema": "http://json-schema.org/draft-07/schema#",
"title": "StatusParams", "title": "StatusParams",
"description": "`system.status` request payload. Empty.", "description": "`system.status` request payload.",
"type": "object" "type": "object",
"properties": {
"wait_for_browser_ms": {
"description": "When set, the daemon polls the browser registry for up to this many milliseconds before returning a snapshot. Only applies while zero browsers are registered; an existing connection returns immediately.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
}
}
} }
@@ -0,0 +1,105 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConsoleEntry",
"type": "object",
"required": [
"kind",
"level",
"sequence",
"text"
],
"properties": {
"column": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"kind": {
"$ref": "#/definitions/ConsoleEntryKind"
},
"level": {
"type": "string"
},
"line": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"sequence": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
},
"stack_trace": {
"type": "array",
"items": {
"$ref": "#/definitions/ConsoleStackFrame"
}
},
"text": {
"type": "string"
},
"timestamp": {
"type": [
"number",
"null"
],
"format": "double"
},
"truncated": {
"default": false,
"type": "boolean"
},
"url": {
"type": [
"string",
"null"
]
}
},
"definitions": {
"ConsoleEntryKind": {
"type": "string",
"enum": [
"console",
"exception",
"log"
]
},
"ConsoleStackFrame": {
"type": "object",
"properties": {
"column": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"function_name": {
"type": [
"string",
"null"
]
},
"line": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"url": {
"type": [
"string",
"null"
]
}
}
}
}
}
@@ -0,0 +1,55 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConsoleParams",
"type": "object",
"required": [
"session_id"
],
"properties": {
"include_stack": {
"description": "Include structured stack frames. Defaults to false.",
"type": [
"boolean",
"null"
]
},
"limit": {
"description": "Maximum number of entries to return. Extension applies safe defaults and caps.",
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 1.0
},
"max_text_chars": {
"description": "Maximum characters returned per entry text. Extension applies safe defaults and caps.",
"type": [
"integer",
"null"
],
"format": "uint32",
"minimum": 1.0
},
"session_id": {
"type": "string"
},
"since": {
"description": "Return entries with sequence strictly greater than this cursor.",
"type": [
"integer",
"null"
],
"format": "uint64",
"minimum": 0.0
},
"tab_id": {
"description": "Optional target tab. Defaults to the Agent Window's currently active tab.",
"type": [
"integer",
"null"
],
"format": "int64"
}
}
}
@@ -0,0 +1,133 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConsoleResult",
"type": "object",
"required": [
"next_since",
"tab_id"
],
"properties": {
"entries": {
"type": "array",
"items": {
"$ref": "#/definitions/ConsoleEntry"
}
},
"next_since": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
},
"tab_id": {
"type": "integer",
"format": "int64"
},
"truncated": {
"default": false,
"type": "boolean"
}
},
"definitions": {
"ConsoleEntry": {
"type": "object",
"required": [
"kind",
"level",
"sequence",
"text"
],
"properties": {
"column": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"kind": {
"$ref": "#/definitions/ConsoleEntryKind"
},
"level": {
"type": "string"
},
"line": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"sequence": {
"type": "integer",
"format": "uint64",
"minimum": 0.0
},
"stack_trace": {
"type": "array",
"items": {
"$ref": "#/definitions/ConsoleStackFrame"
}
},
"text": {
"type": "string"
},
"timestamp": {
"type": [
"number",
"null"
],
"format": "double"
},
"truncated": {
"default": false,
"type": "boolean"
},
"url": {
"type": [
"string",
"null"
]
}
}
},
"ConsoleEntryKind": {
"type": "string",
"enum": [
"console",
"exception",
"log"
]
},
"ConsoleStackFrame": {
"type": "object",
"properties": {
"column": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"function_name": {
"type": [
"string",
"null"
]
},
"line": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"url": {
"type": [
"string",
"null"
]
}
}
}
}
}
@@ -0,0 +1,33 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ConsoleStackFrame",
"type": "object",
"properties": {
"column": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"function_name": {
"type": [
"string",
"null"
]
},
"line": {
"type": [
"integer",
"null"
],
"format": "int64"
},
"url": {
"type": [
"string",
"null"
]
}
}
}
+7 -1
View File
@@ -4,7 +4,8 @@ use std::fs;
use std::path::PathBuf; use std::path::PathBuf;
use bsk_protocol::system::{ use bsk_protocol::system::{
HandshakeParams, HandshakeResult, PingParams, PingResult, StatusParams, StatusResult, BrowserListParams, HandshakeParams, HandshakeResult, PingParams, PingResult, StatusParams,
StatusResult,
}; };
use bsk_protocol::tools::*; use bsk_protocol::tools::*;
use bsk_protocol::{CancelParams, CancelResult}; use bsk_protocol::{CancelParams, CancelResult};
@@ -34,6 +35,7 @@ fn main() {
dump!(PingResult, "system_ping_result"); dump!(PingResult, "system_ping_result");
dump!(StatusParams, "system_status_params"); dump!(StatusParams, "system_status_params");
dump!(StatusResult, "system_status_result"); dump!(StatusResult, "system_status_result");
dump!(BrowserListParams, "browser_list_params");
dump!(CancelParams, "cancel_params"); dump!(CancelParams, "cancel_params");
dump!(CancelResult, "cancel_result"); dump!(CancelResult, "cancel_result");
@@ -80,6 +82,10 @@ fn main() {
dump!(GetHtmlResult, "tool_get_html_result"); dump!(GetHtmlResult, "tool_get_html_result");
dump!(ScreenshotParams, "tool_screenshot_params"); dump!(ScreenshotParams, "tool_screenshot_params");
dump!(ScreenshotResult, "tool_screenshot_result"); dump!(ScreenshotResult, "tool_screenshot_result");
dump!(ConsoleParams, "tool_console_params");
dump!(ConsoleResult, "tool_console_result");
dump!(ConsoleEntry, "tool_console_entry");
dump!(ConsoleStackFrame, "tool_console_stack_frame");
dump!(EvaluateParams, "tool_evaluate_params"); dump!(EvaluateParams, "tool_evaluate_params");
dump!(EvaluateResult, "tool_evaluate_result"); dump!(EvaluateResult, "tool_evaluate_result");
+3 -3
View File
@@ -12,8 +12,8 @@ pub use error::{DecodeError, ErrorCode, RpcError};
pub use frame::{EventFrame, EventKind, Frame, RequestFrame, ResponseBody, ResponseFrame, RpcId}; pub use frame::{EventFrame, EventKind, Frame, RequestFrame, ResponseBody, ResponseFrame, RpcId};
pub use method::Method; pub use method::Method;
pub use system::{ pub use system::{
BrowserPeerInfo, BrowserStatusEntry, HandshakeCompat, HandshakeParams, HandshakeResult, BrowserListParams, BrowserPeerInfo, BrowserStatusEntry, HandshakeCompat, HandshakeParams,
PingParams, PingResult, SessionStatusEntry, StatusParams, StatusResult, VersionSkewEntry, HandshakeResult, PingParams, PingResult, SessionStatusEntry, StatusParams, StatusResult,
compare_protocol, evaluate_handshake_compat, VersionSkewEntry, compare_protocol, evaluate_handshake_compat,
}; };
pub use tools::*; pub use tools::*;
+11
View File
@@ -62,6 +62,8 @@ pub enum Method {
ToolGetHtml, ToolGetHtml,
#[serde(rename = "tool.screenshot")] #[serde(rename = "tool.screenshot")]
ToolScreenshot, ToolScreenshot,
#[serde(rename = "tool.console")]
ToolConsole,
#[serde(rename = "tool.evaluate")] #[serde(rename = "tool.evaluate")]
ToolEvaluate, ToolEvaluate,
#[serde(rename = "tool.wait_for_navigation")] #[serde(rename = "tool.wait_for_navigation")]
@@ -124,6 +126,7 @@ impl Method {
| Method::ToolSnapshot | Method::ToolSnapshot
| Method::ToolGetHtml | Method::ToolGetHtml
| Method::ToolScreenshot | Method::ToolScreenshot
| Method::ToolConsole
| Method::ToolWaitForNavigation | Method::ToolWaitForNavigation
| Method::ToolWaitMs | Method::ToolWaitMs
| Method::ToolRequestHelp => false, | Method::ToolRequestHelp => false,
@@ -159,6 +162,13 @@ mod tests {
assert_eq!(serde_json::to_value(method).unwrap(), json!("cancel")); assert_eq!(serde_json::to_value(method).unwrap(), json!("cancel"));
} }
#[test]
fn console_method_round_trips() {
let method: Method = serde_json::from_value(json!("tool.console")).unwrap();
assert_eq!(method, Method::ToolConsole);
assert_eq!(serde_json::to_value(method).unwrap(), json!("tool.console"));
}
#[test] #[test]
fn cancel_params_and_result_round_trip() { fn cancel_params_and_result_round_trip() {
let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap(); let params: CancelParams = serde_json::from_value(json!({ "rpc_id": "wait-1" })).unwrap();
@@ -176,6 +186,7 @@ mod tests {
assert!(!Method::ToolSnapshot.is_mutating()); assert!(!Method::ToolSnapshot.is_mutating());
assert!(!Method::ToolGetHtml.is_mutating()); assert!(!Method::ToolGetHtml.is_mutating());
assert!(!Method::ToolScreenshot.is_mutating()); assert!(!Method::ToolScreenshot.is_mutating());
assert!(!Method::ToolConsole.is_mutating());
assert!(!Method::ToolWaitForNavigation.is_mutating()); assert!(!Method::ToolWaitForNavigation.is_mutating());
assert!(!Method::ToolWaitMs.is_mutating()); assert!(!Method::ToolWaitMs.is_mutating());
} }
+18 -2
View File
@@ -531,9 +531,25 @@ pub struct SessionStatusEntry {
pub created_at_ms: i64, pub created_at_ms: i64,
} }
/// `system.status` request payload. Empty. /// `system.status` request payload.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct StatusParams {} pub struct StatusParams {
/// When set, the daemon polls the browser registry for up to this many
/// milliseconds before returning a snapshot. Only applies while zero
/// browsers are registered; an existing connection returns immediately.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_for_browser_ms: Option<u64>,
}
/// `browser.list` request payload.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct BrowserListParams {
/// When set, the daemon polls the browser registry for up to this many
/// milliseconds before returning a list. Only applies while zero browsers
/// are registered; an existing connection returns immediately.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wait_for_browser_ms: Option<u64>,
}
/// One entry per connected browser whose protocol version drifts from /// One entry per connected browser whose protocol version drifts from
/// the daemon's (same major). Surfaced in `system.status` for /// the daemon's (same major). Surfaced in `system.status` for
+147
View File
@@ -0,0 +1,147 @@
//! `tool.console` — read buffered console/log/exception messages.
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ConsoleParams {
pub session_id: String,
/// Optional target tab. Defaults to the Agent Window's currently active tab.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
/// Return entries with sequence strictly greater than this cursor.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub since: Option<u64>,
/// Maximum number of entries to return. Extension applies safe defaults and caps.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub limit: Option<u32>,
/// Maximum characters returned per entry text. Extension applies safe defaults and caps.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1))]
pub max_text_chars: Option<u32>,
/// Include structured stack frames. Defaults to false.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub include_stack: Option<bool>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ConsoleEntryKind {
Console,
Exception,
Log,
}
impl ConsoleEntryKind {
pub fn as_str(self) -> &'static str {
match self {
Self::Console => "console",
Self::Exception => "exception",
Self::Log => "log",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ConsoleStackFrame {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub function_name: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<i64>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ConsoleEntry {
pub sequence: u64,
pub kind: ConsoleEntryKind,
pub level: String,
pub text: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub line: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub column: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<f64>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub stack_trace: Vec<ConsoleStackFrame>,
#[serde(default)]
pub truncated: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct ConsoleResult {
pub tab_id: i64,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<ConsoleEntry>,
pub next_since: u64,
#[serde(default)]
pub truncated: bool,
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn console_params_omit_optional_fields() {
let params = ConsoleParams {
session_id: "aa11".into(),
tab_id: None,
since: None,
limit: None,
max_text_chars: None,
include_stack: None,
};
let value = serde_json::to_value(&params).unwrap();
assert_eq!(value["session_id"], "aa11");
assert!(value.get("tab_id").is_none());
assert!(value.get("since").is_none());
assert!(value.get("limit").is_none());
assert!(value.get("max_text_chars").is_none());
assert!(value.get("include_stack").is_none());
let round: ConsoleParams = serde_json::from_value(value).unwrap();
assert_eq!(round, params);
}
#[test]
fn console_result_round_trips_with_stack_and_truncation() {
let result = ConsoleResult {
tab_id: 7,
entries: vec![ConsoleEntry {
sequence: 12,
kind: ConsoleEntryKind::Exception,
level: "error".into(),
text: "Uncaught TypeError: x is undefined".into(),
url: Some("https://example.test/app.js".into()),
line: Some(42),
column: Some(7),
timestamp: Some(1234.5),
stack_trace: vec![ConsoleStackFrame {
function_name: Some("render".into()),
url: Some("https://example.test/app.js".into()),
line: Some(42),
column: Some(7),
}],
truncated: true,
}],
next_since: 12,
truncated: true,
};
let value = serde_json::to_value(&result).unwrap();
assert_eq!(value["entries"][0]["kind"], json!("exception"));
assert_eq!(
value["entries"][0]["stack_trace"][0]["function_name"],
json!("render")
);
let round: ConsoleResult = serde_json::from_value(value).unwrap();
assert_eq!(round, result);
}
}
+2
View File
@@ -1,5 +1,6 @@
//! Typed params/results for the 21 `tool.*` RPC methods (§7). //! Typed params/results for the 21 `tool.*` RPC methods (§7).
pub mod console;
pub mod dialog; pub mod dialog;
pub mod human_loop; pub mod human_loop;
pub mod interaction; pub mod interaction;
@@ -10,6 +11,7 @@ pub mod session;
pub mod tabs; pub mod tabs;
pub mod waits; pub mod waits;
pub use console::*;
pub use dialog::*; pub use dialog::*;
pub use human_loop::*; pub use human_loop::*;
pub use interaction::*; pub use interaction::*;
+197
View File
@@ -0,0 +1,197 @@
<#
.SYNOPSIS
install.ps1 — install the bsk CLI on Windows from GitHub Releases.
.DESCRIPTION
Downloads the latest (or pinned) bsk release for Windows x64,
extracts bsk.exe to a user-local directory, and adds it to PATH.
Usage:
irm https://raw.githubusercontent.com/Tencent/BrowserSkill/main/install.ps1 | iex
Environment overrides:
$env:BSK_REPO GitHub owner/repo (default: Tencent/BrowserSkill)
$env:BSK_VERSION Pin CLI version (default: latest from version.json)
$env:BSK_INSTALL_DIR Install directory (default: $HOME\.local\bin)
#>
#Requires -Version 5.1
$ErrorActionPreference = "Stop"
$Repo = if ($env:BSK_REPO) { $env:BSK_REPO } else { "Tencent/BrowserSkill" }
$InstallDir = if ($env:BSK_INSTALL_DIR) { $env:BSK_INSTALL_DIR } else { Join-Path $HOME ".local\bin" }
$GitHub = "https://github.com/${Repo}"
function Write-Log {
param([string]$Message)
Write-Host "==> $Message" -ForegroundColor Cyan
}
function Write-Die {
param([string]$Message)
Write-Host "error: $Message" -ForegroundColor Red
exit 1
}
# ── Platform / architecture detection ─────────────────────────────────────────
function Get-PlatformTriple {
$arch = [System.Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
switch ($arch) {
"X64" { $archId = "x64" }
"Arm64" { $archId = "arm64" }
default { Write-Die "unsupported architecture: $arch (x64 and ARM64 only)" }
}
$windowsArch = switch ($archId) {
"x64" { "x86_64-pc-windows-msvc" }
"arm64" { "aarch64-pc-windows-msvc" }
}
return @{
ArchId = $archId
TargetTriple = $windowsArch
}
}
# ── Version resolution ────────────────────────────────────────────────────────
function Get-LatestVersion {
$versionJsonUrl = "${GitHub}/releases/latest/download/version.json"
Write-Log "fetching latest version from ${versionJsonUrl}"
try {
$version = (Invoke-RestMethod -Uri $versionJsonUrl).version
if (-not $version) { Write-Die "could not parse version from version.json" }
return $version
}
catch {
Write-Die "failed to fetch version.json: $_"
}
}
# ── PATH helpers ──────────────────────────────────────────────────────────────
function Add-ToUserPath {
param([string]$Dir)
$currentUserPath = [Environment]::GetEnvironmentVariable("PATH", "User") -split ";" | Where-Object { $_ }
if ($currentUserPath -contains $Dir) {
Write-Log "$Dir is already in your user PATH"
return
}
$newUserPath = ($currentUserPath + $Dir) -join ";"
[Environment]::SetEnvironmentVariable("PATH", $newUserPath, "User")
Write-Log "added ${Dir} to user PATH"
}
function Add-ToSessionPath {
param([string]$Dir)
$pathEntries = $env:PATH -split ";" | Where-Object { $_ }
if ($pathEntries -contains $Dir) {
return
}
$env:PATH = "$Dir;$env:PATH"
}
# ── Git Bash (bash environment) PATH helper ──────────────────────────────────
function Add-ToBashProfile {
param([string]$Dir)
# Convert Windows path (e.g. C:\Users\foo\.local\bin) to Git-Bash Unix-style (/c/Users/foo/.local/bin)
$unixPath = $Dir -replace '\\', '/'
if ($unixPath -match '^([A-Z]):(.*)$') {
$unixPath = '/' + $matches[1].ToLower() + $matches[2]
}
$bashRc = Join-Path $HOME ".bashrc"
$exportLine = "export PATH=""${unixPath}:`$PATH"" # bsk CLI"
if (Test-Path $bashRc) {
$content = Get-Content $bashRc -Raw -ErrorAction SilentlyContinue
if ($content -match [regex]::Escape($unixPath)) {
Write-Log "$unixPath is already in ~/.bashrc"
return
}
}
Add-Content $bashRc "`n$exportLine" -Encoding ASCII
Write-Log "added ${unixPath} to ~/.bashrc"
}
# ── Main ──────────────────────────────────────────────────────────────────────
function Main {
$platform = Get-PlatformTriple
if ($env:BSK_VERSION) {
$version = $env:BSK_VERSION -replace '^v', ''
Write-Log "using pinned version ${version}"
}
else {
$version = Get-LatestVersion
Write-Log "latest version is ${version}"
}
$tag = "cli-v${version}"
$archiveName = "bsk-v${version}-$($platform.TargetTriple).zip"
$downloadUrl = "${GitHub}/releases/download/${tag}/${archiveName}"
$tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName())
New-Item -ItemType Directory -Path $tempDir -Force | Out-Null
try {
$archivePath = Join-Path $tempDir $archiveName
Write-Log "downloading ${downloadUrl}"
Invoke-WebRequest -Uri $downloadUrl -OutFile $archivePath -UseBasicParsing -ErrorAction Stop
Write-Log "extracting ${archiveName}"
Expand-Archive -Path $archivePath -DestinationPath $tempDir -Force
if (-not (Test-Path (Join-Path $tempDir "bsk.exe"))) {
Write-Die "bsk.exe not found in archive"
}
if (-not (Test-Path $InstallDir)) {
New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null
}
Copy-Item -Path (Join-Path $tempDir "bsk.exe") -Destination (Join-Path $InstallDir "bsk.exe") -Force
Write-Log "installed bsk to $InstallDir\bsk.exe"
# Add to session PATH (current shell)
Add-ToSessionPath $InstallDir
# Add to user PATH (persistent, for PowerShell / cmd)
Add-ToUserPath $InstallDir
# Add to Git Bash PATH (persistent, for bash-based shells / agents)
Add-ToBashProfile $InstallDir
# Verify
$bskPath = Join-Path $InstallDir "bsk.exe"
if (Get-Command bsk -ErrorAction SilentlyContinue) {
& bsk --version
}
else {
Write-Log "verify install: & ""$bskPath"" --version"
}
Write-Log "done"
Write-Host ""
Write-Host "Open a new terminal (PowerShell / Git Bash) for PATH changes to take full effect."
}
finally {
Remove-Item -Recurse -Force $tempDir -ErrorAction SilentlyContinue
}
}
Main