Compare commits

...
9 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
32 changed files with 1835 additions and 65 deletions
Generated
+2 -2
View File
@@ -84,7 +84,7 @@ dependencies = [
[[package]]
name = "bsk"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"anyhow",
"bsk-protocol",
@@ -113,7 +113,7 @@ dependencies = [
[[package]]
name = "bsk-protocol"
version = "0.1.6"
version = "0.1.7"
dependencies = [
"schemars",
"semver",
+2 -2
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.1.6"
version = "0.1.7"
edition = "2024"
rust-version = "1.85"
license = "MIT"
@@ -37,7 +37,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.1.6", path = "crates/bsk-protocol" }
bsk-protocol = { version = "0.1.7", path = "crates/bsk-protocol" }
[profile.release]
strip = "symbols"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.1.2",
"version": "0.1.3",
"private": true,
"type": "module",
"scripts": {
@@ -119,27 +119,41 @@ describe("ChromiumCdp", () => {
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 () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(3);
const cursor = cdp.dialogCursor(3);
onEvent.fire(
{ tabId: 3 },
"Page.javascriptDialogOpening",
{
type: "alert",
message: "hello",
url: "https://example.com/",
hasBrowserHandler: false,
},
);
onEvent.fire({ tabId: 3 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "hello",
url: "https://example.com/",
hasBrowserHandler: false,
});
await Promise.resolve();
expect(api.sendCommand).toHaveBeenCalledWith(
{ tabId: 3 },
"Page.handleJavaScriptDialog",
{ accept: true },
);
expect(api.sendCommand).toHaveBeenCalledWith({ tabId: 3 }, "Page.handleJavaScriptDialog", {
accept: true,
});
const dialogs = cdp.dialogsSince(3, cursor);
expect(dialogs).toHaveLength(1);
expect(dialogs[0]).toMatchObject({
@@ -170,11 +184,11 @@ describe("ChromiumCdp", () => {
await cdp.ensureAttached(5);
const pending = cdp.send(5, "Runtime.evaluate", { expression: "1+1" });
await Promise.resolve();
onEvent.fire(
{ tabId: 5 },
"Page.javascriptDialogOpening",
{ type: "alert", message: "blocked", url: "https://example.com/" },
);
onEvent.fire({ tabId: 5 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "blocked",
url: "https://example.com/",
});
releaseEvaluate();
await expect(pending).resolves.toEqual({ result: { value: 2 } });
});
@@ -183,17 +197,145 @@ describe("ChromiumCdp", () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(11);
onEvent.fire(
{ tabId: 11 },
"Page.javascriptDialogOpening",
{ type: "alert", message: "x", url: "https://example.com/" },
);
onEvent.fire({ tabId: 11 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "x",
url: "https://example.com/",
});
await Promise.resolve();
expect(cdp.dialogsSince(11, 0)).toHaveLength(1);
await cdp.detach(11);
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 () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -20,7 +20,14 @@
// record the payload for tool results, and auto-accept so automation
// 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
@@ -77,6 +84,9 @@ export type DialogCursor = number;
const MAX_DIALOG_BUFFER = 32;
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 {
type: JavaScriptDialogType;
@@ -86,6 +96,8 @@ interface ParsedDialogOpening {
hasBrowserHandler?: boolean;
}
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
/**
* Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`.
@@ -97,13 +109,18 @@ export class ChromiumCdp {
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
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 dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
this.bindConsoleHandler();
}
/** Attach to `tabId` if we haven't already in this driver. */
@@ -117,6 +134,7 @@ export class ChromiumCdp {
const attach = (async () => {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId);
this.attachedTabs.add(tabId);
})()
.catch((err) => {
@@ -159,12 +177,52 @@ export class ChromiumCdp {
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. */
async detach(tabId: number): Promise<void> {
this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId);
this.clearDialogState(tabId);
this.clearConsoleState(tabId);
try {
await this.api.detach({ tabId });
} catch (err) {
@@ -204,6 +262,9 @@ export class ChromiumCdp {
this.attachedTabs.clear();
this.dialogBuffers.clear();
this.dialogSequences.clear();
this.consoleBuffers.clear();
this.consoleSequences.clear();
this.consoleDomainsAttemptedTabs.clear();
await Promise.all(
tabs.map(async (tabId) => {
try {
@@ -219,6 +280,18 @@ export class ChromiumCdp {
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 {
if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
@@ -272,6 +345,47 @@ export class ChromiumCdp {
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 {
if (this.detachSubscription) return;
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
@@ -280,6 +394,7 @@ export class ChromiumCdp {
this.attachInFlight.delete(source.tabId);
this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId);
}
};
this.api.onDetach.addListener(listener);
@@ -294,6 +409,8 @@ export class ChromiumCdp {
this.detachSubscription = null;
this.dialogSubscription?.dispose();
this.dialogSubscription = null;
this.consoleSubscription?.dispose();
this.consoleSubscription = null;
}
/** 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]`;
}
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 {
if (err instanceof Error) return err;
if (typeof err === "string") return new Error(err);
@@ -510,6 +510,48 @@ describe("requestBorrowConfirmation", () => {
expect(cancelMessage.type).toBe("borrow-cancel");
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", () => {
@@ -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 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";
function fakeTransport() {
@@ -36,6 +41,10 @@ function makeRequest(method: string, params: unknown): RequestFrame {
}
describe("ToolDispatcher", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("routes tool.session_start to the SessionManager and replies with the window id", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
@@ -78,6 +87,55 @@ describe("ToolDispatcher", () => {
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 () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
@@ -613,6 +613,65 @@ describe("handleTabReturn", () => {
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 () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
@@ -383,17 +383,25 @@ export async function requestBorrowConfirmation(
const isAgentWindowId = deps.isAgentWindowId ?? (() => false);
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 {
tab = await tabsApi.get(tabId);
const tab = await tabsApi.get(tabId);
tabTitle = tab.title ?? tab.url ?? String(tabId);
} 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,
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);
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 { Transport } from "@/transport/transport";
import type {
ClickParams,
ConsoleParams,
EvaluateParams,
FillParams,
GetHtmlParams,
@@ -11,17 +13,19 @@ import type {
PressParams,
ProtocolFrame,
ReloadParams,
RequestHelpParams,
RequestFrame,
RequestHelpParams,
ResponseFrame,
RpcError,
SelectParams,
ScreenshotParams,
SelectParams,
SnapshotParams,
WaitForNavigationParams,
} from "@/transport/types";
import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleEvaluate } from "./evaluate";
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handlePress, handleSelect } from "./interaction";
import {
handleNavigate,
@@ -42,16 +46,15 @@ import {
type SessionStartParams,
type SessionStopParams,
} from "./session";
import { OVERLAY_AUTOMATION_BYPASS } from "@/lib/overlay-bridge";
import { chromeTabsApi } from "./shared";
import {
type BorrowConfirmationApprover,
handleTabBorrow,
handleTabClose,
handleTabCreate,
handleTabList,
handleTabReturn,
handleTabSelect,
type BorrowConfirmationApprover,
type TabBorrowParams,
type TabCloseParams,
type TabCreateParams,
@@ -59,7 +62,6 @@ import {
type TabReturnParams,
type TabSelectParams,
} from "./tabs";
import { defaultWatchTabNavigation, handleRequestHelp } from "./human-loop";
import { handleWaitForNavigation } from "./waits";
export interface DispatcherDeps {
@@ -277,6 +279,12 @@ export class ToolDispatcher {
? { cdp: this.cdp, tabsApi: chromeTabsCaptureApi, captureApi: chromeTabsCaptureApi }
: undefined,
);
case "tool.console":
return handleConsole(
this.sessions,
req.params as ConsoleParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.snapshot":
return handleSnapshot(
this.sessions,
+7 -1
View File
@@ -113,7 +113,13 @@ export async function handleSessionStop(
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
for (const tabId of borrowedIds) {
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) {
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
returnFailures?.push({
+11 -4
View File
@@ -6,11 +6,10 @@
// exactly the same sandbox + visibility rules as the M6 observation
// 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 { 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";
/**
@@ -46,6 +45,14 @@ export interface CdpRunner {
};
dialogCursor?(tabId: number): DialogCursor;
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;
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
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 {
@@ -292,6 +302,10 @@ function getWindowsApi(deps: TabManagementDeps): ChromeWindowsApi {
return deps.windows ?? chromeWindowsApi;
}
function getIsAgentWindowId(deps: TabManagementDeps): (windowId: number) => boolean {
return deps.isAgentWindowId ?? (() => false);
}
function getAgentOverlayResetApi(deps: TabManagementDeps): AgentOverlayResetApi {
return deps.agentOverlayReset ?? chromeAgentOverlayResetApi;
}
@@ -818,12 +832,20 @@ function describeError(err: unknown): string {
async function chooseFallbackWindow(
ctx: SessionContext,
windowsApi: ChromeWindowsApi,
isAgentWindowId: (windowId: number) => boolean,
): Promise<{ windowId: number; index: number } | RpcError> {
let lastFocusedError: unknown;
try {
const last = await windowsApi.getLastFocused({ windowTypes: ["normal"] });
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 };
}
} catch (err) {
@@ -888,6 +910,7 @@ export async function returnBorrowedTab(
}
const tabsApi = getTabsApi(deps);
const windowsApi = getWindowsApi(deps);
const isAgentWindowId = getIsAgentWindowId(deps);
let targetWindowId = entry.originalWindowId;
let targetIndex = entry.originalIndex;
@@ -903,7 +926,7 @@ export async function returnBorrowedTab(
}
if (!originalAlive) {
fallback = true;
const target = await chooseFallbackWindow(ctx, windowsApi);
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
if ("code" in target) return target;
targetWindowId = target.windowId;
targetIndex = target.index;
@@ -925,7 +948,7 @@ export async function returnBorrowedTab(
};
} catch (err) {
if (!fallback) {
const target = await chooseFallbackWindow(ctx, windowsApi);
const target = await chooseFallbackWindow(ctx, windowsApi, isAgentWindowId);
if ("code" in target) {
return {
code: "cdp_failed",
@@ -979,7 +1002,11 @@ export async function handleTabReturn(
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;
ctx.borrowedTabs.delete(params.tab_id);
const result: TabReturnResult = {
+38
View File
@@ -134,6 +134,44 @@ export interface JavaScriptDialogInfo {
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 interface TabInfo {
+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(),
}
}
+5
View File
@@ -5,6 +5,7 @@ use std::time::Duration;
pub mod browser_wait;
pub mod browsers;
pub mod business_rpc;
pub mod console;
pub mod daemon;
pub mod dialogs;
pub mod doctor;
@@ -27,6 +28,7 @@ pub mod waits;
use clap::{Args, Parser, Subcommand};
use crate::cli::console::ConsoleArgs;
use crate::cli::daemon::DaemonCmd;
use crate::cli::evaluate::EvaluateArgs;
use crate::cli::get_html::GetHtmlArgs;
@@ -110,6 +112,9 @@ pub enum Command {
/// Produce an indented aria-snapshot with @eN refs.
Snapshot(SnapshotArgs),
/// Read buffered console/log/exception messages.
Console(ConsoleArgs),
/// Dump raw HTML for a tab or a snapshot ref.
#[command(name = "get-html")]
GetHtml(GetHtmlArgs),
+1
View File
@@ -228,6 +228,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolTabBorrow
| Method::ToolTabReturn
| Method::ToolScreenshot
| Method::ToolConsole
| Method::ToolSnapshot
| Method::ToolGetHtml
| Method::ToolNavigate
+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 {
bsk_protocol::EventKind::SessionWindowClosed => {
handle_session_window_closed(state, &ev.payload);
handle_session_window_closed(state, &client.id, &ev.payload);
}
bsk_protocol::EventKind::SessionUserInterrupt => {
handle_session_user_interrupt(state, &ev.payload);
handle_session_user_interrupt(state, &client.id, &ev.payload);
}
other => {
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 {
warn!("session.window_closed event missing session_id");
return;
@@ -448,6 +452,24 @@ fn handle_session_window_closed(state: &Arc<DaemonState>, payload: &serde_json::
})
.unwrap_or_default();
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(
&state.sessions,
&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
/// the extension's dispatcher can abort its `AbortController` —
/// 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 {
warn!("session.user_interrupt event missing session_id");
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);
info!(session = %sid, count = snapshots.len(), "user-interrupt: cancelled inflight tools");
@@ -547,6 +587,7 @@ mod tests {
#[cfg(test)]
mod session_user_interrupt_tests {
use super::*;
use crate::daemon::browsers::BrowserId;
use crate::daemon::inflight::CancelReason;
use crate::daemon::sessions::SessionId;
@@ -587,7 +628,13 @@ mod session_user_interrupt_tests {
.register("b".into(), sid_b.clone())
.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!(g_b.entry().cancel_reason().is_none());
@@ -599,7 +646,11 @@ mod session_user_interrupt_tests {
let sid_a = SessionId("A".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!(
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> {
use crate::daemon::start::DaemonConfig;
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::Screenshot(args) => cli::screenshot::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::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
+37
View File
@@ -67,6 +67,43 @@ fn parses_top_level_status_and_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]
fn parses_install_skill_subcommand() {
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 = Arc::new(tokio::sync::Mutex::new(ws_sink));
// Fake extension: replies to session_start AND to snapshot
// (snapshot must succeed transparently). Records click count
// Fake extension: replies to session_start, console, and snapshot
// (read-only tools must succeed transparently). Records click count
// — must remain 0 because the daemon should reject the click
// without forwarding.
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(),
),
Method::ToolConsole => ResponseBody::Ok(json!({
"tab_id": 7,
"entries": [],
"next_since": 0,
"truncated": false
})),
Method::ToolSnapshot => ResponseBody::Ok(json!({"ok": true})),
Method::ToolClick => {
observed_click_count_clone
@@ -511,8 +517,24 @@ async fn read_only_tool_passes_through_without_consuming_interrupt_marker() {
let state = handle.state();
wait_for_session_interrupt_pending(&state, &start.session_id).await;
// Read-only snapshot must succeed AND must not consume the
// marker.
// Read-only console must succeed AND must not consume the 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
.call::<_, serde_json::Value>(
"snap-after-interrupt",
+60 -3
View File
@@ -10,9 +10,9 @@ use bsk::daemon::{self, DaemonConfig};
use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{
GetHtmlParams, GetHtmlResult, ScreenshotParams, ScreenshotResult, SessionStartParams,
SessionStartResult, SnapshotParams, SnapshotResult, TabInfo, TabListParams, TabListResult,
TabScope,
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
@@ -311,6 +311,63 @@ async fn screenshot_forwards_ref_to_extension() {
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)]
async fn snapshot_returns_text_and_ref_count() {
let (handle, sock) = spawn_daemon().await;
@@ -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"
]
}
}
}
@@ -82,6 +82,10 @@ fn main() {
dump!(GetHtmlResult, "tool_get_html_result");
dump!(ScreenshotParams, "tool_screenshot_params");
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!(EvaluateResult, "tool_evaluate_result");
+11
View File
@@ -62,6 +62,8 @@ pub enum Method {
ToolGetHtml,
#[serde(rename = "tool.screenshot")]
ToolScreenshot,
#[serde(rename = "tool.console")]
ToolConsole,
#[serde(rename = "tool.evaluate")]
ToolEvaluate,
#[serde(rename = "tool.wait_for_navigation")]
@@ -124,6 +126,7 @@ impl Method {
| Method::ToolSnapshot
| Method::ToolGetHtml
| Method::ToolScreenshot
| Method::ToolConsole
| Method::ToolWaitForNavigation
| Method::ToolWaitMs
| Method::ToolRequestHelp => false,
@@ -159,6 +162,13 @@ mod tests {
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]
fn cancel_params_and_result_round_trip() {
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::ToolGetHtml.is_mutating());
assert!(!Method::ToolScreenshot.is_mutating());
assert!(!Method::ToolConsole.is_mutating());
assert!(!Method::ToolWaitForNavigation.is_mutating());
assert!(!Method::ToolWaitMs.is_mutating());
}
+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).
pub mod console;
pub mod dialog;
pub mod human_loop;
pub mod interaction;
@@ -10,6 +11,7 @@ pub mod session;
pub mod tabs;
pub mod waits;
pub use console::*;
pub use dialog::*;
pub use human_loop::*;
pub use interaction::*;