mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
Compare commits
17
Commits
ext-v0.1.2
...
ext-v0.1.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eda8b22c43 | ||
|
|
c987e42da9 | ||
|
|
7f5093d97a | ||
|
|
ce29ddaa53 | ||
|
|
2e7998477d | ||
|
|
c9e00bb641 | ||
|
|
ad2a12e57e | ||
|
|
61096110c0 | ||
|
|
8e8dd3840b | ||
|
|
9c229b2489 | ||
|
|
26e5f98c16 | ||
|
|
dc8a6d38b3 | ||
|
|
ab6aa16dfc | ||
|
|
45c85fa64a | ||
|
|
b8c4be4362 | ||
|
|
335391c2bf | ||
|
|
3cf81d509b |
+17
-4
@@ -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
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
|
|
||||||
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
|
需要 Agent 操作你已打开的标签页?必须显式借用该标签,任务结束后归还,其余浏览器窗口不受影响。
|
||||||
|
|
||||||
|
https://github.com/user-attachments/assets/db782c92-b1d4-4aae-a255-039675937a90
|
||||||
|
|
||||||
## BrowserSkill 的优势
|
## BrowserSkill 的优势
|
||||||
|
|
||||||
- **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。
|
- **复用真实登录态**:Agent 可以操作你已经登录的网站,不需要额外测试账号。
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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, ¶ms, timeout)
|
||||||
Method::SystemStatus,
|
|
||||||
&StatusParams::default(),
|
|
||||||
Duration::from_secs(2),
|
|
||||||
)
|
|
||||||
.await?;
|
.await?;
|
||||||
outcome.map_err(CliError::from_rpc)
|
outcome.map_err(CliError::from_rpc)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -----
|
||||||
|
|||||||
@@ -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)))
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"]);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|||||||
@@ -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::*;
|
||||||
|
|||||||
@@ -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());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(¶ms).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
Reference in New Issue
Block a user