Compare commits

..
Author SHA1 Message Date
polardayandCopilot Autofix powered by AI 9a5d45d156 Fix comment formatting in useDaemonPort function
修复注释不准确

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-03 19:58:49 +08:00
polardayandCopilot Autofix powered by AI 11cd12c9cd Fix regex for validating daemon port input
如果有字符就报错,不直接转为数字

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-03 19:57:12 +08:00
“haonan”andCursor 873971f4cb fix(extension): enlarge the popup daemon port input vertically
The field was too short to read; grow its height without widening the card.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 19:18:03 +08:00
“haonan”andCursor a9c7444139 feat(extension): make the local daemon port configurable in the popup
The extension was hardwired to 52800; persist a custom loopback port and reconnect when it changes so users can match a non-default daemon.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 18:56:08 +08:00
115 changed files with 1575 additions and 7092 deletions
-21
View File
@@ -37,27 +37,6 @@ jobs:
- name: Run Rust tests
run: cargo test --workspace --locked
windows-daemon:
name: Windows daemon and update regression tests
runs-on: windows-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Run Windows named-pipe tests
run: cargo test -p bsk --lib --locked daemon::ipc::windows::tests
- name: Run Windows process liveness tests
run: cargo test -p bsk --lib --locked daemon::lockfile::tests
- name: Run Windows update tests
run: cargo test -p bsk --lib --locked cli::update::tests
- name: Run Windows update process tests
run: cargo test -p bsk --test windows_update --locked
frontend:
name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest
-92
View File
@@ -1,92 +0,0 @@
# Changelog
All notable changes to BrowserSkill will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
Starting from 0.2.0, CLI / Extension / DSH Plugin share the same version number.
## [Unreleased]
## [0.2.0] - 2026-09-02
### Added
- File transfer: upload and download support across CLI, Extension, and DSH Plugin
- File transfer: drag-and-drop upload (`drop-to-upload`)
- VOM semantic graph, name enrichment, and hover perception modules
- VOM hover probing (opt-in via `observe` parameter)
- DSH Plugin: browser tool parity with CLI commands
- Edge Add-ons automated publishing in CI
- Protocol upgrade reminder when CLI / Extension protocol versions differ
- Browser evaluation harness (`evals/browser/`)
- Unified release script (`scripts/release.mjs`)
### Changed
- **Version scheme**: all three components now share the same semver
- VOM rendering algorithm optimizations
- Leaner SKILL.md agent instructions
- DSH Plugin: simplified browser commands
- Borrow confirmation UX — proactive focus and longer timeout
- PiP window now has a close button
### Fixed
- Screenshot media type detection (was hard-coded to `image/png`)
- DSH Plugin session lifecycle stability
- DSH Plugin Cordis package ID mismatch
- Observation thumbnail media type sniffing
- Upload/download race conditions and layout bypass issues
- VOM repeated name and safety policy issues
---
*Previous releases used independent version numbers per component.*
## CLI 0.1.11 / Extension 0.1.7 / DSH Plugin 0.1.2 — 2026-08-26 ~ 2026-08-29
### Added
- DSH Plugin sidebar integration, session lifecycle, and archive cleanup
- Recorder iframe and OOPIF support
### Changed
- VOM functional refactor
### Fixed
- Recorder safety policy and bug fixes
## CLI 0.1.10 / Extension 0.1.6 — 2026-08-08
### Added
- VOM observation recording and settled-state detection
- Record overlay timer
### Fixed
- Browser keepalive disconnect handling
## CLI 0.1.9 / Extension 0.1.5 — 2026-07-29
### Added
- CLI auto-update mechanism
- Trace v3 protocol and recorder
### Fixed
- MV3 keepalive disconnect
## CLI 0.1.8 / Extension 0.1.4 — 2026-07-22
### Added
- More browser interaction actions
### Fixed
- Windows named-pipe hash-only path issue
## CLI 0.1.7 / Extension 0.1.3 — 2026-07-07
Initial public release pair.
## CLI 0.1.6 — 2026-06-30
### Fixed
- Minor CLI fixes
## CLI 0.1.5 / Extension 0.1.2 — 2026-06-22
First tagged releases.
Generated
+2 -2
View File
@@ -134,7 +134,7 @@ dependencies = [
[[package]]
name = "bsk"
version = "0.2.0"
version = "0.1.11"
dependencies = [
"anyhow",
"base64",
@@ -169,7 +169,7 @@ dependencies = [
[[package]]
name = "bsk-protocol"
version = "0.2.0"
version = "0.1.11"
dependencies = [
"schemars",
"semver",
+2 -2
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.2.0"
version = "0.1.11"
edition = "2024"
rust-version = "1.85"
license = "MIT"
@@ -43,7 +43,7 @@ rand = "0.8"
uuid = { version = "1", features = ["v4", "serde"] }
# Workspace crates (version required for `cargo publish` of dependents)
bsk-protocol = { version = "0.2.0", path = "crates/bsk-protocol" }
bsk-protocol = { version = "0.1.9", path = "crates/bsk-protocol" }
[profile.release]
strip = "symbols"
+2 -2
View File
@@ -57,7 +57,7 @@ The Extension requests the following Chrome permissions. Each is used solely for
## 6. Where Data Goes
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to `ws://127.0.0.1:52800` (loopback only). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to the local bsk daemon on `127.0.0.1` (loopback only; default port **52800**, configurable in the extension popup). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
## 7. Data Retention
@@ -80,7 +80,7 @@ The Extension is a developer tool and is not directed at children under 13. It d
## 10. Security
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to `127.0.0.1:52800` could send commands to the Extension. Run BrowserSkill only on machines you control.
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to the configured loopback port could send commands to the Extension. Run BrowserSkill only on machines you control.
## 11. Open Source and Auditability
+2 -2
View File
@@ -54,7 +54,7 @@ BrowserSkill **不会**:
## 6. 数据流向
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与 `ws://127.0.0.1:52800`(仅回环地址)的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与本地 bsk 守护进程在 `127.0.0.1`(仅回环地址;默认端口 **52800**,可在扩展弹窗中配置)上的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
## 7. 数据保留
@@ -76,7 +76,7 @@ BrowserSkill **不会**:
## 10. 安全性
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到 `127.0.0.1:52800` 的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到所配置回环端口的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
## 11. 开源与可审计性
@@ -1,81 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>BrowserSkill file-transfer fixture</title>
<style>
body { font: 16px system-ui; max-width: 760px; margin: 48px auto; }
.actions { display: flex; gap: 12px; align-items: center; margin: 24px 0; }
button, a { padding: 10px 16px; border: 1px solid #777; border-radius: 8px; }
a[aria-disabled="true"] { opacity: .45; pointer-events: none; }
#drop { padding: 36px; border: 3px dashed #777; border-radius: 12px; text-align: center; }
#drop.dragging { border-color: #2563eb; background: #eff6ff; }
img { display: block; max-width: 480px; max-height: 360px; margin-top: 20px; }
</style>
</head>
<body>
<h1>Upload then download the same image</h1>
<p id="status">No image selected</p>
<div class="actions">
<button id="upload" type="button">Upload through file input</button>
<input id="file" type="file" accept="image/png" hidden />
<a id="download" aria-disabled="true">Download uploaded image</a>
</div>
<div id="drop" role="button" tabindex="0">Drop an image here</div>
<img id="preview" alt="Uploaded image preview" hidden />
<script>
const input = document.querySelector('#file');
const upload = document.querySelector('#upload');
const drop = document.querySelector('#drop');
const download = document.querySelector('#download');
const preview = document.querySelector('#preview');
const status = document.querySelector('#status');
let objectUrl;
window.__bskTransferFixture = { events: [], result: null };
function accept(file, mechanism) {
if (!file) return;
if (objectUrl) URL.revokeObjectURL(objectUrl);
objectUrl = URL.createObjectURL(file);
preview.src = objectUrl;
preview.hidden = false;
download.href = objectUrl;
download.download = file.name;
download.removeAttribute('aria-disabled');
status.textContent = `Ready via ${mechanism}: ${file.name} (${file.size} bytes)`;
window.__bskTransferFixture.result = {
mechanism,
name: file.name,
size: file.size,
type: file.type,
hasPathProperty: 'path' in file,
};
}
upload.addEventListener('click', () => input.click());
input.addEventListener('change', () => accept(input.files[0], 'input'));
for (const type of ['dragenter', 'dragover']) {
drop.addEventListener(type, event => {
const types = [...event.dataTransfer.types];
window.__bskTransferFixture.events.push({ type, types });
if (!types.includes('Files')) return;
event.preventDefault();
drop.classList.add('dragging');
});
}
drop.addEventListener('dragleave', () => drop.classList.remove('dragging'));
drop.addEventListener('drop', event => {
window.__bskTransferFixture.events.push({
type: 'drop',
types: [...event.dataTransfer.types],
fileCount: event.dataTransfer.files.length,
});
event.preventDefault();
drop.classList.remove('dragging');
accept(event.dataTransfer.files[0], 'drop');
});
</script>
</body>
</html>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.2.0",
"version": "0.1.7",
"private": true,
"type": "module",
"scripts": {
@@ -254,69 +254,6 @@ describe("ChromiumCdp", () => {
expect(cdp.isAttached(7)).toBe(false);
});
it.each([
false,
true,
])("waits for detach before reconnecting a returned tab (detach rejects: %s)", async (rejectDetach) => {
const { api } = fakeApi();
const attached = new Set<number>();
let finishDetach!: () => void;
vi.mocked(api.attach).mockImplementation(async ({ tabId }) => {
if (attached.has(tabId!)) throw new Error("Another debugger is already attached");
attached.add(tabId!);
});
vi.mocked(api.detach).mockImplementation(async ({ tabId }) => {
attached.delete(tabId!);
});
vi.mocked(api.detach).mockImplementationOnce(
({ tabId }) =>
new Promise<void>((resolve, reject) => {
finishDetach = () => {
attached.delete(tabId!);
if (rejectDetach) reject(new Error("tab already detached"));
else resolve();
};
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 7);
await cdp.ensureAttached(7);
const returning = cdp.releaseSessionTab("aa11", 7);
await vi.waitFor(() => expect(api.detach).toHaveBeenCalledOnce());
let duplicateDetachFinished = false;
const duplicateDetach = cdp.detach(7).then(() => {
duplicateDetachFinished = true;
});
cdp.trackSessionTab("bb22", 7);
// Register rejection handlers immediately; the pre-fix driver rejects
// both commands while the browser still has the previous attachment.
const pending = Promise.allSettled([
cdp.send(7, "Runtime.evaluate", { expression: "document.title" }),
cdp.send(7, "DOM.getDocument"),
]);
await cdp.send(8, "DOM.getDocument");
const finishedEarly = duplicateDetachFinished;
finishDetach();
await Promise.all([returning, duplicateDetach]);
expect(await pending).toEqual([
{ status: "fulfilled", value: { ok: true } },
{ status: "fulfilled", value: { ok: true } },
]);
expect(finishedEarly).toBe(false);
expect(vi.mocked(api.attach).mock.calls.filter(([target]) => target.tabId === 7)).toHaveLength(
2,
);
expect(cdp.isAttached(7)).toBe(true);
expect(cdp.isAttached(8)).toBe(true);
await cdp.detachSession("bb22");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(cdp.isAttached(7)).toBe(false);
expect(attached.has(7)).toBe(false);
expect(cdp.isAttached(8)).toBe(true);
});
it("detachAll() iterates every cached tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -714,155 +651,6 @@ describe("ChromiumCdp", () => {
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
it("releases only the returning session's claim on a tab", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
await cdp.ensureAttached(2);
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
cdp.trackSessionTab("aa11", 2);
await cdp.releaseSessionTab("unknown", 1);
await cdp.releaseSessionTab("aa11", 1);
expect(api.detach).not.toHaveBeenCalled();
await cdp.releaseSessionTab("bb22", 1);
await cdp.releaseSessionTab("bb22", 1);
expect(api.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 1 });
expect(cdp.isAttached(2)).toBe(true);
await cdp.detachSession("aa11");
expect(api.detach).toHaveBeenCalledTimes(2);
expect(api.detach).toHaveBeenLastCalledWith({ tabId: 2 });
});
it.each([
false,
true,
])("releases a tab with an attachment in flight (new owner: %s)", async (newOwner) => {
const { api } = fakeApi();
let finishAttach!: () => void;
vi.mocked(api.attach).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAttach = resolve;
}),
);
const cdp = new ChromiumCdp(api);
cdp.trackSessionTab("aa11", 1);
const attaching = cdp.ensureAttached(1);
const releasing = cdp.releaseSessionTab("aa11", 1);
if (newOwner) cdp.trackSessionTab("bb22", 1);
finishAttach();
await Promise.all([attaching, releasing]);
expect(cdp.isAttached(1)).toBe(newOwner);
expect(api.detach).toHaveBeenCalledTimes(newOwner ? 0 : 1);
});
it("stops accepting dialogs on a returned tab even when another session keeps CDP attached", async () => {
const { api, onEvent } = fakeApi();
let controlled = true;
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog: () => controlled });
cdp.trackSessionTab("aa11", 1);
cdp.trackSessionTab("bb22", 1);
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "before return",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(1));
vi.mocked(api.sendCommand).mockClear();
controlled = false;
await cdp.releaseSessionTab("aa11", 1);
expect(cdp.isAttached(1)).toBe(true);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "confirm",
message: "after return",
});
await Promise.resolve();
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toHaveLength(1);
controlled = true;
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", {
type: "alert",
message: "borrowed again",
});
await vi.waitFor(() => expect(cdp.dialogsSince(1, 0)).toHaveLength(2));
expect(api.sendCommand).toHaveBeenCalledExactlyOnceWith(
{ tabId: 1 },
"Page.handleJavaScriptDialog",
{ accept: true },
);
});
it("ignores dialog events after detach, including a pending eligibility check", async () => {
const { api, onEvent } = fakeApi();
let resolveEligibility!: (value: boolean) => void;
const shouldAutoAcceptDialog = vi.fn(
() =>
new Promise<boolean>((resolve) => {
resolveEligibility = resolve;
}),
);
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
resolveEligibility(true);
await Promise.resolve();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
expect(shouldAutoAcceptDialog).toHaveBeenCalledOnce();
expect(api.sendCommand).not.toHaveBeenCalledWith(
expect.anything(),
"Page.handleJavaScriptDialog",
expect.anything(),
);
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not accept dialogs when the tab's current scope cannot be determined", async () => {
const { api, onEvent } = fakeApi();
const shouldAutoAcceptDialog = vi.fn(async () => {
throw new Error("tab is gone");
});
const cdp = new ChromiumCdp(api, { shouldAutoAcceptDialog });
await cdp.ensureAttached(1);
vi.mocked(api.sendCommand).mockClear();
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "confirm" });
await Promise.resolve();
expect(shouldAutoAcceptDialog).toHaveBeenCalledWith(1);
expect(api.sendCommand).not.toHaveBeenCalled();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
});
it("does not restore dialog state when an acceptance completes after detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(1);
let finishAccept!: () => void;
vi.mocked(api.sendCommand).mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishAccept = resolve;
}),
);
onEvent.fire({ tabId: 1 }, "Page.javascriptDialogOpening", { type: "alert" });
await cdp.detach(1);
finishAccept();
await Promise.resolve();
expect(cdp.dialogsSince(1, 0)).toEqual([]);
expect(cdp.dialogCursor(1)).toBe(0);
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -165,7 +165,6 @@ export class ChromiumCdp {
private readonly api: CdpDebuggerApi;
private readonly attachedTabs = new Set<number>();
private readonly attachInFlight = new Map<number, Promise<void>>();
private readonly detachInFlight = new Map<number, Promise<void>>();
private readonly tabOwners = new Map<number, Set<string>>();
private readonly dialogBuffers = new Map<number, JavaScriptDialogInfo[]>();
private readonly dialogSequences = new Map<number, number>();
@@ -183,13 +182,7 @@ export class ChromiumCdp {
private networkSubscription: { dispose(): void } | null = null;
private frameTargetSubscription: { dispose(): void } | null = null;
constructor(
api: CdpDebuggerApi = chromeDebuggerApi,
private readonly options: {
/** CDP observation alone does not authorize dismissing user dialogs. */
shouldAutoAcceptDialog?: (tabId: number) => boolean | Promise<boolean>;
} = {},
) {
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
@@ -200,10 +193,6 @@ export class ChromiumCdp {
/** Attach to `tabId` if we haven't already in this driver. */
async ensureAttached(tabId: number): Promise<void> {
// Returning a tab clears the cache before Chrome finishes detaching.
// New observers must wait before opening the next connection to that tab.
const detaching = this.detachInFlight.get(tabId);
if (detaching) await detaching;
if (this.attachedTabs.has(tabId)) return;
const existing = this.attachInFlight.get(tabId);
if (existing) {
@@ -436,11 +425,6 @@ export class ChromiumCdp {
/** Detach if attached; never throws. */
async detach(tabId: number): Promise<void> {
const existing = this.detachInFlight.get(tabId);
if (existing) {
await existing;
return;
}
this.attachInFlight.delete(tabId);
if (!this.attachedTabs.has(tabId)) return;
this.attachedTabs.delete(tabId);
@@ -448,19 +432,13 @@ export class ChromiumCdp {
this.clearConsoleState(tabId);
this.clearNetworkState(tabId);
this.clearFrameState(tabId);
const detach = (async () => {
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
})().finally(() => {
this.detachInFlight.delete(tabId);
});
this.detachInFlight.set(tabId, detach);
await detach;
try {
await this.api.detach({ tabId });
} catch (err) {
// Tab may already be gone — Chrome auto-detaches on close. Log
// at debug so production builds aren't noisy.
console.debug("[bsk cdp] detach failed (likely tab already closed)", err);
}
}
/** True iff `ensureAttached(tabId)` has succeeded since the last detach. */
@@ -475,17 +453,6 @@ export class ChromiumCdp {
this.tabOwners.set(tabId, owners);
}
/** Release one session's claim, preserving attachments still used by another. */
async releaseSessionTab(sessionId: string, tabId: number): Promise<void> {
const owners = this.tabOwners.get(tabId);
if (!owners?.delete(sessionId) || owners.size > 0) return;
this.tabOwners.delete(tabId);
// An observation may still be attaching when the tab is returned. Wait
// for it so detach cannot miss the attachment or remove a new owner's claim.
await this.attachInFlight.get(tabId)?.catch(() => {});
if (!this.tabOwners.has(tabId)) await this.detach(tabId);
}
/** Subscribe to all CDP events. Returned disposable removes the listener. */
onEvent(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void;
@@ -672,23 +639,13 @@ export class ChromiumCdp {
}
private async onJavaScriptDialogOpening(tabId: number, params: unknown): Promise<void> {
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const parsed = parseDialogOpeningParams(params);
try {
if (
this.options.shouldAutoAcceptDialog &&
!(await this.options.shouldAutoAcceptDialog(tabId))
) {
return;
}
// The tab may have been returned while its current scope was checked.
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const handleParams: { accept: boolean; promptText?: string } = { accept: true };
if (parsed.type === "prompt") {
handleParams.promptText = parsed.defaultPrompt ?? "";
}
await this.api.sendCommand({ tabId }, "Page.handleJavaScriptDialog", handleParams);
if (!this.attachedTabs.has(tabId) && !this.attachInFlight.has(tabId)) return;
const sequence = (this.dialogSequences.get(tabId) ?? 0) + 1;
this.dialogSequences.set(tabId, sequence);
this.appendDialog(tabId, {
@@ -878,9 +835,15 @@ export class ChromiumCdp {
/** Detach tabs only when no other live session has claimed them. */
async detachSession(sessionId: string): Promise<void> {
await Promise.all(
Array.from(this.tabOwners.keys(), (tabId) => this.releaseSessionTab(sessionId, tabId)),
);
const tabsToDetach: number[] = [];
for (const [tabId, owners] of this.tabOwners) {
owners.delete(sessionId);
if (owners.size === 0) {
this.tabOwners.delete(tabId);
tabsToDetach.push(tabId);
}
}
await Promise.all(tabsToDetach.map((tabId) => this.detach(tabId)));
}
}
@@ -45,32 +45,10 @@ function portHarness() {
describe("RecordFrameAgent", () => {
afterEach(() => {
vi.unstubAllGlobals();
document.documentElement.removeAttribute(RECORD_DOCUMENT_ATTRIBUTE);
document.body.replaceChildren();
});
it("starts when crypto.randomUUID is unavailable", async () => {
const harness = portHarness();
const getRandomValues = vi.fn((bytes: Uint8Array) => {
bytes.fill(0x11);
return bytes;
});
vi.stubGlobal("crypto", { getRandomValues });
vi.stubGlobal("chrome", {
runtime: {
connect: vi.fn(() => harness.port),
sendMessage: vi.fn(),
},
});
const agent = new RecordFrameAgent();
await expect(
agent.start({ type: RECORD_FRAME_START, requestId: "rec-http", startedAtMs: 10 }),
).resolves.toEqual({ ok: true });
expect(getRandomValues).toHaveBeenCalled();
});
it("keeps a failed stop retryable and flushes the final dirty fill", async () => {
const harness = portHarness();
const sendMessage = vi
@@ -1,4 +1,3 @@
import { createRandomUuid } from "@/lib/random-uuid";
import {
isAcceptedRecordStepAck,
RECORD_STEP,
@@ -19,7 +18,7 @@ export class RecordStepDelivery {
constructor(
requestId: string,
send: SendRecordStepMessage = (message) => chrome.runtime.sendMessage(message),
producerId: string = createRandomUuid(),
producerId: string = crypto.randomUUID(),
) {
this.#requestId = requestId;
this.#producerId = producerId;
@@ -1,4 +1,3 @@
import { createRandomUuid } from "@/lib/random-uuid";
import {
markRecordingDocument,
type RecordingDocumentMarker,
@@ -68,7 +67,7 @@ export class RecordFrameAgent {
async #start(
message: RecordFrameStartMessage,
): Promise<{ ok: true } | { ok: false; error: string }> {
const producerId = createRandomUuid();
const producerId = crypto.randomUUID();
const root = await waitForDocumentElement();
if (this.#disposed) return { ok: false, error: "recording document is disposed" };
const marker = markRecordingDocument(producerId, root);
+34 -67
View File
@@ -4,7 +4,9 @@ import { ConnectionController } from "@/lib/connection-controller";
import { startHeartbeat } from "@/lib/heartbeat";
import {
getConnectionEnabled,
getDaemonPort,
setConnectionEnabled as persistConnectionEnabled,
STORAGE_KEYS,
setLabel,
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
@@ -25,7 +27,7 @@ import { recordFrameCoordinator } from "@/lib/recording/frame-coordinator";
import { attachSessionsLiveFlag } from "@/lib/sessions-live-flag";
import { createDisconnectCleanup } from "@/session-manager/disconnect-cleanup";
import { attachSessionEventHandler } from "@/session-manager/event-handler";
import { isAgentControlledTab, SessionManager } from "@/session-manager/manager";
import { SessionManager } from "@/session-manager/manager";
import {
attachBorrowNotificationButtonHandler,
attachBorrowNotificationClickHandler,
@@ -41,8 +43,7 @@ import {
attachRecordStepListener,
type RecordRuntimeDeps,
} from "@/tools/record";
import { chromeTabsApi } from "@/tools/shared";
import { chromeTabMutationApi } from "@/tools/tabs";
import { resolveDaemonWsUrl } from "@/transport/daemon-endpoint";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
@@ -51,16 +52,32 @@ export default defineBackground(() => {
const controller = new ConnectionController();
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
const sessions = new SessionManager();
const cdp = new ChromiumCdp(undefined, {
shouldAutoAcceptDialog: async (tabId) => {
const tab = await chrome.tabs.get(tabId);
return sessions.findByWindowId(tab.windowId) !== null;
},
});
const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();
async function applyDaemonPort(port: number): Promise<void> {
const url = resolveDaemonWsUrl(port);
if (!transport.setUrl(url)) return;
await transport.disconnect();
if (!controller.isConnectionEnabled) return;
try {
await transport.connect();
} catch (err) {
console.debug("[browser-skill] reconnect after port change failed", err);
}
}
if (typeof chrome !== "undefined" && chrome.storage?.onChanged) {
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.DAEMON_PORT];
if (!change || typeof change.newValue !== "number") return;
void applyDaemonPort(change.newValue);
});
}
function setControlMode(sessionId: string, mode: OverlayMode): void {
if (controlModes.get(sessionId) === mode) return;
controlModes.set(sessionId, mode);
@@ -87,24 +104,6 @@ export default defineBackground(() => {
};
}
/**
* Authoritative overlay state for a specific tab. Agent Window tabs are
* free by default; only tabs explicitly claimed through session startup,
* `tab_create`, or `tab_borrow` receive the control overlay.
*/
function overlayStateForTab(tabId?: number, windowId?: number): OverlayAgentStateMessage {
if (typeof tabId === "number" && typeof windowId === "number") {
const ctx = sessions.findByWindowId(windowId);
if (ctx && isAgentControlledTab(ctx, tabId)) return overlayStateForWindow(windowId);
}
return {
type: OVERLAY_AGENT_STATE,
sessionId: null,
mode: "hidden",
generation: overlayGeneration,
};
}
async function pushOverlayStateToTab(
tabId: number,
state: OverlayAgentStateMessage,
@@ -116,18 +115,13 @@ export default defineBackground(() => {
}
}
async function pushOverlayStateForTab(tabId: number, windowId?: number): Promise<void> {
const state = overlayStateForTab(tabId, windowId);
await pushOverlayStateToTab(tabId, state);
}
async function pushOverlayStateForWindow(windowId: number): Promise<void> {
const state = overlayStateForWindow(windowId);
const tabs = await chrome.tabs.query({ windowId });
await Promise.all(
tabs.map((tab) => {
if (typeof tab.id !== "number") return Promise.resolve();
return pushOverlayStateForTab(tab.id, windowId);
}),
tabs.map((tab) =>
typeof tab.id === "number" ? pushOverlayStateToTab(tab.id, state) : Promise.resolve(),
),
);
}
@@ -166,30 +160,13 @@ export default defineBackground(() => {
if (typeof tab.windowId !== "number") return;
pushOverlayStateForAgentWindow(tab.windowId);
});
// Newly observed tabs are free unless the agent's own creation path has
// already claimed their concrete id. This listener only renders that
// state; it never infers or mutates ownership from event ordering.
chrome.tabs.onCreated.addListener((tab) => {
if (typeof tab.windowId !== "number" || typeof tab.id !== "number") return;
if (!sessions.findByWindowId(tab.windowId)) return;
void pushOverlayStateForTab(tab.id, tab.windowId);
});
chrome.tabs.onRemoved.addListener((tabId, removeInfo) => {
sessions.forgetClosedTab(tabId, { isWindowClosing: removeInfo.isWindowClosing });
});
// Re-sync the storage.session flag on SW startup so a previous SW's
// stale `true` does not keep waking us on every page load until the
// first mutation (review M4/M5 round 3 m-R3-1).
void sessionsLive.refresh();
const cleanupAfterDisconnect = createDisconnectCleanup({
manager: sessions,
// Same contract as the dispatcher's `tool.session_stop`: without these
// deps the agent-tab cleanup and the window-release path are dead code.
sessionStopDeps: {
cdp,
tabManagement: { tabs: chromeTabMutationApi },
tabsQuery: chromeTabsApi,
},
sessionStopDeps: { cdp },
onSessionsChanged: () => {
void sessionsLive.syncFromManager();
},
@@ -223,9 +200,6 @@ export default defineBackground(() => {
recording: recordDeps,
onSessionsChanged: onOverlaySessionStateChanged,
onBrowserControlResumed,
onAgentTabClaimed: (tabId, windowId) => {
void pushOverlayStateForTab(tabId, windowId);
},
approveBorrow: (ctx) =>
requestBorrowConfirmation(ctx.tabId, {
...(ctx.signal !== undefined ? { signal: ctx.signal } : {}),
@@ -329,6 +303,8 @@ export default defineBackground(() => {
void (async () => {
const connectionEnabled = await getConnectionEnabled();
const port = await getDaemonPort();
transport.setUrl(resolveDaemonWsUrl(port));
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
beforeDisconnect: async () => {
const report = await cleanupAfterDisconnect();
@@ -359,11 +335,7 @@ export default defineBackground(() => {
}
if (msg.kind === OVERLAY_MSG_READY) {
// Resolve from explicit tab ownership before sending so free tabs never
// flash the control mask.
if (typeof sender.tab?.id === "number") {
void pushOverlayStateForTab(sender.tab.id, sender.tab.windowId);
}
sendResponse(overlayStateForWindow(sender.tab?.windowId));
return false;
}
@@ -399,11 +371,6 @@ export default defineBackground(() => {
if (msg && typeof msg === "object" && "kind" in msg) {
if (msg.kind === "set_label") {
void setLabel(msg.value).then(() => controller.refreshLabel());
} else if (msg.kind === "set_port") {
// Placeholder for the future custom-port UI; warn loudly so
// any reintroduced popup control is caught instead of
// silently doing nothing (review M4/M5 C2).
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
} else if (msg.kind === "set_connection_enabled") {
void controller
.setConnectionEnabled(msg.value)
@@ -2,6 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { STORAGE_KEYS } from "@/lib/instance-id";
import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint";
import { EXTENSION_VERSION } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
@@ -66,9 +67,32 @@ describe("App", () => {
render(<App />);
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
});
it("shows the connection switch off and hides transport errors when disconnected", () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
lastError: "[WSTransport] disconnect during connect",
},
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});
render(<App />);
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
expect(screen.queryByText("端口不匹配")).toBeNull();
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("false");
expect(screen.queryByText("[WSTransport] disconnect during connect")).toBeNull();
});
it("does not render record UI on the main view", () => {
render(<App />);
@@ -122,8 +146,8 @@ describe("App", () => {
expect(screen.queryByText(/^扩展 v/)).toBeNull();
expect(screen.queryByText(/^daemon v/)).toBeNull();
expect(screen.getByTitle("扩展版本").textContent).toBe(`Ext ${EXTENSION_VERSION}`);
expect(screen.getByTitle("bsk 版本").textContent).toBe(`CLI ${mockDaemonVersion}`);
expect(screen.getByTitle("扩展版本").textContent).toBe(EXTENSION_VERSION);
expect(screen.getByTitle("bsk 版本").textContent).toBe(mockDaemonVersion);
expect(screen.getByText("03c3e47f")).toBeTruthy();
const copyButton = screen.getByRole("button", { name: "复制实例 ID" });
@@ -136,6 +160,13 @@ describe("App", () => {
});
it("renders the connection toggle with switch semantics", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
@@ -143,6 +174,13 @@ describe("App", () => {
});
it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
@@ -160,6 +198,7 @@ describe("App", () => {
render(<App />);
expect(screen.getByText("连接已关闭")).toBeTruthy();
expect(screen.queryByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeNull();
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("false");
@@ -354,14 +393,20 @@ describe("control hints toggle", () => {
const info = await screen.findByRole("button", { name: "控制提示说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByRole("tooltip");
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
const tooltip = screen.getByText("Agent 控制页面时显示提示条和橙色闪光。");
expect(tooltip.getAttribute("role")).toBe("tooltip");
// Hidden until the info button is hovered or focused.
expect(tooltip.className).toContain("opacity-0");
});
it("uses the same switch component and size for both settings rows", async () => {
stubChromeStorage();
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel: vi.fn(),
setConnectionEnabled: vi.fn(),
});
render(<App />);
@@ -374,3 +419,109 @@ describe("control hints toggle", () => {
expect(hintsToggle.className).toBe(connectionToggle.className);
});
});
describe("daemon port input", () => {
function stubChromeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
vi.stubGlobal("chrome", {
runtime: { lastError: undefined },
storage: {
local: {
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
const items: Record<string, unknown> = {};
for (const k of Array.isArray(keys) ? keys : [keys]) {
if (k in store) items[k] = store[k];
}
cb(items);
},
set: (items: Record<string, unknown>, cb?: () => void) => {
Object.assign(store, items);
cb?.();
},
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
});
return store;
}
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});
it("prefills the port from storage", async () => {
stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });
render(<App />);
const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).value).toBe("53200"));
});
it("persists a valid port on blur", async () => {
const store = stubChromeStorage();
render(<App />);
const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.blur(input);
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
expect((input as HTMLInputElement).value).toBe("53200");
});
it("persists a valid port when Enter blurs the field", async () => {
const store = stubChromeStorage();
render(<App />);
const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
});
it("shows an error and does not write invalid ports", async () => {
const store = stubChromeStorage();
render(<App />);
const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "abc" } });
fireEvent.blur(input);
expect(screen.getByText("请输入 1 到 65535 之间的端口号。")).toBeTruthy();
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBeUndefined();
});
it("stores the default port when the field is cleared", async () => {
const store = stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });
render(<App />);
const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(DEFAULT_DAEMON_PORT);
expect((input as HTMLInputElement).value).toBe(String(DEFAULT_DAEMON_PORT));
});
it("keeps the port hint copy in an accessible info tooltip", async () => {
stubChromeStorage();
render(<App />);
const info = await screen.findByRole("button", { name: "连接端口说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByText("扩展通过此端口连接本机 daemon。非必要请勿修改。");
expect(tooltip.getAttribute("role")).toBe("tooltip");
expect(tooltip.className).toContain("opacity-0");
});
});
+79 -4
View File
@@ -15,6 +15,7 @@ import { POPUP_FEATURES, type PopupView } from "./features";
import { Switch } from "./switch";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
import { useControlHintsHidden } from "./use-control-hints-hidden";
import { useDaemonPort } from "./use-daemon-port";
const STATE_LABEL_KEYS = {
disconnected: "popup.stateLabel.disconnected",
@@ -41,6 +42,12 @@ export function App() {
const { t } = useTranslation("extension");
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden();
const {
draft: daemonPortDraft,
setDraft: setDaemonPortDraft,
commit: commitDaemonPort,
invalid: daemonPortInvalid,
} = useDaemonPort();
const [view, setView] = useState<PopupView>("main");
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
const [purposeDraft, setPurposeDraft] = useState("");
@@ -79,6 +86,7 @@ export function App() {
}, [copiedTick]);
const isSkewed = statusState === "version_skew";
const isDisconnected = statusState === "disconnected";
const connectionLive = statusState === "connected" || isSkewed;
const daemonVersion = snapshot.handshake?.version ?? "—";
const daemonProtocol = snapshot.handshake?.protocol_version ?? "—";
@@ -198,13 +206,21 @@ export function App() {
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<Switch
checked={snapshot.connectionEnabled}
checked={snapshot.connectionEnabled && !isDisconnected}
onCheckedChange={setConnectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
/>
</div>
</div>
{isDisconnected && (
<p
className="mt-2 text-xs leading-snug text-muted-foreground"
data-slot="popup-daemon-unreachable"
>
{t("popup.daemonUnreachable")}
</p>
)}
{isSkewed && (
<p
className="mt-2 text-xs leading-snug text-muted-foreground"
@@ -253,7 +269,66 @@ export function App() {
</div>
</section>
{snapshot.lastError && (
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-daemon-port-card"
>
<div className="flex items-center justify-between gap-2">
<span className="flex min-w-0 items-center gap-1">
<Label
htmlFor="bh-daemon-port"
className="truncate text-sm font-medium leading-normal"
>
{t("popup.daemonPortLabel")}
</Label>
<span className="group relative inline-flex shrink-0">
<button
type="button"
aria-label={t("popup.daemonPortInfoLabel")}
data-slot="popup-daemon-port-info"
className="flex size-4 items-center justify-center rounded-full text-muted-foreground/70 transition-colors hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50"
>
<RiInformationLine className="size-3.5" aria-hidden />
</button>
<span
role="tooltip"
className="pointer-events-none absolute bottom-full left-0 z-10 mb-1.5 w-56 whitespace-normal rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium leading-snug text-background opacity-0 shadow-md backdrop-blur-sm transition-opacity group-hover:opacity-100 group-focus-within:opacity-100"
>
{t("popup.daemonPortHint")}
</span>
</span>
</span>
<Input
id="bh-daemon-port"
type="text"
inputMode="numeric"
value={daemonPortDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setDaemonPortDraft(event.target.value)
}
onBlur={commitDaemonPort}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
commitDaemonPort();
}
}}
className="mt-0 h-7.5 w-19 shrink-0 rounded-md px-2 py-0 text-center text-sm leading-none shadow-none"
aria-invalid={daemonPortInvalid || undefined}
data-slot="popup-daemon-port-input"
/>
</div>
{daemonPortInvalid && (
<p
className="mt-1.5 text-[11px] leading-snug text-destructive"
data-slot="popup-daemon-port-error"
>
{t("popup.daemonPortInvalid")}
</p>
)}
</section>
{snapshot.lastError && !isDisconnected && (
<div
className="rounded-lg border border-destructive/25 bg-destructive/10 px-3 py-2 text-xs leading-snug text-destructive"
data-slot="popup-error"
@@ -267,9 +342,9 @@ export function App() {
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.daemonVersionHint")}>CLI {daemonVersion}</span>
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span aria-hidden>/</span>
<span title={t("popup.extensionVersionHint")}>Ext {extensionVersion}</span>
<span title={t("popup.daemonVersionHint")}>{daemonVersion}</span>
</div>
<div className="flex min-w-0 items-center justify-end gap-1">
<span className="shrink-0">{t("popup.instanceTitle")}</span>
@@ -40,13 +40,8 @@ const FALLBACK_SNAPSHOT: SnapshotInfo = {
/**
* Live snapshot of the background's `ConnectionController` for popup UI.
*
* Posts `set_label` mutations back to the background; the incoming
* snapshot stream reflects the canonical state. The `set_port` channel
* is defined by the popup-bridge but is not currently routed to the
* background — wiring it requires `ConnectionController` to swap its
* Transport URL and persist the chosen port. Tracked for a follow-up
* milestone; the popup intentionally does not expose a control until
* the underlying support lands (review M4/M5 C2).
* Posts `set_label` and `set_connection_enabled` mutations back to the
* background; the incoming snapshot stream reflects the canonical state.
*/
export function useConnectionState(): {
snapshot: SnapshotInfo;
@@ -0,0 +1,71 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { getDaemonPort, STORAGE_KEYS, setDaemonPort } from "@/lib/instance-id";
import { parseDaemonPortInput } from "@/transport/daemon-endpoint";
/**
* Popup-side daemon port preference stored in `chrome.storage.local`.
* Commits on blur, Enter, and pagehide.
*/
export function useDaemonPort(): {
draft: string;
setDraft: (value: string) => void;
commit: () => void;
invalid: boolean;
} {
const [draft, setDraftState] = useState("");
const [invalid, setInvalid] = useState(false);
const draftRef = useRef(draft);
draftRef.current = draft;
useEffect(() => {
if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined;
let cancelled = false;
getDaemonPort()
.then((port) => {
if (!cancelled) setDraftState(String(port));
})
.catch((err) => {
console.debug("[browser-skill] daemon port read failed", err);
});
const onChanged = (changes: Record<string, chrome.storage.StorageChange>, areaName: string) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.DAEMON_PORT];
if (change && typeof change.newValue === "number") {
setDraftState(String(change.newValue));
setInvalid(false);
}
};
chrome.storage.onChanged.addListener(onChanged);
return () => {
cancelled = true;
chrome.storage.onChanged.removeListener(onChanged);
};
}, []);
const commit = useCallback(() => {
const parsed = parseDaemonPortInput(draftRef.current);
if (parsed === null) {
setInvalid(true);
return;
}
setInvalid(false);
setDraftState(String(parsed));
if (typeof chrome === "undefined" || !chrome.storage?.local) return;
setDaemonPort(parsed).catch((err) => {
console.debug("[browser-skill] daemon port write failed", err);
});
}, []);
useEffect(() => {
const onPageHide = () => commit();
window.addEventListener("pagehide", onPageHide);
return () => window.removeEventListener("pagehide", onPageHide);
}, [commit]);
const setDraft = useCallback((value: string) => {
setDraftState(value);
setInvalid(false);
}, []);
return { draft, setDraft, commit, invalid };
}
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import {
CAPTURE_SUPPRESS,
type CaptureSuppressMessage,
withExtensionOverlayHidden,
withOverlaysHiddenForCapture,
} from "../capture-suppress-bridge";
function recordingSendToTab(events: string[]) {
@@ -12,12 +12,12 @@ function recordingSendToTab(events: string[]) {
});
}
describe("withExtensionOverlayHidden", () => {
describe("withOverlaysHiddenForCapture", () => {
it("sends begin → fn → end and returns fn's result", async () => {
const events: string[] = [];
const sendToTab = recordingSendToTab(events);
const result = await withExtensionOverlayHidden(
const result = await withOverlaysHiddenForCapture(
7,
async () => {
events.push("capture");
@@ -37,7 +37,7 @@ describe("withExtensionOverlayHidden", () => {
const sendToTab = recordingSendToTab(events);
await expect(
withExtensionOverlayHidden(
withOverlaysHiddenForCapture(
7,
async () => {
events.push("capture");
@@ -55,7 +55,7 @@ describe("withExtensionOverlayHidden", () => {
});
const fn = vi.fn(async () => "shot");
const result = await withExtensionOverlayHidden(7, fn, sendToTab);
const result = await withOverlaysHiddenForCapture(7, fn, sendToTab);
expect(result).toBe("shot");
expect(fn).toHaveBeenCalledTimes(1);
@@ -69,7 +69,7 @@ describe("withExtensionOverlayHidden", () => {
return { type: CAPTURE_SUPPRESS, ok: true };
});
const result = await withExtensionOverlayHidden(7, async () => "shot", sendToTab);
const result = await withOverlaysHiddenForCapture(7, async () => "shot", sendToTab);
expect(result).toBe("shot");
expect(sendToTab).toHaveBeenCalledTimes(2);
@@ -1,12 +1,15 @@
import { describe, expect, it, vi } from "vitest";
import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint";
import {
getConnectionEnabled,
getControlHintsHidden,
getDaemonPort,
getLabel,
getOrCreateInstanceId,
STORAGE_KEYS,
setConnectionEnabled,
setControlHintsHidden,
setDaemonPort,
setLabel,
} from "../instance-id";
@@ -117,4 +120,21 @@ describe("instance-id", () => {
expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true);
expect(await getControlHintsHidden(backend)).toBe(true);
});
it("getDaemonPort returns default when storage is empty", async () => {
const { backend } = fakeStorage();
expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT);
});
it("getDaemonPort normalizes invalid stored values to default", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 0 });
expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT);
});
it("setDaemonPort persists the value retrievable by getDaemonPort", async () => {
const { backend, store } = fakeStorage();
await setDaemonPort(53200, backend);
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200);
expect(await getDaemonPort(backend)).toBe(53200);
});
});
@@ -1,30 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createRandomUuid } from "../random-uuid";
describe("createRandomUuid", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("uses crypto.randomUUID when it is available", () => {
const randomUUID = vi.fn(() => "00000000-0000-4000-8000-000000000000");
vi.stubGlobal("crypto", { randomUUID, getRandomValues: vi.fn() });
expect(createRandomUuid()).toBe("00000000-0000-4000-8000-000000000000");
expect(randomUUID).toHaveBeenCalledOnce();
});
it("falls back to getRandomValues and sets UUID v4 bits", () => {
const bytes = new Uint8Array(16).fill(0xff);
const getRandomValues = vi.fn((target: Uint8Array) => {
target.set(bytes);
return target;
});
vi.stubGlobal("crypto", { getRandomValues });
const uuid = createRandomUuid();
expect(uuid).toBe("ffffffff-ffff-4fff-bfff-ffffffffffff");
expect(getRandomValues).toHaveBeenCalledOnce();
});
});
@@ -1,9 +1,9 @@
/**
* Wire protocol and background-side helper for temporarily excluding the
* extension's in-page overlay. Screenshots use it to avoid captured chrome;
* coordinate-driven native input uses it so Chrome hit-tests the page.
* Wire protocol and background-side helper for hiding the extension's
* in-page overlay while a screenshot is captured, so captured frames only
* contain the page itself.
*
* The background wraps each bounded operation in `withExtensionOverlayHidden`,
* The background wraps every capture in `withOverlaysHiddenForCapture`,
* which sends `begin` (the content script hides the overlay host and only
* acks once the compositor has produced an overlay-free frame) and always
* follows up with `end`, even when the capture throws. Tabs without the
@@ -46,7 +46,7 @@ const defaultSendToTab: CaptureSuppressSendToTab = (tabId, message) =>
* (or any other `begin` failure) simply skips suppression — there is no
* overlay to hide in that tab.
*/
export async function withExtensionOverlayHidden<T>(
export async function withOverlaysHiddenForCapture<T>(
tabId: number,
fn: () => Promise<T>,
sendToTab: CaptureSuppressSendToTab = defaultSendToTab,
+17
View File
@@ -1,7 +1,10 @@
import { normalizeDaemonPort } from "@/transport/daemon-endpoint";
const STORAGE_KEY = "bsk_instance_id";
const LABEL_STORAGE_KEY = "bh_label";
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden";
const DAEMON_PORT_KEY = "bsk_daemon_port";
export interface StorageBackend {
get(keys: string | string[]): Promise<Record<string, unknown>>;
@@ -127,9 +130,23 @@ export async function setControlHintsHidden(
await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden });
}
/** Defaults to {@link DEFAULT_DAEMON_PORT} when unset or invalid. */
export async function getDaemonPort(storage: StorageBackend = defaultStorage()): Promise<number> {
const items = await storage.get(DAEMON_PORT_KEY);
return normalizeDaemonPort(items[DAEMON_PORT_KEY]);
}
export async function setDaemonPort(
port: number,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [DAEMON_PORT_KEY]: port });
}
export const STORAGE_KEYS = {
INSTANCE_ID: STORAGE_KEY,
LABEL: LABEL_STORAGE_KEY,
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_KEY,
DAEMON_PORT: DAEMON_PORT_KEY,
} as const;
+2 -4
View File
@@ -3,10 +3,8 @@
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.ready" }` → background decides the per-tab state first
* (see overlayStateForTab) and pushes the authoritative overlay state for
* that tab. It also proactively pushes on tab create and control-mode
* change, so a tab usually receives state before it ever sends ready.
* - `{ kind: "overlay.ready" }` → background replies with the authoritative
* overlay state for the sender's window.
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
* daemon (via a `session.user_interrupt` WS event) to cancel
* every inflight + queued tool call for that session with
+2 -7
View File
@@ -5,19 +5,14 @@ import type { SnapshotInfo } from "./connection-controller";
* - Background pushes `{ kind: "snapshot", data: SnapshotInfo }`.
* - Popup sends `{ kind: "set_label" }`, `{ kind: "set_connection_enabled" }`, etc.
*
* NOTE(review M4/M5 C2): the `set_port` variant is defined as a
* placeholder so the future custom-port UI does not have to re-design
* the bridge, but the popup does NOT render a control that emits it
* yet and the background does NOT route it. The wiring requires
* ConnectionController to dispose its current Transport and persist
* the port in chrome.storage; tracked as a follow-up.
* Daemon port preference is persisted via `chrome.storage.local` instead
* of this bridge (see `use-daemon-port.ts`).
*/
export const POPUP_PORT_NAME = "popup";
export type PopupOutbound =
| { kind: "set_label"; value: string }
| { kind: "set_port"; value: number }
| { kind: "set_connection_enabled"; value: boolean };
export type PopupInbound = { kind: "snapshot"; data: SnapshotInfo };
-26
View File
@@ -1,26 +0,0 @@
/**
* Generate a UUID v4 in browser contexts where randomUUID() is unavailable.
*
* Crypto.randomUUID() is restricted to secure contexts, while
* crypto.getRandomValues() is available to content scripts on ordinary HTTP
* origins. BrowserSkill records many intranet pages served over HTTP, so the
* fallback is required for recording to work on those pages.
*/
export function createRandomUuid(): string {
const cryptoApi = globalThis.crypto;
if (typeof cryptoApi.randomUUID === "function") return cryptoApi.randomUUID();
const bytes = new Uint8Array(16);
cryptoApi.getRandomValues(bytes);
bytes[6] = (bytes[6]! & 0x0f) | 0x40;
bytes[8] = (bytes[8]! & 0x3f) | 0x80;
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20),
].join("-");
}
@@ -1,4 +1,3 @@
import { createRandomUuid } from "../random-uuid";
import {
isRecordFrameQueryMessage,
RECORD_FRAME_PORT,
@@ -189,7 +188,7 @@ export class RecordFrameCoordinator {
const recording = this.#recordings.get(requestId);
if (!recording) return true;
recording.finishing = true;
const commandId = createRandomUuid();
const commandId = crypto.randomUUID();
const results = await Promise.all(
[...recording.agents.values()].map((agent) => this.#stopAgent(agent, requestId, commandId)),
);
@@ -9,7 +9,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => nextWindowId++),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
remove,
},
});
@@ -41,7 +41,7 @@ describe("disconnect session cleanup", () => {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
remove: vi.fn(() => removeGate),
},
});
@@ -39,7 +39,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await manager.start("aa11");
@@ -73,7 +73,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const ctx = await manager.start("aa11");
@@ -87,7 +87,6 @@ describe("attachSessionEventHandler", () => {
windowEvents: events.api,
});
manager.forgetClosedTab(7, { isWindowClosing: true });
events.emit(4242);
for (let i = 0; i < 4; i += 1) await Promise.resolve();
@@ -114,7 +113,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const transport = fakeTransport();
@@ -130,7 +129,7 @@ describe("attachSessionEventHandler", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const transport = fakeTransport();
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { AgentWindowApi, AgentWindowCreateOptions } from "../agent-window";
import { isAgentControlledTab, SessionManager } from "../manager";
import { SessionManager } from "../manager";
function fakeAgentWindow(): AgentWindowApi & {
createMock: ReturnType<typeof vi.fn>;
@@ -13,7 +13,7 @@ function fakeAgentWindow(): AgentWindowApi & {
return id;
});
const removeMock = vi.fn(async (_id: number) => {});
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => 0);
const ensureActiveTabMock = vi.fn(async (_windowId: number, _url: string) => {});
return {
create: createMock,
remove: removeMock,
@@ -151,76 +151,6 @@ describe("SessionManager", () => {
expect(sm.list()).toEqual([]);
});
describe("explicit tab claims", () => {
it("claims the exact home tab returned by AgentWindowApi", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
expect(ctx.agentCreatedTabs).toEqual(new Set([0]));
expect(isAgentControlledTab(ctx, 0)).toBe(true);
});
it("keeps every other tab free without classification state", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
expect(isAgentControlledTab(ctx, 99)).toBe(false);
expect(ctx).not.toHaveProperty("userTabs");
expect(ctx).not.toHaveProperty("pendingAgentTabCount");
});
it("treats a committed borrow as an explicit claim", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 3 });
expect(isAgentControlledTab(ctx, 42)).toBe(true);
});
it("forgets an agent-created tab after Chrome removes it", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(42);
sm.forgetClosedTab(42);
expect(ctx.agentCreatedTabs.has(42)).toBe(false);
expect(isAgentControlledTab(ctx, 0)).toBe(true);
});
it("forgets closed borrows idempotently without affecting other tabs or sessions", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const a = await sm.start("aa11");
const b = await sm.start("bb22");
a.borrowedTabs.set(42, { tabId: 42, originalWindowId: 7, originalIndex: 0 });
a.borrowedTabs.set(43, { tabId: 43, originalWindowId: 7, originalIndex: 1 });
b.borrowedTabs.set(44, { tabId: 44, originalWindowId: 8, originalIndex: 0 });
sm.forgetClosedTab(42);
sm.forgetClosedTab(42);
sm.forgetClosedTab(999);
expect(isAgentControlledTab(a, 42)).toBe(false);
expect(sm.findBorrowingSession(42, null)).toBeNull();
expect([...a.borrowedTabs.keys()]).toEqual([43]);
expect([...b.borrowedTabs.keys()]).toEqual([44]);
expect(sm.list()).toHaveLength(2);
});
it("prevents an in-flight borrow from reclaiming a closed tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
const ctx = await sm.start("aa11");
const reservation = sm.tryReserveBorrow(42, ctx.sessionId);
if ("borrowedBy" in reservation) throw new Error("unexpected borrow conflict");
sm.forgetClosedTab(42);
expect(() =>
reservation.commit({ tabId: 42, originalWindowId: 7, originalIndex: 0 }),
).toThrow(/reservation disappeared/);
reservation.release();
expect(sm.findBorrowingSession(42, null)).toBeNull();
expect(ctx.borrowedTabs.has(42)).toBe(false);
});
});
describe("findBorrowingSession", () => {
it("returns null when no session has borrowed the tab", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow() });
@@ -14,11 +14,8 @@ export interface AgentWindowApi {
* Guarantee the Agent Window has an active, CDP-navigable tab.
* `chrome://` pages (including the New Tab page) reject `Page.navigate`,
* so sessions bootstrap with `about:blank` instead.
*
* Resolves with the id of the activated (or newly created) tab, so callers
* can track the session's home tab without re-querying Chrome.
*/
ensureActiveTab(windowId: number, url: string): Promise<number>;
ensureActiveTab(windowId: number, url: string): Promise<void>;
}
/** Creation hints for a new Agent Window. */
@@ -52,19 +49,15 @@ export const chromeAgentWindowApi: AgentWindowApi = {
// cancellation success while the Agent Window remains open.
await chrome.windows.remove(windowId);
},
async ensureActiveTab(windowId: number, url: string): Promise<number> {
async ensureActiveTab(windowId: number, url: string): Promise<void> {
const tabs = await chrome.tabs.query({ windowId });
const first = tabs.find((t) => typeof t.id === "number");
if (first?.id !== undefined) {
if (first?.id) {
if (!first.active) {
await chrome.tabs.update(first.id, { active: true });
}
return first.id;
return;
}
const created = await chrome.tabs.create({ windowId, url, active: true });
if (typeof created?.id !== "number") {
throw new Error("[bh] chrome.tabs.create returned no tab id");
}
return created.id;
await chrome.tabs.create({ windowId, url, active: true });
},
};
+1 -29
View File
@@ -6,20 +6,9 @@ export interface SessionContext {
agentWindowId: number;
refStore: RefStore;
borrowedTabs: Map<number, BorrowedTab>;
/**
* Tabs explicitly claimed by the agent because it created them. This
* includes the Agent Window's home tab and tabs created by `tool.tab_create`.
* Tabs opened through Chrome UI never enter this set.
*/
agentCreatedTabs: Set<number>;
createdAtMs: number;
}
/** Whether this session has explicitly claimed control of `tabId`. */
export function isAgentControlledTab(ctx: SessionContext, tabId: number): boolean {
return ctx.agentCreatedTabs.has(tabId) || ctx.borrowedTabs.has(tabId);
}
export interface BorrowedTab {
tabId: number;
originalWindowId: number;
@@ -117,19 +106,6 @@ export class SessionManager {
return Array.from(this.sessions.values());
}
/**
* Forget a tab Chrome has removed, including any uncommitted borrow.
* Whole-window closures keep committed borrows until the window-removed
* handler reports which user tabs could not be returned.
*/
forgetClosedTab(tabId: number, { isWindowClosing = false } = {}): void {
this.borrowReservations.delete(tabId);
for (const ctx of this.sessions.values()) {
ctx.agentCreatedTabs.delete(tabId);
if (!isWindowClosing) ctx.borrowedTabs.delete(tabId);
}
}
/**
* Look up whether `tabId` is currently borrowed by some *other*
* session than the one calling. Used by M8 `tab_borrow` to refuse
@@ -203,7 +179,7 @@ export class SessionManager {
const { signal: _signal, ...createOptions } = opts;
windowId = await this.agentWindow.create(AGENT_WINDOW_HOME, createOptions);
throwIfSessionStartAborted(opts.signal);
const homeTabId = await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
await this.agentWindow.ensureActiveTab(windowId, AGENT_WINDOW_HOME);
throwIfSessionStartAborted(opts.signal);
const ctx: SessionContext = {
@@ -211,10 +187,6 @@ export class SessionManager {
agentWindowId: windowId,
refStore: new RefStore(),
borrowedTabs: new Map(),
// The home tab is the session's first explicit claim. Every other
// tab remains free until `tab_create` or `tab_borrow` identifies it
// by its concrete Chrome tab id.
agentCreatedTabs: new Set([homeTabId]),
createdAtMs: this.now(),
};
this.sessions.set(sessionId, ctx);
@@ -13,7 +13,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -49,54 +49,13 @@ describe("ToolDispatcher", () => {
vi.unstubAllGlobals();
});
it.each([
"tool.snapshot",
"tool.evaluate",
"tool.get_html",
"tool.console",
])("annotates Chrome extension-access failures from %s", async (method) => {
const tab = { id: 7, windowId: 4242, active: true, url: "https://example.test" };
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => tab),
query: vi.fn(async () => [tab]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(),
},
});
await sessions.start("test");
const error = new Error("Cannot access a chrome-extension:// URL of different extension");
const cdp = {
send: vi.fn().mockRejectedValue(error),
ensureConsoleCapture: vi.fn().mockRejectedValue(error),
consoleEntriesSince: vi.fn(),
} as unknown as TestDispatcherCdp;
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
deliver(makeRequest(method, { session_id: "test", expression: "document.title" }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({
error: {
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
},
});
dispatcher.stop();
});
it("uses the configured recording runtime for start and stop", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -158,7 +117,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -181,7 +140,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create,
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -199,7 +158,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -213,47 +172,6 @@ describe("ToolDispatcher", () => {
expect(sent[0]).toEqual({ id: "r-1", result: {} });
});
it("wires the production tab APIs into tool.session_stop so a surviving user tab releases the window", async () => {
// Regression guard for the deps that session_stop reads directly: when
// `tabManagement.tabs` / `tabsQuery` are not injected by the dispatcher,
// the agent-tab cleanup and the release decision silently no-op and the
// Agent Window is closed even though a user tab is still open (issue #57).
const removeTab = vi.fn(async () => {});
const closeWindow = vi.fn(async () => {});
// After cleanup the window still holds one tab (id 99) that is neither
// the home tab nor an agent-created tab — i.e. a genuine user tab.
vi.stubGlobal("chrome", {
tabs: {
remove: removeTab,
query: vi.fn(async () => [{ id: 99, windowId: 4242, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: closeWindow,
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
// Register the exact tab id returned by the agent's creation path.
sessions.get("aa11")?.agentCreatedTabs.add(7);
const dispatcher = new ToolDispatcher({ transport, sessions });
dispatcher.start();
deliver(makeRequest("tool.session_stop", { session_id: "aa11" }));
await flushMicrotasks();
// The agent tab is closed through the real chrome.tabs surface...
expect(removeTab).toHaveBeenCalledWith(7);
// ...and the window is released rather than closed, so the user's tab survives.
expect(closeWindow).not.toHaveBeenCalled();
expect(sessions.has("aa11")).toBe(false);
expect(sent[0]).toEqual({ id: "r-1", result: { window_released: true } });
});
it("routes tool.console through the CDP console buffer", async () => {
vi.stubGlobal("chrome", {
tabs: {
@@ -265,7 +183,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -328,7 +246,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const ctx = await sessions.start("aa11");
@@ -421,7 +339,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -457,7 +375,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove,
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -505,7 +423,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -525,7 +443,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -549,7 +467,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -566,7 +484,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -583,7 +501,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const onSessionsChanged = vi.fn();
@@ -600,32 +518,16 @@ describe("ToolDispatcher", () => {
});
it("invokes onBrowserControlResumed for browser-control tools but not passive reads", async () => {
vi.stubGlobal("chrome", {
tabs: {
create: vi.fn(async (props: chrome.tabs.CreateProperties) => ({
id: 7,
windowId: props.windowId,
url: props.url,
active: props.active,
})),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const onBrowserControlResumed = vi.fn();
const onAgentTabClaimed = vi.fn();
const dispatcher = new ToolDispatcher({
transport,
sessions,
onBrowserControlResumed,
onAgentTabClaimed,
});
const dispatcher = new ToolDispatcher({ transport, sessions, onBrowserControlResumed });
dispatcher.start();
deliver(makeRequest("tool.session_start", { session_id: "aa11" }));
@@ -642,7 +544,6 @@ describe("ToolDispatcher", () => {
});
await flushMicrotasks();
expect(onBrowserControlResumed).toHaveBeenCalledWith("aa11");
expect(onAgentTabClaimed).toHaveBeenCalledWith(7, 1);
});
it("reasserts remembered hover before follow-up work and releases only after actions", async () => {
@@ -656,7 +557,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const order: string[] = [];
@@ -738,7 +639,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const cdp = {
@@ -788,135 +689,6 @@ describe("ToolDispatcher", () => {
);
});
it.each([
{ tab_id: 7, code: "not_found" },
{ tab_id: undefined, code: "invalid_params" },
])("keeps hover when tab_return is rejected with $code", async ({ tab_id, code }) => {
const sendMessage = vi.fn(async () => undefined);
vi.stubGlobal("chrome", {
tabs: {
sendMessage,
get: vi.fn(async () => ({ id: 7, windowId: 100, active: true })),
query: vi.fn(async () => [{ id: 7, windowId: 100, active: true }]),
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
await sessions.start("aa11");
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
helpers.rememberHover("aa11", { tab_id: 7, x: 10, y: 20 });
await helpers.setHoverBypass("aa11", 7, true);
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ error: { code } });
expect(cdp.releaseSessionTab).not.toHaveBeenCalled();
expect(sendMessage).not.toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).toHaveBeenCalledExactlyOnceWith(7, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
dispatcher.stop();
});
it("releases the returned tab's hover before moving it and leaves other tabs alone", async () => {
const sendMessage = vi.fn(async () => undefined);
const move = vi.fn(async () => {
expect(sendMessage).toHaveBeenCalledWith(7, expect.objectContaining({ enabled: false }));
return { id: 7, windowId: 200, index: 4 };
});
vi.stubGlobal("chrome", {
tabs: { sendMessage, move },
windows: { get: vi.fn(async () => ({ id: 200 })) },
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
},
});
const ctx = await sessions.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const cdp = {
send: vi.fn(async <T>() => ({}) as T),
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({
transport,
sessions,
cdp: cdp as unknown as TestDispatcherCdp,
});
const helpers = dispatcher as unknown as {
rememberHover: (sessionId: string, result: unknown) => unknown;
setHoverBypass: (sessionId: string, tabId: number, enabled: boolean) => Promise<void>;
withHoverReassert: <T>(
params: { session_id: string; tab_id?: number },
work: () => Promise<T>,
) => Promise<T>;
};
for (const [sessionId, tabId] of [
["aa11", 7],
["aa11", 8],
["bb22", 9],
] as const) {
helpers.rememberHover(sessionId, { tab_id: tabId, x: 10, y: 20 });
await helpers.setHoverBypass(sessionId, tabId, true);
}
dispatcher.start();
deliver(makeRequest("tool.tab_return", { session_id: "aa11", tab_id: 7 }));
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ result: { tab_id: 7, returned_to_window_id: 200 } });
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 7);
await helpers.withHoverReassert({ session_id: "aa11", tab_id: 7 }, async () => ({}));
expect(cdp.send).not.toHaveBeenCalled();
for (const [sessionId, tabId] of [
["aa11", 8],
["bb22", 9],
] as const) {
expect(sendMessage).not.toHaveBeenCalledWith(
tabId,
expect.objectContaining({ enabled: false }),
);
await helpers.withHoverReassert({ session_id: sessionId, tab_id: tabId }, async () => ({}));
expect(cdp.send).toHaveBeenLastCalledWith(tabId, "Input.dispatchMouseEvent", {
type: "mouseMoved",
x: 10,
y: 20,
});
}
dispatcher.stop();
});
it("transfers hover overlay bypass ownership between sessions", async () => {
vi.stubGlobal("chrome", {
tabs: {
@@ -928,7 +700,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -965,7 +737,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
await sessions.start("aa11");
@@ -1017,7 +789,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -1051,7 +823,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
transport.send = () => {
@@ -1073,7 +845,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 5555),
remove,
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
transport.send = () => {
@@ -1101,7 +873,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -1152,7 +924,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(async () => 1),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -1173,7 +945,7 @@ describe("ToolDispatcher", () => {
agentWindow: {
create: vi.fn(() => createPromise),
remove: vi.fn(),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
const dispatcher = new ToolDispatcher({ transport, sessions });
@@ -20,7 +20,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -1,39 +0,0 @@
import { describe, expect, it } from "vitest";
import type { RpcError } from "@/transport/types";
import { cdpError, classifyCdpError } from "../errors";
const denied = "Cannot access a chrome-extension:// URL of different extension";
describe("CDP extension access errors", () => {
it("classifies both Chrome Error instances and string failures", () => {
for (const error of [new Error(denied), denied]) {
expect(cdpError(error)).toEqual({
code: "cdp_failed",
message: denied,
data: { reason: "cdp_extension_access_denied" },
});
}
});
it("preserves transfer outcome and cleanup instructions when CDP was denied", () => {
const error: RpcError = {
code: "cdp_failed",
message: denied,
data: {
reason: "transfer_outcome_unknown",
effect_state: "unknown",
cleanup_state: "failed",
},
};
expect(classifyCdpError(error)).toBe(error);
});
it("leaves other error codes and unrelated debugger failures unchanged", () => {
for (const error of [
{ code: "permission_denied", message: denied },
{ code: "cdp_failed", message: "Another debugger is already attached" },
] satisfies RpcError[]) {
expect(classifyCdpError(error)).toBe(error);
}
});
});
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -2,7 +2,6 @@ import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { type DownloadsApi, handleDownload } from "../download";
import { captureBrowserDownload } from "../download-capture";
import { uploadThroughFileDrop } from "../file-drop-transaction";
import { uploadThroughActivatedFileInput } from "../file-input-transaction";
import type { ResolvedActionTarget } from "../interaction";
import type { CdpRunner } from "../shared";
@@ -13,7 +12,7 @@ function sessions() {
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
},
});
}
@@ -38,20 +37,16 @@ function fakeEvent<T extends (...args: never[]) => unknown>() {
};
}
function actionTarget(frameId?: string, sessionId?: string): ResolvedActionTarget {
function actionTarget(frameId?: string): ResolvedActionTarget {
return {
tab: { tabId: 4, windowId: 100, active: true },
backendNodeId: 123,
cdpTarget: { tabId: 4, ...(sessionId ? { sessionId } : {}) },
cdpTarget: { tabId: 4 },
...(frameId ? { frameId } : {}),
usedRef: "e3",
};
}
function withoutOverlay<T>(operation: () => Promise<T>): Promise<T> {
return operation();
}
function uploadCdp(
options: {
inputCount?: number;
@@ -64,7 +59,6 @@ function uploadCdp(
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "Page.setInterceptFileChooserDialog") return {};
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
@@ -116,50 +110,6 @@ function uploadCdp(
};
}
function dropCdp(
options: {
targetContainsPoint?: boolean;
dispatchError?: (type: string) => Error | Promise<never> | undefined;
onCall?: (method: string) => void;
} = {},
) {
const calls: Array<{ method: string; params?: object }> = [];
const detach = vi.fn(async () => {});
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
options.onCall?.(method);
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
}
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
}
if (method === "DOM.getNodeForLocation") return { backendNodeId: 124 };
if (method === "DOM.resolveNode") {
const id = (params as { backendNodeId?: number }).backendNodeId;
return { object: { objectId: id === 123 ? "drop-target" : "hit-target" } };
}
if (method === "Runtime.callFunctionOn") {
return { result: { value: options.targetContainsPoint ?? true } };
}
if (method === "Input.dispatchDragEvent") {
const type = (params as { type?: string }).type ?? "";
const error = options.dispatchError?.(type);
if (error instanceof Promise) return error;
if (error) throw error;
return {};
}
if (method === "Runtime.releaseObjectGroup") return {};
throw new Error(`unexpected drop CDP command ${method}`);
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
detach,
};
return { calls, cdp, detach };
}
describe("file transfer tools", () => {
it("captures the file input activated by the requested click and injects only staged paths", async () => {
const manager = sessions();
@@ -296,6 +246,104 @@ describe("file transfer tools", () => {
});
});
it("does not dispatch browser work when upload is already cancelled", async () => {
const controller = new AbortController();
controller.abort();
const { cdp, calls } = uploadCdp();
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 100,
signal: controller.signal,
trigger: vi.fn(),
});
expect(result).toMatchObject({
code: "cancelled",
data: { effect_state: "none", phase: "arm_interception" },
});
expect(calls).not.toContainEqual({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: true },
});
expect(calls.some((call) => call.method === "DOM.setFileInputFiles")).toBe(false);
});
it("stops before file assignment when cancellation interrupts input resolution", async () => {
const controller = new AbortController();
const fixture = uploadCdp();
const originalSend = fixture.cdp.send;
let markProbeStarted!: () => void;
const probeStarted = new Promise<void>((resolve) => {
markProbeStarted = resolve;
});
fixture.cdp.send = vi.fn((tabId: number, method: string, params?: object) => {
const declaration = (params as { functionDeclaration?: string } | undefined)
?.functionDeclaration;
if (
method === "Runtime.callFunctionOn" &&
declaration?.includes("count: state.inputs.length")
) {
markProbeStarted();
return new Promise<never>(() => {});
}
return originalSend(tabId, method, params);
}) as CdpRunner["send"];
const transaction = uploadThroughActivatedFileInput({
cdp: fixture.cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 1_000,
signal: controller.signal,
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
await probeStarted;
controller.abort();
const result = await transaction;
expect(result).toMatchObject({
code: "cancelled",
data: { effect_state: "none", phase: "resolve_input" },
});
expect(fixture.calls.some((call) => call.method === "DOM.setFileInputFiles")).toBe(false);
});
it("disarms chooser interception when its enable response times out", async () => {
const calls: Array<{ method: string; params?: object }> = [];
const cdp: CdpRunner = {
send: vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
if (
method === "Page.setInterceptFileChooserDialog" &&
(params as { enabled?: boolean }).enabled
) {
return new Promise<never>(() => {});
}
return {};
}) as CdpRunner["send"],
};
const result = await uploadThroughActivatedFileInput({
cdp,
actionTarget: actionTarget(),
files: ["/private/stage/one"],
timeoutMs: 5,
trigger: vi.fn(),
});
expect(result).toMatchObject({
code: "timeout",
data: { effect_state: "none", phase: "arm_interception" },
});
expect(calls).toContainEqual({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: false, cancel: true },
});
});
it("marks a timed-out file assignment unknown and detaches browser state", async () => {
const fixture = uploadCdp();
const originalSend = fixture.cdp.send;
@@ -321,236 +369,6 @@ describe("file transfer tools", () => {
expect(detach).toHaveBeenCalledWith(4);
});
it("delivers staged files through one explicit native drop without clicking", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
const lifecycle: string[] = [];
const { cdp, calls } = dropCdp({ onCall: (method) => lifecycle.push(method) });
const overlayPhases: string[] = [];
const result = await handleUpload(
manager,
{
session_id: "s1",
ref: "@e3",
mode: "drop",
files: [{ transfer_id: "tr_1", name: "one.png", staged_path: "/stage/one.png" }],
},
{
cdp,
tabsApi: tabsApi(),
sendToTab: vi.fn(async (_tabId, message) => {
overlayPhases.push(message.phase);
lifecycle.push(`overlay:${message.phase}`);
}),
},
);
expect(result).toMatchObject({ tab_id: 4, used_ref: "e3", file_names: ["one.png"] });
expect(calls.filter((call) => call.method === "Input.dispatchDragEvent")).toEqual([
{
method: "Input.dispatchDragEvent",
params: {
type: "dragEnter",
x: 110,
y: 70,
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
},
},
{
method: "Input.dispatchDragEvent",
params: {
type: "dragOver",
x: 110,
y: 70,
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
},
},
{
method: "Input.dispatchDragEvent",
params: {
type: "drop",
x: 110,
y: 70,
data: { items: [], files: ["/stage/one.png"], dragOperationsMask: 1 },
},
},
]);
expect(calls.some((call) => call.method === "Input.dispatchMouseEvent")).toBe(false);
expect(overlayPhases).toEqual(["begin", "end"]);
expect(lifecycle.indexOf("DOM.getContentQuads")).toBeLessThan(
lifecycle.indexOf("overlay:begin"),
);
expect(lifecycle.indexOf("overlay:begin")).toBeLessThan(
lifecycle.indexOf("DOM.getNodeForLocation"),
);
expect(lifecycle.lastIndexOf("Input.dispatchDragEvent")).toBeLessThan(
lifecycle.indexOf("overlay:end"),
);
expect(lifecycle.indexOf("overlay:end")).toBeLessThan(
lifecycle.indexOf("Runtime.releaseObjectGroup"),
);
});
it("fails closed before dragging when another element owns the action point", async () => {
const { cdp, calls } = dropCdp({ targetContainsPoint: false });
const result = await uploadThroughFileDrop({
cdp,
actionTarget: actionTarget(),
files: ["/stage/one.png"],
timeoutMs: 100,
withOverlayHidden: withoutOverlay,
});
expect(result).toMatchObject({
code: "permission_denied",
data: {
reason: "file_drop_target_unavailable",
mechanism: "drop",
effect_state: "none",
phase: "resolve_target",
},
});
expect(calls.some((call) => call.method === "Input.dispatchDragEvent")).toBe(false);
});
it("dispatches an OOPIF file drop in the child target's local coordinates", async () => {
const rootCalls: Array<{ method: string; params?: object }> = [];
const childCalls: Array<{ method: string; params?: object }> = [];
const cdp: CdpRunner = {
send: vi.fn(async (_tabId, method, params) => {
rootCalls.push({ method, params });
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getBoxModel") {
return { model: { content: [100, 100, 300, 100, 300, 200, 100, 200] } };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 800, clientHeight: 600 } };
}
throw new Error(`unexpected root command ${method}`);
}) as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method, params) => {
childCalls.push({ method, params });
if (method === "DOM.scrollIntoViewIfNeeded") return {};
if (method === "DOM.getContentQuads") {
return { quads: [[10, 20, 210, 20, 210, 120, 10, 120]] };
}
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 400, clientHeight: 300 } };
}
if (method === "DOM.getNodeForLocation") {
return { backendNodeId: 124, frameId: "child" };
}
if (method === "DOM.resolveNode") {
const id = (params as { backendNodeId?: number }).backendNodeId;
return { object: { objectId: id === 123 ? "drop-target" : "hit-target" } };
}
if (method === "Runtime.callFunctionOn") return { result: { value: true } };
if (method === "Input.dispatchDragEvent" || method === "Runtime.releaseObjectGroup") {
return {};
}
throw new Error(`unexpected child command ${method}`);
}) as CdpRunner["sendToTarget"],
getFrameGraph: vi.fn(async () => ({
rootFrameId: "main",
frames: [
{ frameId: "main", target: { tabId: 4 } },
{
frameId: "child",
parentFrameId: "main",
ownerBackendNodeId: 10,
target: { tabId: 4, sessionId: "child-session" },
},
],
})),
};
const result = await uploadThroughFileDrop({
cdp,
actionTarget: actionTarget("child", "child-session"),
files: ["/stage/one.png"],
timeoutMs: 100,
withOverlayHidden: withoutOverlay,
});
expect(result).toMatchObject({ x: 110, y: 70 });
expect(
childCalls
.filter((call) => call.method === "Input.dispatchDragEvent")
.map((call) => call.params),
).toEqual([
expect.objectContaining({ type: "dragEnter", x: 110, y: 70 }),
expect.objectContaining({ type: "dragOver", x: 110, y: 70 }),
expect.objectContaining({ type: "drop", x: 110, y: 70 }),
]);
expect(rootCalls.some((call) => call.method === "Input.dispatchDragEvent")).toBe(false);
});
it("reports a missing native drag command as unsupported before commit", async () => {
const { cdp } = dropCdp({
dispatchError: (type) =>
type === "dragEnter" ? new Error("'Input.dispatchDragEvent' wasn't found") : undefined,
});
const result = await uploadThroughFileDrop({
cdp,
actionTarget: actionTarget(),
files: ["/stage/one.png"],
timeoutMs: 100,
withOverlayHidden: withoutOverlay,
});
expect(result).toMatchObject({
code: "unsupported",
data: {
reason: "upload_mechanism_unsupported",
effect_state: "none",
phase: "drag_enter",
},
});
});
it("cancels an unresolved drag before returning a pre-commit timeout", async () => {
const { cdp, calls } = dropCdp({
dispatchError: (type) => (type === "dragEnter" ? new Promise<never>(() => {}) : undefined),
});
const result = await uploadThroughFileDrop({
cdp,
actionTarget: actionTarget(),
files: ["/stage/one.png"],
timeoutMs: 10,
withOverlayHidden: withoutOverlay,
});
expect(result).toMatchObject({
code: "timeout",
data: { reason: "file_drop_failed", effect_state: "none", phase: "drag_enter" },
});
expect(calls).toContainEqual({
method: "Input.dispatchDragEvent",
params: expect.objectContaining({ type: "dragCancel" }),
});
});
it("marks an unresolved drop unknown and detaches browser state", async () => {
const { cdp, detach } = dropCdp({
dispatchError: (type) => (type === "drop" ? new Promise<never>(() => {}) : undefined),
});
const result = await uploadThroughFileDrop({
cdp,
actionTarget: actionTarget(),
files: ["/stage/one.png"],
timeoutMs: 10,
withOverlayHidden: withoutOverlay,
});
expect(result).toMatchObject({
code: "timeout",
data: { reason: "file_drop_failed", effect_state: "unknown", phase: "drop" },
});
expect(detach).toHaveBeenCalledWith(4);
});
it("routes one exact-target download through a browser-relative capability", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
@@ -847,7 +665,10 @@ describe("file transfer tools", () => {
expect(downloads.cancel).not.toHaveBeenCalled();
});
it("reconciles a download that completes while cancellation is being requested", async () => {
it.each([
["cancel resolves", false],
["cancel reports that completion won", true],
])("reconciles a download that completes while %s", async (_label, cancelThrows) => {
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
@@ -881,7 +702,7 @@ describe("file transfer tools", () => {
onDeterminingFilename,
search: vi.fn().mockResolvedValueOnce([initial]).mockResolvedValueOnce([complete]),
cancel: vi.fn(async () => {
throw new Error("download already complete");
if (cancelThrows) throw new Error("download already complete");
}),
removeFile: vi.fn(async () => {}),
};
@@ -1,573 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import { handleFill } from "../interaction";
interface ScriptParams {
functionDeclaration: string;
arguments?: Array<{ value: unknown }>;
}
async function setup(markup = '<input value="old">') {
document.body.innerHTML = markup;
const element = document.body.firstElementChild as HTMLInputElement;
const manager = new SessionManager({
agentWindow: {
create: async () => 100,
remove: async () => {},
ensureActiveTab: async () => 4,
},
});
const ctx = await manager.start("aa11");
ctx.refStore.set("e1", 12, { tabId: 4 });
const script = vi.fn(async (params: ScriptParams): Promise<unknown> => {
try {
const fn = new Function(`return (${params.functionDeclaration})`)();
return {
result: {
value: await fn.apply(
element,
(params.arguments ?? []).map((arg) => arg.value),
),
},
};
} catch {
return { exceptionDetails: { text: "test page script failed" } };
}
});
// Number/email controls have a browser caret even though their selection
// getters return null. Model that caret instead of assuming it is at the end.
const hiddenCaret = { start: 0, end: 0 };
const moveToEnd = vi.fn(async () => {
if (typeof element.selectionStart === "number") {
element.setSelectionRange(element.value.length, element.value.length);
} else {
hiddenCaret.start = hiddenCaret.end = element.value.length;
}
});
const insert = vi.fn(async (text: string) => {
const focused = document.activeElement as HTMLInputElement;
if (focused.readOnly || focused.disabled) return;
const start = focused.selectionStart ?? hiddenCaret.start;
const end = focused.selectionEnd ?? hiddenCaret.end;
const value = focused.value.slice(0, start) + text + focused.value.slice(end);
focused.value = focused.maxLength >= 0 ? value.slice(0, focused.maxLength) : value;
hiddenCaret.start = hiddenCaret.end = Math.min(start + text.length, focused.value.length);
focused.dispatchEvent(new Event("input", { bubbles: true }));
});
const release = vi.fn(async () => ({}));
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
switch (method) {
case "DOM.describeNode":
return {
node: {
nodeName: element.tagName,
attributes: Array.from(element.attributes).flatMap((attr) => [attr.name, attr.value]),
},
};
case "DOM.scrollIntoViewIfNeeded":
return {};
case "DOM.focus":
element.focus();
return {};
case "DOM.resolveNode":
return { object: { objectId: "fill-target" } };
case "Runtime.callFunctionOn":
return script(params as ScriptParams);
case "Input.insertText":
await insert((params as { text: string }).text);
return {};
case "Input.dispatchKeyEvent":
if ((params as { commands?: string[] }).commands?.includes("moveToEndOfDocument")) {
await moveToEnd();
}
return {};
case "Runtime.releaseObject":
return release();
default:
throw new Error(`unexpected ${method}`);
}
});
return {
element,
hiddenCaret,
moveToEnd,
script,
insert,
release,
send,
fill: (value = "hello", clearBefore = true, signal?: AbortSignal) =>
handleFill(
manager,
{ session_id: "aa11", ref: "e1", value, clear_before: clearBefore },
{
cdp: { send: send as CdpRunner["send"] },
tabsApi: {
get: async (id) => ({ id, windowId: 100, active: true }) as chrome.tabs.Tab,
query: async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab],
},
signal,
},
),
};
}
afterEach(() => {
vi.restoreAllMocks();
document.body.innerHTML = "";
});
describe("fill result verification", () => {
it.each([
"readonly",
"disabled",
])("preserves a %s field before rejecting it", async (attribute) => {
const h = await setup(`<input ${attribute} value="old">`);
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([
"checkbox",
"radio",
"range",
"date",
"file",
])("rejects an unsupported %s input before clearing", async (type) => {
const h = await setup(`<input type="${type}">`);
const before = h.element.value;
expect(await h.fill()).toMatchObject({
code: "invalid_params",
data: { reason: "target_not_fillable" },
});
expect(h.element.value).toBe(before);
expect(h.insert).not.toHaveBeenCalled();
});
it("rejects an overlong value before clearing", async () => {
const h = await setup('<input maxlength="3" value="old">');
expect(await h.fill()).toMatchObject({
code: "invalid_params",
message: /maxlength/,
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
});
it.each([
true,
false,
])("classifies a DOM.focus rejection on a disabled=%s target", async (disabled) => {
const h = await setup(`<input ${disabled ? "disabled" : ""} value="old">`);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
if (method === "DOM.focus") throw new Error("Element is not focusable");
return send(tabId, method, params);
});
expect(await h.fill()).toMatchObject({
code: disabled ? "invalid_params" : "cdp_failed",
data: { reason: disabled ? "target_not_fillable" : "fill_failed" },
});
expect(h.element.value).toBe("old");
expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("fills a background document without requiring foreground browser focus", async () => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
});
it("restores focus once after clearing without typing into another field", async () => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener("input", () => other.focus());
expect(await h.fill()).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("hello");
expect(other.value).toBe("");
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"focus",
"value",
])("rechecks %s after restoring focus, without retrying again", async (action) => {
const h = await setup();
const other = document.createElement("input");
document.body.append(other);
h.element.addEventListener(
"input",
() => {
other.focus();
h.element.addEventListener("focus", () => {
if (action === "focus") queueMicrotask(() => other.focus());
else h.element.value = "changed";
});
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: action === "focus" ? "fill_focus_lost" : "fill_target_changed" },
});
expect(other.value).toBe("");
expect(h.insert).not.toHaveBeenCalled();
expect(h.send.mock.calls.filter(([, method]) => method === "DOM.focus")).toHaveLength(2);
});
it.each([
"remove",
"readonly",
"restore",
"microtask-restore",
])("stops when clearing causes the page to %s the target", async (action) => {
const h = await setup();
h.element.addEventListener("input", () => {
if (action === "remove") h.element.remove();
if (action === "readonly") h.element.readOnly = true;
if (action === "restore") h.element.value = "restored";
if (action === "microtask-restore")
queueMicrotask(() => {
h.element.value = "restored";
});
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
expect(h.insert).not.toHaveBeenCalled();
});
it("fails when insertText is acknowledged without changing the value", async () => {
const h = await setup();
h.insert.mockImplementation(async () => {});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.release).toHaveBeenCalledOnce();
});
it("detects truncation when maxlength changes after preparation", async () => {
const h = await setup();
h.element.addEventListener(
"input",
() => {
h.element.maxLength = 3;
},
{ once: true },
);
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("hel");
});
it.each([
"input",
"change",
"microtask-change",
])("detects a value reverted by the %s handler", async (event) => {
const h = await setup();
h.element.addEventListener(event === "microtask-change" ? "change" : event, () => {
if (event === "microtask-change")
queueMicrotask(() => {
h.element.value = "";
});
else h.element.value = "";
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed", message: /expected value/ });
});
it("compares content rather than length without including field values in the error", async () => {
const h = await setup('<input type="password">');
h.element.addEventListener("change", () => {
h.element.value = "other-secret";
});
const result = await h.fill("given-secret");
expect(result).toMatchObject({ code: "cdp_failed", message: /expected value/ });
expect(JSON.stringify(result)).not.toMatch(/given-secret|other-secret/);
});
it("does not report success for a target replaced by a change handler", async () => {
const h = await setup();
h.element.addEventListener("change", () => h.element.replaceWith(h.element.cloneNode()));
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_target_changed" },
});
});
it.each([
1, 2, 3, 4, 5,
])("rejects script exceptions at phase %s and releases the target", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { exceptionDetails: { text: "page secret" } } : run(params),
);
const result = await h.fill();
expect(result).toMatchObject({ code: "cdp_failed", data: { reason: "fill_failed" } });
expect(JSON.stringify(result)).not.toContain("page secret");
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it.each([1, 2, 3, 5])("rejects missing results at phase %s", async (phase) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const run = h.script.getMockImplementation()!;
let count = 0;
h.script.mockImplementation(async (params) =>
++count === phase ? { result: { type: "undefined" } } : run(params),
);
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (phase <= 3) expect(h.insert).not.toHaveBeenCalled();
});
it.each(["input", "change"])("checks state after nested microtasks from %s", async (event) => {
const h = await setup();
h.element.addEventListener(event, () => {
queueMicrotask(() =>
queueMicrotask(() => {
h.element.value = "reverted";
}),
);
});
expect(await h.fill()).toMatchObject({ code: "cdp_failed" });
if (event === "input") expect(h.insert).not.toHaveBeenCalled();
});
it.each([
"<input>",
'<input type="password">',
"<textarea></textarea>",
])("verifies successful text in %s", async (markup) => {
const h = await setup(markup);
expect(await h.fill("你好🙂")).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("你好🙂");
expect(h.release).toHaveBeenCalledOnce();
});
it("verifies append against the old value plus the requested text", async () => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(3, 3);
expect(await h.fill("!", false)).toMatchObject({ value_length: 4 });
expect(h.element.value).toBe("old!");
});
it.each([
["<textarea></textarea>", "a\r\nb", "a\nb"],
["<input>", "a\nb", "a b"],
["<input>", "a\r\nb", "a b"],
[
'<input type="email" multiple>',
" a@example.com , b@example.com ",
"a@example.com,b@example.com",
],
])("accepts native normalization for %s", async (markup, requested, normalized) => {
if (markup.startsWith("<textarea")) {
// Happy DOM does not implement Chrome's textarea line-ending normalization.
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")!.set!;
vi.spyOn(HTMLTextAreaElement.prototype, "value", "set").mockImplementation(function (
this: HTMLTextAreaElement,
value,
) {
setter.call(this, value.replace(/\r\n?/g, "\n"));
});
}
const h = await setup(markup);
h.insert.mockImplementation(async () => {
h.element.value = normalized;
});
expect(await h.fill(requested)).toMatchObject({ value_length: normalized.length });
});
it("does not count an editable padding break as an extra typed character", async () => {
const h = await setup('<div contenteditable="true" tabindex="0"></div>');
// These two layout/editability properties are missing in Happy DOM.
let innerText = "";
Object.defineProperty(h.element, "isContentEditable", { get: () => true });
Object.defineProperty(h.element, "innerText", { get: () => innerText });
h.insert.mockImplementation(async () => {
h.element.innerHTML = "one<div><br></div>";
innerText = "one\n\n";
});
expect(await h.fill("one\n")).toMatchObject({ value_length: 4 });
});
it.each([
[0, 0],
[1, 1],
[0, 3],
])("appends after moving selection %s:%s to the end", async (start, end) => {
const h = await setup();
h.element.focus();
h.element.setSelectionRange(start, end);
expect(await h.fill("🙂", false)).toMatchObject({ value_length: 5 });
expect(h.element.value).toBe("old🙂");
});
it.each(
["number", "email"].flatMap((type) =>
[
[0, 0],
[1, 1],
[0, 2],
].map(([start, end]) => ({ type, start, end })),
),
)("appends to $type from selection $start:$end without using its unsupported API", async ({
type,
start,
end,
}) => {
const h = await setup(`<input type="${type}" value="12">`);
Object.assign(h.hiddenCaret, { start, end });
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
const selection = vi.spyOn(h.element, "setSelectionRange").mockImplementation(() => {
throw new DOMException("unsupported", "InvalidStateError");
});
expect(await h.fill("3", false)).toMatchObject({ value_length: 3 });
expect(h.element.value).toBe("123");
expect(selection).not.toHaveBeenCalled();
});
it.each([
"number",
"email",
])("does not report successful %s append when the caret command is ignored", async (type) => {
const h = await setup(`<input type="${type}" value="12">`);
vi.spyOn(h.element, "selectionStart", "get").mockReturnValue(null);
vi.spyOn(h.element, "selectionEnd", "get").mockReturnValue(null);
h.moveToEnd.mockResolvedValueOnce(undefined);
expect(await h.fill("3", false)).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("312");
expect(h.insert).toHaveBeenCalledOnce();
});
it("rejects invalid number text before clearing", async () => {
const h = await setup('<input type="number" value="12">');
expect(await h.fill("abc")).toMatchObject({
code: "invalid_params",
data: { reason: "fill_value_invalid" },
});
expect(h.element.value).toBe("12");
expect(h.insert).not.toHaveBeenCalled();
});
it("reports page formatting as unconfirmed without retrying the write", async () => {
const h = await setup();
h.element.addEventListener("change", () => {
h.element.value = h.element.value.toUpperCase();
});
expect(await h.fill()).toMatchObject({
code: "cdp_failed",
data: { reason: "fill_value_mismatch" },
});
expect(h.element.value).toBe("HELLO");
expect(h.insert).toHaveBeenCalledOnce();
});
it.each([
["focus", false],
["focus", true],
["value", false],
["value", true],
["cancel", false],
["cancel", true],
["throw", false],
["throw", true],
] as const)("stops when positioning triggers %s (clear=%s) and releases the key", async (action, clearBefore) => {
const h = await setup();
vi.spyOn(document, "hasFocus").mockReturnValue(false);
const controller = new AbortController();
const other = document.createElement("input");
document.body.append(other);
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const reply = await send(tabId, method, params);
if (
method === "Input.dispatchKeyEvent" &&
(params as { type: string }).type === "rawKeyDown"
) {
if (action === "focus") other.focus();
if (action === "value") h.element.value = "changed";
if (action === "cancel") controller.abort();
if (action === "throw") throw new Error("dispatch failed");
}
return reply;
});
const result = await h.fill("!", clearBefore, controller.signal);
expect(result).toMatchObject({ code: action === "cancel" ? "cancelled" : "cdp_failed" });
expect(h.insert).not.toHaveBeenCalled();
expect(other.value).toBe("");
expect(h.send).toHaveBeenCalledWith(
4,
"Input.dispatchKeyEvent",
expect.objectContaining({ type: "keyUp" }),
);
expect(h.release).toHaveBeenCalledOnce();
});
it.each([true, false])("verifies an empty request with clear_before=%s", async (clearBefore) => {
const h = await setup();
expect(await h.fill("", clearBefore)).toMatchObject({ value_length: clearBefore ? 0 : 3 });
expect(h.element.value).toBe(clearBefore ? "" : "old");
expect(h.insert).not.toHaveBeenCalled();
});
it("cancels after insertion without sending change events", async () => {
const h = await setup();
const controller = new AbortController();
h.insert.mockImplementation(async () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.script).toHaveBeenCalledTimes(2);
expect(h.release).toHaveBeenCalledOnce();
});
it("cancels after resolving the remote object without clearing the field", async () => {
const h = await setup();
const controller = new AbortController();
const send = h.send.getMockImplementation()!;
h.send.mockImplementation(async (tabId, method, params) => {
const result = await send(tabId, method, params);
if (method === "DOM.resolveNode") controller.abort();
return result;
});
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.element.value).toBe("old");
expect(h.script).not.toHaveBeenCalled();
expect(h.release).toHaveBeenCalledOnce();
});
it("honors cancellation during verification", async () => {
const h = await setup();
const controller = new AbortController();
h.element.addEventListener("change", () => controller.abort());
expect(await h.fill("hello", true, controller.signal)).toMatchObject({ code: "cancelled" });
expect(h.release).toHaveBeenCalledOnce();
});
it("keeps the verified outcome when releasing the remote object fails", async () => {
const h = await setup();
h.release.mockRejectedValueOnce(new Error("target closed"));
expect(await h.fill()).toMatchObject({ value_length: 5 });
});
});
@@ -169,7 +169,6 @@ describe("frame geometry projection", () => {
expect(geometry).toMatchObject({
topBounds: { x: 100, y: 100, width: 200, height: 100 },
actionPoint: { x: 200, y: 150 },
targetActionPoint: { x: 100, y: 50 },
});
});
@@ -262,7 +261,6 @@ describe("frame geometry projection", () => {
expect(geometry).toMatchObject({
topBounds: { x: 100, y: 100, width: 200, height: 100 },
actionPoint: { x: 200, y: 150 },
targetActionPoint: { x: 200, y: 150 },
});
});
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -540,21 +540,6 @@ describe("handleHover", () => {
});
});
function successfulFillScript(params: unknown) {
const script = params as { arguments?: Array<{ value: unknown }>; functionDeclaration: string };
const args = script.arguments ?? [];
return {
result: {
value:
args.length === 2
? { before: "", expected: args[0].value }
: script.functionDeclaration.startsWith("function(expected)")
? { connected: true, matches: true, valueLength: String(args[0].value).length }
: "ready",
},
};
}
describe("handleFill", () => {
it("returns not_found for unknown ref", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
@@ -601,8 +586,7 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-1" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -616,12 +600,13 @@ describe("handleFill", () => {
expect(res.used_ref).toBe("e1");
const insert = fake.sent.find((c) => c.method === "Input.insertText");
expect(insert?.params).toEqual({ text: "hello" });
// Foreground replacement needs no extra caret-positioning round trip.
// clear_before defaults to true → callFunctionOn invoked once to
// clear, once to fire input/change after typing.
const callFns = fake.sent.filter((c) => c.method === "Runtime.callFunctionOn");
expect(callFns).toHaveLength(4);
expect(callFns.length).toBeGreaterThanOrEqual(2);
});
it("passes clear_before=false to preparation and verifies the result", async () => {
it("clear_before=false skips the wipe call", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.refStore.set("e1", 1, { tabId: 4 });
@@ -633,12 +618,12 @@ describe("handleFill", () => {
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-2" } }),
"Runtime.callFunctionOn": (p) => {
const args = (p as { arguments?: Array<{ value: unknown }> }).arguments ?? [];
if (args.length === 2) expect(args[1].value).toBe(false);
return successfulFillScript(p);
const fn = (p as { functionDeclaration?: string }).functionDeclaration ?? "";
// No "this.value = ''" clearing on a no-clear path; only the
// post-input dispatchEvent.
expect(fn).not.toMatch(/this\.value\s*=\s*''/);
return { result: { type: "undefined" } };
},
"Input.dispatchKeyEvent": () => ({}),
"Runtime.releaseObject": () => ({}),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -665,8 +650,7 @@ describe("handleFill", () => {
"DOM.scrollIntoViewIfNeeded": () => ({}),
"DOM.focus": () => ({}),
"DOM.resolveNode": () => ({ object: { objectId: "obj-3" } }),
"Runtime.callFunctionOn": successfulFillScript,
"Runtime.releaseObject": () => ({}),
"Runtime.callFunctionOn": () => ({ result: { type: "undefined" } }),
"Input.insertText": () => ({}),
});
const res = await handleFill(
@@ -1,266 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { BrowserNavigationApi } from "../browser-navigation";
import { handleNavigate, handleReload } from "../navigation";
import type { CdpRunner } from "../shared";
const denied = "Cannot access a chrome-extension:// URL of different extension";
const url = "https://example.test/recovered";
function event<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
listeners,
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
fire: (...args: Parameters<T>) => {
for (const listener of [...listeners]) listener(...args);
},
};
}
async function fixture() {
const manager = new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 4),
},
});
await manager.start("test");
type Listener = Parameters<BrowserNavigationApi["onCommitted"]["addListener"]>[0];
const events = {
onCommitted: event<Listener>(),
onDOMContentLoaded: event<Listener>(),
onCompleted: event<Listener>(),
onErrorOccurred: event<Listener>(),
};
const cdpEvents = event<Parameters<NonNullable<CdpRunner["onEvent"]>>[0]>();
const state = { blocked: true, destinationBlocked: false, emitIdle: true };
const send = vi.fn(async (_tabId: number, method: string) => {
if (state.blocked) throw new Error(denied);
if (method === "Page.getFrameTree") {
return { frameTree: { frame: { id: "main", loaderId: "new-loader" } } };
}
if (method === "Runtime.evaluate") return { result: { value: "complete" } };
if (method === "Page.setLifecycleEventsEnabled" && state.emitIdle) {
cdpEvents.fire({ tabId: 4 }, "Page.lifecycleEvent", {
frameId: "main",
loaderId: "new-loader",
name: "networkIdle",
});
}
if (method === "Page.navigate") return { frameId: "main", loaderId: "new-loader" };
return {};
});
const cdp: CdpRunner = {
send: send as CdpRunner["send"],
onEvent: (listener) => {
cdpEvents.addListener(listener);
return { dispose: () => cdpEvents.removeListener(listener) };
},
};
const main = { tabId: 4, frameId: 0, documentId: "new-document" };
const navigate = async () => {
state.blocked = state.destinationBlocked;
events.onCommitted.fire(main);
events.onDOMContentLoaded.fire(main);
events.onCompleted.fire(main);
};
const browserNavigation = { ...events, update: vi.fn(navigate), reload: vi.fn(navigate) };
const tab = { id: 4, windowId: 100, active: true, url } as chrome.tabs.Tab;
const tabsApi = { get: vi.fn(async () => tab), query: vi.fn(async () => [tab]) };
const deps = { cdp, tabsApi, browserNavigation, defaultTimeoutMs: 1000 };
const expectCleanedUp = () => {
for (const entry of Object.values(events)) expect(entry.listeners.size).toBe(0);
expect(cdpEvents.listeners.size).toBe(0);
};
return { manager, deps, state, send, events, main, cdpEvents, expectCleanedUp };
}
describe("navigation after Chrome denies extension-frame access", () => {
it.each([
"load",
"commit",
"domcontentloaded",
"networkidle",
] as const)("recovers the same tab with the requested %s checkpoint", async (waitUntil) => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: waitUntil,
},
f.deps,
);
expect(result).toMatchObject({ tab_id: 4, final_url: url, reached: waitUntil });
expect(f.deps.browserNavigation.update).toHaveBeenCalledExactlyOnceWith(4, { url });
expect(f.send.mock.calls.some(([, method]) => method === "Page.navigate")).toBe(false);
expect(f.send).toHaveBeenCalledWith(4, "Page.enable", {});
f.expectCleanedUp();
});
it("recovers reload through tabs.reload and preserves hard reload", async () => {
const f = await fixture();
const result = await handleReload(f.manager, { session_id: "test", hard: true }, f.deps);
expect(result).toMatchObject({ tab_id: 4, reached: "load" });
expect(f.deps.browserNavigation.reload).toHaveBeenCalledExactlyOnceWith(4, {
bypassCache: true,
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("reports the restriction when reload leaves the conflicting frame present", async () => {
const f = await fixture();
f.state.destinationBlocked = true;
const result = await handleReload(f.manager, { session_id: "test" }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.reload).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("does not fall back for unrelated debugger failures", async () => {
const f = await fixture();
f.send.mockRejectedValue(new Error("Another debugger is already attached"));
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
message: "Another debugger is already attached",
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("does not replay navigation when a dispatched CDP command fails", async () => {
const f = await fixture();
f.state.blocked = false;
const send = f.send.getMockImplementation()!;
f.send.mockImplementation(async (tabId, method) => {
if (method === "Page.navigate") throw new Error(denied);
return send(tabId, method);
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "cdp_extension_access_denied" },
});
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("keeps the Agent Window guard in front of the recovery", async () => {
const f = await fixture();
f.deps.tabsApi.get.mockResolvedValue({
id: 4,
windowId: 200,
active: true,
url,
} as chrome.tabs.Tab);
const result = await handleNavigate(f.manager, { session_id: "test", tab_id: 4, url }, f.deps);
expect(result).toMatchObject({ code: "permission_denied" });
expect(f.send).not.toHaveBeenCalled();
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
});
it("ignores old-document, subframe, and other-tab completion events", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
let settled = false;
const work = handleNavigate(f.manager, { session_id: "test", url }, f.deps).then((result) => {
settled = true;
return result;
});
await vi.waitFor(() => expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce());
f.events.onCompleted.fire(f.main);
f.events.onCommitted.fire({ ...f.main, tabId: 9 });
f.events.onCommitted.fire({ ...f.main, frameId: 1 });
f.events.onCompleted.fire(f.main);
await Promise.resolve();
expect(settled).toBe(false);
f.events.onCommitted.fire(f.main);
f.events.onCompleted.fire({ ...f.main, documentId: "old-document" });
await Promise.resolve();
expect(settled).toBe(false);
f.state.blocked = false;
f.events.onCompleted.fire(f.main);
expect(await work).toMatchObject({ reached: "load" });
f.expectCleanedUp();
});
it("does not infer network idle from a browser load event", async () => {
const f = await fixture();
f.state.emitIdle = false;
const result = await handleNavigate(
f.manager,
{
session_id: "test",
url,
wait_until: "networkidle",
timeout_ms: 10,
},
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("cleans up browser listeners on timeout", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url, timeout_ms: 10 },
f.deps,
);
expect(result).toMatchObject({ reached: "timeout" });
f.expectCleanedUp();
});
it("does not navigate an already-cancelled request", async () => {
const f = await fixture();
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{
...f.deps,
signal: AbortSignal.abort(),
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).not.toHaveBeenCalled();
f.expectCleanedUp();
});
it("cleans up browser listeners when cancelled after dispatch", async () => {
const f = await fixture();
const ac = new AbortController();
f.deps.browserNavigation.update.mockImplementation(async () => {
ac.abort();
});
const result = await handleNavigate(
f.manager,
{ session_id: "test", url },
{ ...f.deps, signal: ac.signal },
);
expect(result).toMatchObject({ code: "cancelled" });
expect(f.deps.browserNavigation.update).toHaveBeenCalledOnce();
f.expectCleanedUp();
});
it("reports browser navigation errors and releases listeners", async () => {
const f = await fixture();
f.deps.browserNavigation.update.mockImplementation(async () => {
f.events.onErrorOccurred.fire({ ...f.main, error: "net::ERR_NAME_NOT_RESOLVED" });
});
const result = await handleNavigate(f.manager, { session_id: "test", url }, f.deps);
expect(result).toMatchObject({ code: "cdp_failed", message: "net::ERR_NAME_NOT_RESOLVED" });
f.expectCleanedUp();
});
});
@@ -21,7 +21,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -28,7 +28,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -1,26 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { handleSessionStop } from "../session";
import type { ChromeTabsApi } from "../shared";
import { type AgentOverlayResetApi, type ChromeWindowsApi, type TabMutationApi } from "../tabs";
/** Build a read-only query api over a FakeState. */
function makeQuery(state: FakeState): ChromeTabsApi {
return {
get: vi.fn(async (id) => {
const t = state.tabs.get(id);
if (!t) throw new Error(`tab ${id} not found`);
return t;
}),
query: vi.fn(async (q: chrome.tabs.QueryInfo) => {
const w = q.windowId;
return Array.from(state.tabs.values()).filter(
(t) => typeof w !== "number" || t.windowId === w,
);
}),
};
}
function fakeAgentWindow(ids: number[]) {
let i = 0;
const create = vi.fn(async () => {
@@ -29,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => 0);
const ensureActiveTab = vi.fn(async () => {});
return { create, remove, ensureActiveTab };
}
@@ -48,9 +30,7 @@ function makeApis(
} {
const tabs: TabMutationApi = {
create: vi.fn(),
remove: vi.fn(async (id: number) => {
state.tabs.delete(id);
}),
remove: vi.fn(async () => {}),
update: vi.fn(async (_id, _p) => undefined),
get: vi.fn(async (id) => {
const t = state.tabs.get(id);
@@ -257,15 +237,11 @@ describe("handleSessionStop with auto-return", () => {
moves: [],
};
const { tabs, windows } = makeApis(state, { moveThrowsFor: new Set([1]) });
const cdp = {
detachSession: vi.fn(async () => {}),
releaseSessionTab: vi.fn(async () => {}),
};
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ cdp, tabManagement: { tabs, windows } },
{ tabManagement: { tabs, windows } },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.return_failures?.map((f) => f.tab_id)).toEqual([1]);
@@ -273,8 +249,6 @@ describe("handleSessionStop with auto-return", () => {
expect(sm.has("aa11")).toBe(true);
expect(ctx.borrowedTabs.has(1)).toBe(true);
expect(ctx.borrowedTabs.has(2)).toBe(false);
expect(cdp.releaseSessionTab).toHaveBeenCalledExactlyOnceWith("aa11", 2);
expect(cdp.detachSession).not.toHaveBeenCalled();
expect(aw.remove).not.toHaveBeenCalled();
});
@@ -293,275 +267,4 @@ describe("handleSessionStop with auto-return", () => {
await handleSessionStop(sm, { session_id: "aa11" }, { tabManagement: { tabs, windows } });
expect(ctx.refStore.isEmpty()).toBe(true);
});
it.each([
"before teardown",
"during its return",
"after its return failed",
"before its turn in the return loop",
])("finishes stopping when a borrowed tab closes %s", async (timing) => {
const aw = fakeAgentWindow([100]);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(1, { tabId: 1, originalWindowId: 200, originalIndex: 0 });
ctx.borrowedTabs.set(2, { tabId: 2, originalWindowId: 200, originalIndex: 1 });
const state: FakeState = {
tabs: new Map([
[1, { id: 1, windowId: 100 } as chrome.tabs.Tab],
[2, { id: 2, windowId: 100 } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const cdp = { detachSession: vi.fn(async () => {}) };
const closeTab = (tabId: number) => {
state.tabs.delete(tabId);
sm.forgetClosedTab(tabId);
};
const baseMove = tabs.move;
tabs.move = vi.fn(async (tabId, props) => {
if (tabId === 1 && timing === "during its return") closeTab(1);
if (tabId === 1 && timing === "after its return failed") {
throw new Error("simulated move failure");
}
if (tabId === 2 && timing === "after its return failed") closeTab(1);
if (tabId === 1 && timing === "before its turn in the return loop") closeTab(2);
if (!state.tabs.has(tabId)) throw new Error(`tab ${tabId} not found`);
return baseMove(tabId, props);
});
if (timing === "before teardown") closeTab(1);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{
cdp,
tabManagement: {
tabs,
windows,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
tabsQuery: makeQuery(state),
},
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
const closedTabId = timing === "before its turn in the return loop" ? 2 : 1;
expect(res.returned_tab_ids).toEqual([closedTabId === 1 ? 2 : 1]);
expect(res.return_failures).toBeUndefined();
expect(ctx.borrowedTabs.size).toBe(0);
expect(sm.has("aa11")).toBe(false);
expect(cdp.detachSession).toHaveBeenCalledWith("aa11");
expect(aw.remove).toHaveBeenCalledWith(100);
if (timing === "before teardown" || timing === "before its turn in the return loop") {
expect(tabs.move).not.toHaveBeenCalledWith(closedTabId, expect.anything());
}
});
});
describe("handleSessionStop window release (issue #57)", () => {
const agentWindowId = 100;
it("releases the window when user-created tabs remain", async () => {
const aw = fakeAgentWindow([agentWindowId]);
// ensureActiveTab returns the home tab id (10). We override the fake to
// return a known id.
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
ctx.agentCreatedTabs.add(12);
// user tab 99 (created via Chrome UI, NOT in agentCreatedTabs)
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
[12, { id: 12, windowId: agentWindowId } as chrome.tabs.Tab],
[99, { id: 99, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBe(true);
// agent tabs + home removed, user tab 99 kept
expect((tabs.remove as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0]).sort()).toEqual([
10, 11, 12,
]);
expect(state.tabs.has(99)).toBe(true);
// window released (dropOnly), not closed
expect(aw.remove).not.toHaveBeenCalled();
expect(sm.has("aa11")).toBe(false);
});
it("closes the window (not release) when no user tabs remain", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
expect(sm.has("aa11")).toBe(false);
});
it("is non-fatal when an agent tab is already gone", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // no longer in state → remove throws
const state: FakeState = {
tabs: new Map([[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
if (id === 11) throw new Error("tab 11 already closed");
state.tabs.delete(id); // home (10) removed normally
});
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// user tab 10? no — 10 is home, removed. window would be empty → close.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalled();
});
it("degrades to close-window when tabsQuery is missing", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows } }, // no tabsQuery
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// No query available → conservatively close the window.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
});
it("degrades to close-window when tabsQuery.query throws", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11);
const state: FakeState = {
tabs: new Map([[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab]]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
const query = makeQuery(state);
(query.query as ReturnType<typeof vi.fn>).mockImplementation(async () => {
throw new Error("query failed");
});
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalled();
});
it("tracks agentCreatedTabs across tab_create / tab_close", async () => {
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
// Simulate handleTabCreate adding, then handleTabClose removing.
ctx.agentCreatedTabs.add(11);
expect(ctx.agentCreatedTabs.has(11)).toBe(true);
ctx.agentCreatedTabs.delete(11); // as handleTabClose does on success
expect(ctx.agentCreatedTabs.has(11)).toBe(false);
// Unknown id delete is a safe no-op (user-closed tab).
expect(() => ctx.agentCreatedTabs.delete(999)).not.toThrow();
});
it("closes the window (not release) when an agent tab fails to close", async () => {
// Regression: a leaked agent tab (still in agentCreatedTabs, but
// still present because Step 4 remove() threw) must NOT be mistaken
// for a user tab. Otherwise the window would be released (dropOnly)
// and the agent tab would leak (issue #57 regression).
const aw = fakeAgentWindow([agentWindowId]);
aw.ensureActiveTab = vi.fn(async () => 10);
const sm = new SessionManager({ agentWindow: aw });
const ctx = await sm.start("aa11");
ctx.agentCreatedTabs.add(11); // Step 4 remove() will throw for this tab
const state: FakeState = {
tabs: new Map([
[10, { id: 10, windowId: agentWindowId } as chrome.tabs.Tab],
[11, { id: 11, windowId: agentWindowId } as chrome.tabs.Tab],
]),
windowsClosed: new Set(),
moves: [],
};
const { tabs, windows } = makeApis(state);
(tabs.remove as ReturnType<typeof vi.fn>).mockImplementation(async (id: number) => {
if (id === 11) throw new Error("tab 11 failed to close");
state.tabs.delete(id); // home (10) removed normally; 11 lingers
});
const query = makeQuery(state);
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{ tabManagement: { tabs, windows }, tabsQuery: query },
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
// The leaked agent tab must NOT keep the window open.
expect(res.window_released).toBeFalsy();
expect(aw.remove).toHaveBeenCalledWith(agentWindowId);
expect(sm.has("aa11")).toBe(false);
});
});
@@ -12,7 +12,7 @@ function fakeAgentWindow() {
return {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
},
remove: async () => {},
ensureActiveTab: async () => 1,
ensureActiveTab: async () => {},
};
}
+1 -181
View File
@@ -1,7 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpDebuggerApi, ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { SessionManager } from "@/session-manager/manager";
import { handleHover } from "../interaction";
import {
type AgentOverlayResetApi,
type ChromeWindowsApi,
@@ -25,7 +23,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -223,7 +221,6 @@ describe("handleTabCreate", () => {
active: true,
});
expect(res).toMatchObject({ tab_id: 10, window_id: 100 });
expect(sm.get("aa11")?.agentCreatedTabs.has(10)).toBe(true);
});
it("forwards explicit url + index + active=false", async () => {
@@ -259,41 +256,6 @@ describe("handleTabCreate", () => {
const res = await handleTabCreate(sm, { session_id: "aa11", index: -1 }, { tabs: api });
expect(res).toMatchObject({ code: "invalid_params" });
});
it("does not claim any tab when chrome.tabs.create fails", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
spies.create.mockRejectedValueOnce(new Error("chrome.tabs.create failed"));
const res = await handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
expect(res).toMatchObject({ code: "protocol_error" });
expect(sm.get("aa11")?.agentCreatedTabs).toEqual(new Set([1]));
});
it("claims the returned id even when a user tab appears during tab creation", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
const state: FakeTabState = { tabs: new Map(), nextTabId: 10, windowsClosed: new Set() };
const { api, spies } = makeTabMutationApi(state);
let resolveCreate!: (tab: chrome.tabs.Tab) => void;
const createResult = new Promise<chrome.tabs.Tab>((resolve) => {
resolveCreate = resolve;
});
spies.create.mockImplementationOnce(() => createResult);
const pending = handleTabCreate(sm, { session_id: "aa11" }, { tabs: api });
// A user can create an unrelated same-window tab while Chrome is still
// resolving the agent request. There is no pending slot for it to steal.
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
resolveCreate({ id: 10, windowId: 100, active: true } as chrome.tabs.Tab);
await expect(pending).resolves.toMatchObject({ tab_id: 10 });
expect(ctx.agentCreatedTabs.has(10)).toBe(true);
expect(ctx.agentCreatedTabs.has(99)).toBe(false);
});
});
describe("handleTabClose", () => {
@@ -549,148 +511,6 @@ describe("handleTabBorrow", () => {
});
describe("handleTabReturn", () => {
it.each([
"not_found",
"cdp_failed",
])("detaches after selector resolution fails with %s", async (code) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const debuggerApi = {
attach: vi.fn(async () => {}),
detach: vi.fn(async () => {}),
sendCommand: vi.fn(async (_target: unknown, method: string) => {
if (method === "DOM.getDocument") return { root: { nodeId: 1 } };
if (method === "DOM.querySelector") {
if (code === "cdp_failed") throw new Error("selector query failed");
return { nodeId: 0 };
}
return {};
}),
onEvent: { addListener: vi.fn(), removeListener: vi.fn() },
onDetach: { addListener: vi.fn(), removeListener: vi.fn() },
};
const cdp = new ChromiumCdp(debuggerApi as unknown as CdpDebuggerApi);
const hover = await handleHover(
sm,
{ session_id: "aa11", tab_id: 7, selector: "#missing" },
{
cdp,
tabsApi: { get: api.get, query: vi.fn(async () => []) },
},
);
expect(hover).toMatchObject({ code });
expect(cdp.isAttached(7)).toBe(true);
const returned = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
cdp,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect(returned).toMatchObject({ returned_to_window_id: 200 });
expect(ctx.borrowedTabs.has(7)).toBe(false);
expect(cdp.isAttached(7)).toBe(false);
expect(debuggerApi.detach).toHaveBeenCalledExactlyOnceWith({ tabId: 7 });
cdp.dispose();
});
it("honours cancellation while preparing a validated return", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windows } = makeWindowsApi(state);
const controller = new AbortController();
const beforeReturn = vi.fn(async () => {
controller.abort();
});
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows,
signal: controller.signal,
beforeReturn,
},
);
expect(result).toMatchObject({ code: "cancelled" });
expect(beforeReturn).toHaveBeenCalledExactlyOnceWith("aa11", 7);
expect(spies.move).not.toHaveBeenCalled();
expect(ctx.borrowedTabs.has(7)).toBe(true);
});
it.each([
"original",
"fallback",
"cleanup_failed",
"failed",
"cancelled",
])("releases CDP only after a successful return (%s)", async (mode) => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
ctx.borrowedTabs.set(7, { tabId: 7, originalWindowId: 200, originalIndex: 4 });
const state: FakeTabState = {
tabs: new Map([[7, { id: 7, windowId: 100 } as chrome.tabs.Tab]]),
nextTabId: 50,
windowsClosed: new Set(),
};
const { api, spies } = makeTabMutationApi(state);
const { api: windowsApi } = makeWindowsApi(state, { lastFocused: 500 });
const success = mode !== "failed" && mode !== "cancelled";
if (mode === "fallback") spies.move.mockRejectedValueOnce(new Error("original move failed"));
if (mode === "failed") spies.move.mockRejectedValue(new Error("move failed"));
let windowAtRelease: number | undefined;
const cdp = {
releaseSessionTab: vi.fn(async () => {
windowAtRelease = state.tabs.get(7)?.windowId;
if (mode === "cleanup_failed") throw new Error("debugger release failed");
}),
};
const controller = new AbortController();
if (mode === "cancelled") controller.abort();
const result = await handleTabReturn(
sm,
{ session_id: "aa11", tab_id: 7 },
{
tabs: api,
windows: windowsApi,
cdp,
signal: controller.signal,
agentOverlayReset: { resetAgentOverlays: vi.fn(async () => {}) },
},
);
expect("code" in result).toBe(!success);
expect(ctx.borrowedTabs.has(7)).toBe(!success);
expect(cdp.releaseSessionTab).toHaveBeenCalledTimes(success ? 1 : 0);
if (success) {
expect(cdp.releaseSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(windowAtRelease).toBe(mode === "fallback" ? 500 : 200);
expect(spies.move).toHaveBeenCalledTimes(mode === "fallback" ? 2 : 1);
}
});
it("returns the tab to its original window/index and clears borrowedTabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
const ctx = await sm.start("aa11");
@@ -12,7 +12,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => 1),
ensureActiveTab: vi.fn(async () => {}),
};
}
@@ -11,7 +11,7 @@ function fakeAgentWindow(ids: number[]) {
return id;
});
const remove = vi.fn(async () => {});
const ensureActiveTab = vi.fn(async () => 1);
const ensureActiveTab = vi.fn(async () => {});
return { create, remove, ensureActiveTab };
}
@@ -1,110 +0,0 @@
import type { RpcError, WaitUntil } from "@/transport/types";
import { cdpError } from "./errors";
interface NavigationEvent {
tabId: number;
frameId: number;
documentId?: string;
error?: string;
}
interface NavigationEvents {
addListener(listener: (details: NavigationEvent) => void): void;
removeListener(listener: (details: NavigationEvent) => void): void;
}
/** Normal browser navigation remains available when chrome.debugger is denied. */
export interface BrowserNavigationApi {
update(tabId: number, props: { url: string }): Promise<unknown>;
reload(tabId: number, props: { bypassCache: boolean }): Promise<void>;
onCommitted: NavigationEvents;
onDOMContentLoaded: NavigationEvents;
onCompleted: NavigationEvents;
onErrorOccurred: NavigationEvents;
}
export const chromeBrowserNavigationApi: BrowserNavigationApi = {
update: (tabId, props) => chrome.tabs.update(tabId, props),
reload: (tabId, props) => chrome.tabs.reload(tabId, props),
get onCommitted() {
return chrome.webNavigation.onCommitted;
},
get onDOMContentLoaded() {
return chrome.webNavigation.onDOMContentLoaded;
},
get onCompleted() {
return chrome.webNavigation.onCompleted;
},
get onErrorOccurred() {
return chrome.webNavigation.onErrorOccurred;
},
};
export type NavigationOutcome =
| { reached: "match" | "timeout" | "cancelled"; lastLifecycle?: string }
| { reached: "failed"; error: RpcError };
/** Subscribe before dispatch and ignore the departing document's load events. */
export async function navigateWithBrowserApi(
api: BrowserNavigationApi,
tabId: number,
action: () => Promise<unknown>,
waitUntil: Exclude<WaitUntil, "networkidle">,
timeoutMs: number,
signal?: AbortSignal,
): Promise<NavigationOutcome> {
if (signal?.aborted) return { reached: "cancelled" };
if (timeoutMs <= 0) return { reached: "timeout" };
let committed = false;
let documentId: string | undefined;
let lastLifecycle: string | undefined;
let finish!: (outcome: NavigationOutcome) => void;
const outcome = new Promise<NavigationOutcome>((resolve) => {
finish = resolve;
});
const isMainFrame = (details: NavigationEvent) =>
details.tabId === tabId && details.frameId === 0;
const onCommitted = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
committed = true;
documentId = details.documentId;
lastLifecycle = "commit";
if (waitUntil === "commit") finish({ reached: "match", lastLifecycle });
};
const onDOMContentLoaded = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
lastLifecycle = "DOMContentLoaded";
if (waitUntil === "domcontentloaded") finish({ reached: "match", lastLifecycle });
};
const onCompleted = (details: NavigationEvent) => {
if (!isMainFrame(details) || !committed || (documentId && details.documentId !== documentId))
return;
finish({ reached: "match", lastLifecycle: "load" });
};
const onError = (details: NavigationEvent) => {
if (!isMainFrame(details)) return;
finish({ reached: "failed", error: cdpError(details.error ?? "browser navigation failed") });
};
const onAbort = () => finish({ reached: "cancelled", lastLifecycle });
const subscriptions = [
[api.onCommitted, onCommitted],
[api.onDOMContentLoaded, onDOMContentLoaded],
[api.onCompleted, onCompleted],
[api.onErrorOccurred, onError],
] as const;
for (const [event, listener] of subscriptions) event.addListener(listener);
signal?.addEventListener("abort", onAbort, { once: true });
const timer = setTimeout(() => finish({ reached: "timeout", lastLifecycle }), timeoutMs);
try {
if (signal?.aborted) return { reached: "cancelled" };
await action();
return await outcome;
} catch (error) {
return { reached: "failed", error: cdpError(error) };
} finally {
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
for (const [event, listener] of subscriptions) event.removeListener(listener);
}
}
+6 -33
View File
@@ -36,7 +36,6 @@ import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { classifyCdpError } from "./errors";
import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop";
import { handleClick, handleFill, handleHover, handlePress, handleSelect } from "./interaction";
@@ -70,7 +69,6 @@ import {
import { chromeTabsApi, lookupSession, resolveTargetTab } from "./shared";
import {
type BorrowConfirmationApprover,
chromeTabMutationApi,
handleTabBorrow,
handleTabClose,
handleTabCreate,
@@ -120,8 +118,6 @@ export interface DispatcherDeps {
onSessionsChanged?: () => void;
/** Invoked before a tool that dispatches page input or mutates browser state is forwarded. */
onBrowserControlResumed?: (sessionId: string) => void;
/** Invoked after a tab is explicitly claimed so its overlay can be refreshed immediately. */
onAgentTabClaimed?: (tabId: number, windowId: number) => void;
/** User approval for `tool.tab_borrow` (overlay in content script). */
approveBorrow?: BorrowConfirmationApprover;
/** i18n notification copy for `tool.request_help` (resolved per-call). */
@@ -151,7 +147,6 @@ export class ToolDispatcher {
private readonly recording?: RecordRuntimeDeps;
private readonly onSessionsChanged?: () => void;
private readonly onBrowserControlResumed?: (sessionId: string) => void;
private readonly onAgentTabClaimed?: (tabId: number, windowId: number) => void;
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
private subscription: { dispose(): void } | null = null;
@@ -172,7 +167,6 @@ export class ToolDispatcher {
this.recording = deps.recording;
this.onSessionsChanged = deps.onSessionsChanged;
this.onBrowserControlResumed = deps.onBrowserControlResumed;
this.onAgentTabClaimed = deps.onAgentTabClaimed;
this.approveBorrow = deps.approveBorrow;
this.helpNotificationCopy = deps.helpNotificationCopy;
}
@@ -240,7 +234,7 @@ export class ToolDispatcher {
if (sessionId) this.onBrowserControlResumed?.(sessionId);
const result = await this.invoke(req, ac.signal);
if (isRpcError(result)) {
body = { id: req.id, error: classifyCdpError(result) };
body = { id: req.id, error: result };
} else {
body = { id: req.id, result };
if (req.method === "tool.session_start") {
@@ -308,25 +302,13 @@ export class ToolDispatcher {
await this.releaseHoverLatch((req.params as SessionStopParams).session_id);
return handleSessionStop(this.sessions, req.params as SessionStopParams, {
cdp: this.cdp,
// Must be wired in production: the agent-tab cleanup and the
// window-release decision (issue #57) read these deps directly
// and silently no-op when they are absent.
tabManagement: { tabs: chromeTabMutationApi },
tabsQuery: chromeTabsApi,
signal,
});
}
case "tool.tab_list":
return handleTabList(this.sessions, req.params as TabListParams, chromeTabsApi, signal);
case "tool.tab_create": {
const result = await handleTabCreate(this.sessions, req.params as TabCreateParams, {
signal,
});
if (!isRpcError(result)) {
this.onAgentTabClaimed?.(result.tab_id, result.window_id);
}
return result;
}
case "tool.tab_create":
return handleTabCreate(this.sessions, req.params as TabCreateParams, { signal });
case "tool.tab_close":
return this.withHoverReleaseForRequest(
req.params as TabCloseParams,
@@ -335,22 +317,13 @@ export class ToolDispatcher {
);
case "tool.tab_select":
return handleTabSelect(this.sessions, req.params as TabSelectParams, { signal });
case "tool.tab_borrow": {
const result = await handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
case "tool.tab_borrow":
return handleTabBorrow(this.sessions, req.params as TabBorrowParams, {
signal,
approveBorrow: this.approveBorrow,
});
if (!isRpcError(result)) {
this.onAgentTabClaimed?.(result.tab_id, result.agent_window_id);
}
return result;
}
case "tool.tab_return":
return handleTabReturn(this.sessions, req.params as TabReturnParams, {
signal,
cdp: this.cdp,
beforeReturn: (sessionId, tabId) => this.releaseHoverLatch(sessionId, tabId),
});
return handleTabReturn(this.sessions, req.params as TabReturnParams, { signal });
case "tool.window_resize":
return handleWindowResize(
this.sessions,
+20 -9
View File
@@ -9,6 +9,8 @@ import { type CdpRunner, isRpcError } from "./shared";
const CORRELATION_GRACE_MS = 750;
const UNIQUE_SETTLE_MS = 50;
const CLEANUP_RECONCILE_MS = 1_000;
const CLEANUP_POLL_MS = 25;
const SIZE_POLL_MS = 250;
type DeterminingFilenameListener = (
@@ -110,26 +112,35 @@ function captureError(
async function cleanupClaimedDownload(downloads: DownloadsApi, downloadId: number): Promise<void> {
const lookup = async () => (await downloads.search({ id: downloadId }))[0];
const item = await lookup();
let item = await lookup();
if (!item || item.state === "interrupted") return;
if (item.state === "complete") {
await downloads.removeFile(downloadId);
return;
}
let cancelError: unknown;
try {
await downloads.cancel(downloadId);
} catch (cancelError) {
// Completion can win the race after the lookup but before cancellation.
// Reconcile against Chrome's authoritative state before declaring cleanup
// failed so every terminal state has one explicit cleanup path.
const reconciled = await lookup();
if (!reconciled || reconciled.state === "interrupted") return;
if (reconciled.state === "complete") {
} catch (error) {
cancelError = error;
}
const deadline = Date.now() + CLEANUP_RECONCILE_MS;
while (true) {
item = await lookup();
if (!item || item.state === "interrupted") return;
if (item.state === "complete") {
await downloads.removeFile(downloadId);
return;
}
throw cancelError;
if (Date.now() >= deadline) {
if (cancelError instanceof Error) throw cancelError;
throw new Error("download remained in progress after cancellation");
}
await new Promise((resolve) =>
setTimeout(resolve, Math.min(CLEANUP_POLL_MS, Math.max(1, deadline - Date.now()))),
);
}
}
-28
View File
@@ -13,34 +13,6 @@ import type {
export type { RpcErrorData, RpcErrorReason };
/** Chrome checks the whole tab's frame tree, including other extensions' frames. */
export function isCdpExtensionAccessDenied(error: unknown): boolean {
const message = error instanceof Error ? error.message : String(error);
return message.includes("Cannot access a chrome-extension:// URL of different extension");
}
/** Preserve more specific reasons, especially file-transfer outcome/cleanup details. */
export function classifyCdpError(error: RpcError): RpcError {
if (
error.code !== "cdp_failed" ||
error.data?.reason ||
!isCdpExtensionAccessDenied(error.message)
) {
return error;
}
return {
...error,
data: { ...error.data, reason: "cdp_extension_access_denied" },
};
}
export function cdpError(error: unknown): RpcError {
return classifyCdpError({
code: "cdp_failed",
message: error instanceof Error ? error.message : String(error),
});
}
export interface TransferErrorOptions {
effectState: TransferEffectState;
phase: string;
@@ -1,319 +0,0 @@
// One native file-drop transaction bound to an explicit page target. CDP drag
// events are sent to the target that owns the node: root targets use top-level
// coordinates, while OOPIF targets require coordinates local to their session.
import type { RpcError, TransferEffectState } from "@/transport/types";
import { transferError } from "./errors";
import { resolveNodeGeometry } from "./frame-geometry";
import type { ResolvedActionTarget } from "./interaction";
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
import { BoundedWaitError, waitBounded } from "./transfer-transaction";
const CLEANUP_TIMEOUT_MS = 1_000;
type FileDropPhase = "resolve_target" | "drag_enter" | "drag_over" | "drop" | "cleanup";
interface RuntimeReply {
result?: { value?: unknown; objectId?: string };
exceptionDetails?: { text?: string; exception?: { description?: string } };
}
interface DragData {
items: [];
files: string[];
dragOperationsMask: number;
}
export interface FileDropTransactionOptions {
cdp: CdpRunner;
actionTarget: ResolvedActionTarget;
files: string[];
timeoutMs: number;
signal?: AbortSignal;
withOverlayHidden: <T>(operation: () => Promise<T>) => Promise<T>;
}
export interface FileDropTransactionResult {
x: number;
y: number;
}
function failure(
code: RpcError["code"],
reason: "upload_mechanism_unsupported" | "file_drop_target_unavailable" | "file_drop_failed",
message: string,
effectState: TransferEffectState,
phase: FileDropPhase,
): RpcError {
const error = transferError(code, reason, message, { effectState, phase });
error.data = { ...error.data, mechanism: "drop" };
return error;
}
function enrichFailure(
error: RpcError,
effectState: TransferEffectState,
phase: FileDropPhase,
): RpcError {
return {
...error,
data: {
...error.data,
mechanism: "drop",
effect_state: effectState,
phase,
},
};
}
function messageOf(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function methodUnavailable(error: unknown): boolean {
return /(?:method|command).*(?:not found|wasn't found|unsupported)|wasn't found|-32601/i.test(
messageOf(error),
);
}
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
if (!reply.exceptionDetails) return null;
return new Error(
reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? fallback,
);
}
async function pointBelongsToTarget(
options: FileDropTransactionOptions,
x: number,
y: number,
objectGroup: string,
): Promise<boolean> {
const target = options.actionTarget.cdpTarget;
const hit = await sendToCdpTarget<{ backendNodeId?: number; frameId?: string }>(
options.cdp,
target,
"DOM.getNodeForLocation",
{ x: Math.round(x), y: Math.round(y), includeUserAgentShadowDOM: true },
);
if (typeof hit.backendNodeId !== "number") return false;
if (options.actionTarget.frameId && hit.frameId !== options.actionTarget.frameId) return false;
const [resolvedTarget, resolvedHit] = await Promise.all([
sendToCdpTarget<{ object?: { objectId?: string } }>(options.cdp, target, "DOM.resolveNode", {
backendNodeId: options.actionTarget.backendNodeId,
objectGroup,
}),
sendToCdpTarget<{ object?: { objectId?: string } }>(options.cdp, target, "DOM.resolveNode", {
backendNodeId: hit.backendNodeId,
objectGroup,
}),
]);
const targetObjectId = resolvedTarget.object?.objectId;
const hitObjectId = resolvedHit.object?.objectId;
if (!targetObjectId || !hitObjectId) return false;
const reply = await sendToCdpTarget<RuntimeReply>(options.cdp, target, "Runtime.callFunctionOn", {
objectId: targetObjectId,
functionDeclaration: `function(hit) {
for (let node = hit; node; ) {
if (node === this) return true;
if (this.contains?.(node)) return true;
const root = node.getRootNode?.();
node = node.parentNode || (root && root !== node ? root.host : null);
}
return false;
}`,
arguments: [{ objectId: hitObjectId }],
returnByValue: true,
});
const error = runtimeError(reply, "failed to verify file-drop target");
if (error) throw error;
return reply.result?.value === true;
}
export async function uploadThroughFileDrop(
options: FileDropTransactionOptions,
): Promise<FileDropTransactionResult | RpcError> {
const deadline = Date.now() + options.timeoutMs;
const target = options.actionTarget.cdpTarget;
const objectGroup = `bsk-file-drop-${crypto.randomUUID()}`;
const dragData: DragData = {
items: [],
files: options.files,
dragOperationsMask: 1,
};
let dragStarted = false;
let dropSent = false;
let dragPoint = { x: 0, y: 0 };
let cleanupFailed = false;
let outcome: FileDropTransactionResult | RpcError = failure(
"protocol_error",
"file_drop_failed",
"file-drop transaction ended without an outcome",
"none",
"cleanup",
);
try {
const geometry = await waitBounded(
resolveNodeGeometry(
options.cdp,
options.actionTarget.tab.tabId,
{
target,
backendNodeId: options.actionTarget.backendNodeId,
...(options.actionTarget.frameId ? { frameId: options.actionTarget.frameId } : {}),
},
{ scrollIntoView: true },
),
deadline,
options.signal,
"resolving file-drop target timed out",
);
if (isRpcError(geometry)) {
outcome = enrichFailure(geometry, "none", "resolve_target");
return outcome;
}
const point = geometry.targetActionPoint;
dragPoint = point;
outcome = await options.withOverlayHidden(async () => {
try {
try {
const belongs = await waitBounded(
pointBelongsToTarget(options, point.x, point.y, objectGroup),
deadline,
options.signal,
"verifying file-drop target timed out",
);
if (!belongs) {
return failure(
"permission_denied",
"file_drop_target_unavailable",
"file-drop target is not the topmost element at its action point",
"none",
"resolve_target",
);
}
} catch (error) {
return failure(
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_drop_target_unavailable",
messageOf(error),
"none",
"resolve_target",
);
}
for (const [type, phase] of [
["dragEnter", "drag_enter"],
["dragOver", "drag_over"],
] as const) {
try {
if (type === "dragEnter") dragStarted = true;
await waitBounded(
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
type,
x: point.x,
y: point.y,
data: dragData,
}),
deadline,
options.signal,
`${type} timed out`,
);
} catch (error) {
const unsupported = methodUnavailable(error);
if (type === "dragEnter" && unsupported) dragStarted = false;
return failure(
unsupported
? "unsupported"
: error instanceof BoundedWaitError
? "timeout"
: "cdp_failed",
unsupported ? "upload_mechanism_unsupported" : "file_drop_failed",
messageOf(error),
"none",
phase,
);
}
}
try {
dropSent = true;
await waitBounded(
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
type: "drop",
x: point.x,
y: point.y,
data: dragData,
}),
deadline,
options.signal,
"file drop timed out",
);
return { x: point.x, y: point.y };
} catch (error) {
outcome = failure(
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_drop_failed",
messageOf(error),
"unknown",
"drop",
);
return outcome;
}
} finally {
if (dragStarted && !dropSent) {
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Input.dispatchDragEvent", {
type: "dragCancel",
x: dragPoint.x,
y: dragPoint.y,
data: dragData,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"cancelling file drag timed out",
);
} catch {
cleanupFailed = true;
}
}
}
});
return outcome;
} catch (error) {
outcome = failure(
error instanceof BoundedWaitError ? "timeout" : "cdp_failed",
"file_drop_target_unavailable",
messageOf(error),
"none",
"resolve_target",
);
return outcome;
} finally {
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"releasing file-drop object group timed out",
);
} catch {
cleanupFailed = true;
}
const effect = isRpcError(outcome)
? (outcome.data?.effect_state as TransferEffectState | undefined)
: "committed";
if (effect === "unknown" || cleanupFailed) {
await options.cdp.detach?.(target.tabId);
}
if (cleanupFailed && isRpcError(outcome)) {
outcome.data = { ...outcome.data, cleanup_state: "failed" };
}
}
}
@@ -7,9 +7,9 @@ import type { ClickResult, RpcError, TransferEffectState } from "@/transport/typ
import { transferError } from "./errors";
import type { ResolvedActionTarget } from "./interaction";
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
import { BoundedWaitError, remainingMs, waitBounded } from "./transfer-transaction";
const CLEANUP_TIMEOUT_MS = 1_000;
const DETACH_TIMEOUT_MS = 500;
const ACTIVATION_GRACE_MS = 1_000;
const PROBE_INTERVAL_MS = 20;
@@ -46,6 +46,51 @@ export interface FileInputTransactionResult {
multiple: boolean;
}
class BoundedWaitError extends Error {
constructor(
readonly kind: "timeout" | "aborted",
message: string,
) {
super(message);
}
}
function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
async function waitBounded<T>(
operation: () => Promise<T>,
deadline: number,
signal: AbortSignal | undefined,
timeoutMessage: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (signal?.aborted) throw new BoundedWaitError("aborted", "upload transaction aborted");
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const boundary = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
if (signal) {
onAbort = () => reject(new BoundedWaitError("aborted", "upload transaction aborted"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
const work = Promise.resolve().then(() => {
if (signal?.aborted) throw new BoundedWaitError("aborted", "upload transaction aborted");
if (remainingMs(deadline) === 0) throw new BoundedWaitError("timeout", timeoutMessage);
return operation();
});
return await Promise.race([work, boundary]);
} finally {
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
if (!reply.exceptionDetails) return null;
return new Error(
@@ -83,13 +128,18 @@ function sameTarget(
async function delay(ms: number, signal?: AbortSignal): Promise<void> {
await waitBounded(
new Promise((resolve) => setTimeout(resolve, ms)),
() => new Promise((resolve) => setTimeout(resolve, ms)),
Date.now() + ms + 1,
signal,
"upload activation probe timed out",
);
}
function waitErrorCode(error: unknown): RpcError["code"] {
if (!(error instanceof BoundedWaitError)) return "cdp_failed";
return error.kind === "aborted" ? "cancelled" : "timeout";
}
async function callOnTrigger<T = RuntimeReply>(
options: FileInputTransactionOptions,
objectId: string,
@@ -114,8 +164,8 @@ export async function uploadThroughActivatedFileInput(
const objectGroup = `bsk-upload-${crypto.randomUUID()}`;
const stateKey = `__bskUpload_${crypto.randomUUID().replaceAll("-", "")}`;
const chooserEvents: ChooserEvent[] = [];
let interceptionArmed = false;
let probeArmed = false;
let interceptionState: "not_started" | "possibly_active" | "active" = "not_started";
let probeState: "not_started" | "possibly_active" | "active" = "not_started";
let triggerObjectId: string | undefined;
let outcome: FileInputTransactionResult | RpcError = transferFailure(
"protocol_error",
@@ -133,18 +183,20 @@ export async function uploadThroughActivatedFileInput(
try {
try {
interceptionState = "possibly_active";
await waitBounded(
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: true,
}),
() =>
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: true,
}),
deadline,
options.signal,
"arming native file chooser interception timed out",
);
interceptionArmed = true;
interceptionState = "active";
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
waitErrorCode(err),
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
@@ -155,15 +207,16 @@ export async function uploadThroughActivatedFileInput(
try {
const resolved = await waitBounded(
sendToCdpTarget<{ object?: { objectId?: string } }>(
options.cdp,
target,
"DOM.resolveNode",
{
backendNodeId: options.actionTarget.backendNodeId,
objectGroup,
},
),
() =>
sendToCdpTarget<{ object?: { objectId?: string } }>(
options.cdp,
target,
"DOM.resolveNode",
{
backendNodeId: options.actionTarget.backendNodeId,
objectGroup,
},
),
deadline,
options.signal,
"resolving upload trigger timed out",
@@ -171,11 +224,13 @@ export async function uploadThroughActivatedFileInput(
triggerObjectId = resolved.object?.objectId;
if (!triggerObjectId) throw new Error("DOM.resolveNode returned no trigger objectId");
probeState = "possibly_active";
const armed = await waitBounded(
callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) {
() =>
callOnTrigger<RuntimeReply>(
options,
triggerObjectId as string,
`function(key) {
const doc = this.ownerDocument;
const owner = doc.defaultView;
if (!owner) return false;
@@ -193,9 +248,9 @@ export async function uploadThroughActivatedFileInput(
doc.addEventListener("click", state.listener, true);
return true;
}`,
[stateKey],
true,
),
[stateKey],
true,
),
deadline,
options.signal,
"arming frame-scoped file input probe timed out",
@@ -204,10 +259,10 @@ export async function uploadThroughActivatedFileInput(
if (armError || armed.result?.value !== true) {
throw armError ?? new Error("file input probe did not arm");
}
probeArmed = true;
probeState = "active";
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
waitErrorCode(err),
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
@@ -219,14 +274,14 @@ export async function uploadThroughActivatedFileInput(
let click: ClickResult | RpcError;
try {
click = await waitBounded(
options.trigger(),
options.trigger,
deadline,
options.signal,
"upload trigger timed out",
);
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
waitErrorCode(err),
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
@@ -244,17 +299,23 @@ export async function uploadThroughActivatedFileInput(
let summary: { count: number; multiple: boolean } = { count: 0, multiple: false };
while (Date.now() < activationDeadline) {
if (chooserEvents.length > 0) break;
const reply = await callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) {
const reply = await waitBounded(
() =>
callOnTrigger<RuntimeReply>(
options,
triggerObjectId as string,
`function(key) {
const state = this.ownerDocument.defaultView?.[key];
return state
? { count: state.inputs.length, multiple: state.inputs[0]?.multiple === true }
: { count: 0, multiple: false };
}`,
[stateKey],
true,
[stateKey],
true,
),
activationDeadline,
options.signal,
"inspecting activated file input timed out",
);
const summaryError = runtimeError(reply, "failed to inspect activated file input");
if (summaryError) throw summaryError;
@@ -317,21 +378,33 @@ export async function uploadThroughActivatedFileInput(
);
return outcome;
}
const input = await callOnTrigger<RuntimeReply>(
options,
triggerObjectId,
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
[stateKey],
false,
const input = await waitBounded(
() =>
callOnTrigger<RuntimeReply>(
options,
triggerObjectId as string,
`function(key) { return this.ownerDocument.defaultView?.[key]?.inputs[0]; }`,
[stateKey],
false,
),
deadline,
options.signal,
"resolving activated file input timed out",
);
const inputError = runtimeError(input, "failed to resolve activated file input object");
if (inputError) throw inputError;
if (!input.result?.objectId) throw new Error("activated file input returned no objectId");
const described = await sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
options.cdp,
target,
"DOM.describeNode",
{ objectId: input.result.objectId },
const described = await waitBounded(
() =>
sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
options.cdp,
target,
"DOM.describeNode",
{ objectId: input.result?.objectId },
),
deadline,
options.signal,
"describing activated file input timed out",
);
backendNodeId = described.node?.backendNodeId;
if (typeof backendNodeId !== "number") {
@@ -350,17 +423,18 @@ export async function uploadThroughActivatedFileInput(
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "DOM.setFileInputFiles", {
files: options.files,
backendNodeId,
}),
() =>
sendToCdpTarget(options.cdp, target, "DOM.setFileInputFiles", {
files: options.files,
backendNodeId,
}),
deadline,
options.signal,
"setting file input files timed out",
);
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
waitErrorCode(err),
"set_file_input_failed",
err instanceof Error ? err.message : String(err),
"unknown",
@@ -372,7 +446,7 @@ export async function uploadThroughActivatedFileInput(
return outcome;
} catch (err) {
outcome = transferFailure(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
waitErrorCode(err),
"file_input_probe_failed",
err instanceof Error ? err.message : String(err),
"none",
@@ -382,61 +456,70 @@ export async function uploadThroughActivatedFileInput(
}
} finally {
chooserSubscription?.dispose();
let cleanupFailed = false;
if (probeArmed && triggerObjectId) {
const cleanupDeadline = Date.now() + CLEANUP_TIMEOUT_MS;
const cleanupTasks: Promise<boolean>[] = [];
const cleanup = async (operation: () => Promise<unknown>, message: string) => {
try {
await waitBounded(
callOnTrigger(
options,
triggerObjectId,
`function(key) {
await waitBounded(operation, cleanupDeadline, undefined, message);
return true;
} catch {
return false;
}
};
if (probeState !== "not_started" && triggerObjectId) {
cleanupTasks.push(
cleanup(
() =>
callOnTrigger(
options,
triggerObjectId as string,
`function(key) {
const owner = this.ownerDocument.defaultView;
const state = owner?.[key];
if (state?.listener) this.ownerDocument.removeEventListener("click", state.listener, true);
if (owner) delete owner[key];
}`,
[stateKey],
true,
),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
[stateKey],
true,
),
"cleaning file input probe timed out",
);
} catch {
cleanupFailed = true;
}
}
if (interceptionArmed) {
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: false,
cancel: true,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"disabling file chooser interception timed out",
);
} catch {
cleanupFailed = true;
}
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"releasing upload object group timed out",
),
);
} catch {
cleanupFailed = true;
}
if (interceptionState !== "not_started") {
cleanupTasks.push(
cleanup(
() =>
sendToCdpTarget(options.cdp, target, "Page.setInterceptFileChooserDialog", {
enabled: false,
cancel: true,
}),
"disabling file chooser interception timed out",
),
);
}
let cleanupFailed = (await Promise.all(cleanupTasks)).some((completed) => !completed);
if (!cleanupFailed) {
cleanupFailed = !(await cleanup(
() => sendToCdpTarget(options.cdp, target, "Runtime.releaseObjectGroup", { objectGroup }),
"releasing upload object group timed out",
));
}
const effect = isRpcError(outcome)
? (outcome.data?.effect_state as TransferEffectState | undefined)
: "committed";
if (effect === "unknown" || cleanupFailed) {
await options.cdp.detach?.(target.tabId);
try {
await waitBounded(
() => options.cdp.detach?.(target.tabId) ?? Promise.resolve(),
Date.now() + DETACH_TIMEOUT_MS,
undefined,
"detaching upload target timed out",
);
} catch {
cleanupFailed = true;
}
}
if (cleanupFailed && isRpcError(outcome)) {
outcome.data = { ...outcome.data, cleanup_state: "failed" };
+1 -27
View File
@@ -29,10 +29,7 @@ export interface NodeAddress {
export interface ResolvedNodeGeometry {
topVisibleRegions: Region;
topBounds: ViewportRect;
/** Point in the top-level tab viewport, used by root-target input events. */
actionPoint: Point;
/** Point in the addressed CDP target's viewport, used by OOPIF-local input events. */
targetActionPoint: Point;
}
function geometryError(message: string): RpcError {
@@ -265,30 +262,11 @@ export async function resolveNodeGeometry(
if (isRpcError(localRegion)) return localRegion;
let topVisibleRegions: Region;
let targetActionPoint: Point | null = null;
if (address.frameId && graph) {
const projection = await resolveFrameProjection(cdp, graph, address.frameId);
if (!projection)
return geometryError(`could not resolve frame geometry for ${address.frameId}`);
topVisibleRegions = projectRegionToViewport(localRegion, projection);
if (address.target.sessionId) {
const localViewport = projection.edges[0]?.sourceViewport ?? projection.topViewport;
const localVisibleRegions = localRegion
.map((polygon) =>
clipPolygon(
polygon,
rectPolygon({
x: 0,
y: 0,
w: localViewport.width,
h: localViewport.height,
}),
),
)
.filter((polygon) => polygon.length >= 3);
const localActionRegion = largestRegion(localVisibleRegions);
targetActionPoint = localActionRegion ? polygonCentroid(localActionRegion) : null;
}
} else {
const viewport = await targetViewport(cdp, address.target);
if (!viewport) return geometryError("could not resolve top viewport geometry");
@@ -305,11 +283,7 @@ export async function resolveNodeGeometry(
if (!topBounds || !actionPoint) {
return { code: "permission_denied", message: "element not visible" };
}
if (address.target.sessionId && !targetActionPoint) {
return { code: "permission_denied", message: "element not visible in its target" };
}
targetActionPoint ??= actionPoint;
return { topVisibleRegions, topBounds, actionPoint, targetActionPoint };
return { topVisibleRegions, topBounds, actionPoint };
} catch (error) {
return geometryError(error instanceof Error ? error.message : String(error));
}
+38 -278
View File
@@ -183,8 +183,6 @@ export async function resolveBackendNode(
}
// selector path
try {
// Selector lookup itself attaches CDP, even when no element is found.
cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const doc = await cdp.send<{ root?: { nodeId?: number } }>(target.tabId, "DOM.getDocument", {
depth: 0,
});
@@ -609,53 +607,6 @@ type SelectMutationResult =
// tool.fill
// ---------------------------------------------------------------------------
type FillFailureReason =
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed";
type FillPreparation =
| { before: string; expected: string }
| { reason: FillFailureReason; error: string };
type FillReadiness = "ready" | "background" | "fill_target_changed" | "fill_focus_lost";
function fillError(reason: FillFailureReason, message: string): RpcError {
return rpcError(
reason === "target_not_fillable" || reason === "fill_value_invalid"
? "invalid_params"
: "cdp_failed",
reason,
message,
);
}
interface FillScriptReply<T> {
result?: { value?: T };
exceptionDetails?: unknown;
}
const FILL_EDITABLE_FUNCTION = `function() {
const tag = this.tagName.toLowerCase();
const supported = tag === 'input'
? ['text', 'search', 'tel', 'url', 'email', 'password', 'number'].includes(this.type)
: tag === 'textarea' || this.isContentEditable;
return this.isConnected && supported && !this.readOnly && !this.matches(':disabled');
}`;
// Chrome renders a trailing editable newline with an empty <div><br></div>.
// innerText includes the padding break; it is not an extra typed character.
const FILL_VALUE_FUNCTION = `function() {
if (!this.isContentEditable) return this.value;
const value = this.innerText;
const tail = this.lastChild;
const padding = tail && tail.nodeName === 'DIV' && tail.childNodes.length === 1 && tail.firstChild.nodeName === 'BR';
return padding && value.endsWith('\\n\\n') ? value.slice(0, -1) : value;
}`;
export async function handleFill(
manager: SessionManager,
params: FillParams,
@@ -707,8 +658,12 @@ export async function handleFill(
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (err) {
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
if (throwIfAborted(deps.signal)) {
@@ -721,252 +676,57 @@ export async function handleFill(
const clearBefore = params.clear_before ?? true;
try {
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
try {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
} catch (error) {
// Chrome rejects DOM.focus for disabled controls, including inherited
// fieldset state. Classify the live target before treating this as a
// browser failure; the normal path needs no extra round trip.
const editable = await nodeCdp.send<FillScriptReply<boolean>>(
target.tabId,
"Runtime.callFunctionOn",
{ objectId, functionDeclaration: FILL_EDITABLE_FUNCTION, returnByValue: true },
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (!editable.exceptionDetails && editable.result?.value === false) {
return fillError(
"target_not_fillable",
"fill target is not editable or its input type is unsupported",
);
}
throw error;
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Check editability before clearing. Keep the expected result tied to
// this object, including the existing value on the append path.
const prepared = await nodeCdp.send<FillScriptReply<FillPreparation>>(
target.tabId,
"Runtime.callFunctionOn",
{
if (clearBefore) {
// Clear input/textarea value or wipe contenteditable innerText,
// then fire `input` so frameworks observe the empty state.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function(value, clearBefore) {
const tag = this.tagName.toLowerCase();
const native = tag === 'input' || tag === 'textarea';
if (!(${FILL_EDITABLE_FUNCTION}).call(this)) return { reason: 'target_not_fillable', error: 'fill target is not editable or its input type is unsupported' };
if (this.getRootNode().activeElement !== this) return { reason: 'fill_focus_lost', error: 'fill target does not have focus' };
const before = clearBefore ? '' : (${FILL_VALUE_FUNCTION}).call(this);
let expected = before + value;
if (native) {
// Use the browser's own value sanitization (e.g. textarea
// line endings) without changing the live control.
const normalizer = this.ownerDocument.createElement(tag);
if (tag === 'input') {
normalizer.type = this.type;
normalizer.multiple = this.multiple;
}
// insertText treats line breaks in a single-line input as spaces.
if (tag === 'input') expected = expected.replace(/\\r\\n|\\r|\\n/g, ' ');
normalizer.value = expected;
if (expected !== '' && normalizer.value === '' && this.type === 'number') {
return { reason: 'fill_value_invalid', error: 'fill value is not valid for a number input' };
}
expected = normalizer.value;
if (this.type !== 'number' && this.maxLength >= 0 && expected.length > this.maxLength) {
return { reason: 'fill_value_invalid', error: 'fill value exceeds the target maxlength' };
}
} else {
expected = expected.replace(/\\r\\n?/g, '\\n');
functionDeclaration: `function() {
if (this.isContentEditable) { this.textContent = ''; }
else {
const proto = this instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
const descriptor = Object.getOwnPropertyDescriptor(proto, 'value');
if (descriptor && descriptor.set) descriptor.set.call(this, '');
else this.value = '';
}
if (clearBefore) {
if (native) {
const proto = tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value').set.call(this, '');
} else {
this.textContent = '';
}
this.dispatchEvent(new Event('input', { bubbles: true }));
}
return { before, expected };
this.dispatchEvent(new Event('input', { bubbles: true }));
}`,
arguments: [{ value: params.value }, { value: clearBefore }],
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (prepared.exceptionDetails) {
return fillError("fill_failed", "fill preparation script failed");
}
const preparation = prepared.result?.value;
if (preparation && "error" in preparation) {
return fillError(preparation.reason, preparation.error);
}
if (typeof preparation?.before !== "string" || typeof preparation?.expected !== "string") {
return fillError("fill_failed", "fill preparation returned an unexpected result");
}
// A separate call runs after the clearing event's microtasks drain.
// DOM focus works in background tabs. Use document.hasFocus() only to
// detect deferred focus events, never to reject background input.
const checkReady = async (): Promise<FillReadiness | RpcError> => {
const reply = await nodeCdp.send<FillScriptReply<FillReadiness>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function(before) {
if (!(${FILL_EDITABLE_FUNCTION}).call(this) ||
(${FILL_VALUE_FUNCTION}).call(this) !== before) return 'fill_target_changed';
if (this.getRootNode().activeElement !== this) return 'fill_focus_lost';
return this.ownerDocument.hasFocus() ? 'ready' : 'background';
}`,
arguments: [{ value: preparation.before }],
returnByValue: true,
},
);
const state = reply.result?.value;
if (
reply.exceptionDetails ||
(state !== "ready" &&
state !== "background" &&
state !== "fill_target_changed" &&
state !== "fill_focus_lost")
) {
return fillError(
"fill_failed",
"fill readiness script failed or returned an unexpected result",
);
}
return state;
};
let ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Restore focus once only if clearing left this same target editable
// and unchanged. Recheck after focus handlers have run.
if (clearBefore && ready === "fill_focus_lost") {
await nodeCdp.send(target.tabId, "DOM.focus", { backendNodeId: node.backendNodeId });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
ready = await checkReady();
});
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus before typing");
}
if (params.value !== "" && (!clearBefore || ready === "background")) {
// Native editing commands also support number/email inputs, whose
// selection APIs cannot set a caret, and multiline contenteditables.
// Also do this for background replacement: CDP input focuses the
// renderer, delivering deferred focus events before it inserts text.
// Use a command-only event so this does not invoke an End shortcut.
try {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", {
type: "rawKeyDown",
commands: ["moveToEndOfDocument"],
});
} finally {
await deps.cdp.send(target.tabId, "Input.dispatchKeyEvent", { type: "keyUp" });
}
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Focus and key handlers can change the target or move focus too.
ready = await checkReady();
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (typeof ready !== "string") return ready;
if (ready !== "ready" && ready !== "background") {
return fillError(ready, "fill target changed or lost focus while positioning the caret");
}
}
// CDP `Input.insertText` handles IME / multi-byte input out of the
// box, much more reliably than per-key `dispatchKeyEvent`.
if (params.value !== "") {
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
}
await deps.cdp.send(target.tabId, "Input.insertText", { text: params.value });
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
// Keep the existing notifications, then read in a separate call so
// nested microtasks queued by the page cannot race the verification.
const notified = await nodeCdp.send<FillScriptReply<unknown>>(
target.tabId,
"Runtime.callFunctionOn",
{
objectId,
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
},
);
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
if (notified.exceptionDetails) {
return fillError("fill_failed", "fill notification script failed");
}
const verified = await nodeCdp.send<
FillScriptReply<{ connected: boolean; matches: boolean; valueLength: number }>
>(target.tabId, "Runtime.callFunctionOn", {
// Fire `input` + `change` so React / Vue controlled inputs commit.
await nodeCdp.send(target.tabId, "Runtime.callFunctionOn", {
objectId,
functionDeclaration: `function(expected) {
const value = (${FILL_VALUE_FUNCTION}).call(this);
return { connected: this.isConnected, matches: value === expected, valueLength: value.length };
}`,
arguments: [{ value: preparation.expected }],
functionDeclaration: `function() {
this.dispatchEvent(new Event('input', { bubbles: true }));
this.dispatchEvent(new Event('change', { bubbles: true }));
}`,
returnByValue: true,
});
if (throwIfAborted(deps.signal)) {
return { code: "cancelled", message: "fill aborted" };
}
const verification = verified.result?.value;
if (
verified.exceptionDetails ||
typeof verification?.connected !== "boolean" ||
typeof verification?.matches !== "boolean" ||
typeof verification?.valueLength !== "number"
) {
return fillError(
"fill_failed",
"fill verification script failed or returned an unexpected result",
);
}
if (!verification.connected) {
return fillError("fill_target_changed", "fill target was removed or replaced during input");
}
if (!verification.matches) {
return fillError(
"fill_value_mismatch",
"fill could not verify the expected value; observe the page before deciding whether to retry",
);
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: verification.valueLength,
});
} catch (err) {
return fillError("fill_failed", err instanceof Error ? err.message : String(err));
} finally {
await nodeCdp.send(target.tabId, "Runtime.releaseObject", { objectId }).catch(() => undefined);
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
tab_id: target.tabId,
used_ref: node.usedRef,
used_selector: node.usedSelector,
value_length: params.value.length,
});
}
// ---------------------------------------------------------------------------
+10 -116
View File
@@ -28,13 +28,7 @@ import type {
RpcError,
WaitUntil,
} from "@/transport/types";
import {
type BrowserNavigationApi,
chromeBrowserNavigationApi,
navigateWithBrowserApi,
} from "./browser-navigation";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpError, isCdpExtensionAccessDenied } from "./errors";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -48,7 +42,6 @@ import {
export interface NavigationDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
browserNavigation?: BrowserNavigationApi;
/** Optional AbortSignal — M7 abort hook (M10.2 will wire the full chain). */
signal?: AbortSignal;
/** Override default timeout when the caller omits `timeout_ms`. */
@@ -486,81 +479,6 @@ async function readTabUrl(api: ChromeTabsApi, tabId: number): Promise<string | u
}
}
/** Recover only a failed preflight; never replay an already-dispatched navigation. */
async function recoverBrowserNavigation(
deps: NavigationDeps,
tabId: number,
action: (api: BrowserNavigationApi) => Promise<unknown>,
waitUntil: WaitUntil,
timeoutMs: number,
): Promise<ReloadResult | RpcError> {
const abort = linkedAbortSignal(deps.signal);
const deadline = Date.now() + timeoutMs;
const api = deps.browserNavigation ?? chromeBrowserNavigationApi;
try {
let outcome = await navigateWithBrowserApi(
api,
tabId,
() => action(api),
waitUntil === "networkidle" ? "commit" : waitUntil,
timeoutMs,
abort.signal,
);
if (outcome.reached === "failed") return outcome.error;
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
if (outcome.reached === "match") {
// A completed reload is not a recovery if the restricted frame remains.
await deps.cdp.send(tabId, "Page.enable", {});
if (abort.signal.aborted)
return { code: "cancelled", message: "navigation recovery aborted" };
if (waitUntil === "networkidle") {
// webNavigation cannot prove network idle. Subscribe on the new
// document before enabling CDP lifecycle events after its commit.
const frame = await readMainFrameInfo(deps.cdp, tabId);
if (!frame.frameId) return cdpError("no main frame after navigation");
const remaining = deadline - Date.now();
if (remaining <= 0) {
outcome = { reached: "timeout", lastLifecycle: "commit" };
} else {
const wait = startLifecycleWait(
deps.cdp,
tabId,
frame.frameId,
"networkIdle",
remaining,
abort.signal,
{ loaderId: frame.loaderId ?? undefined },
);
await deps.cdp.send(tabId, "Page.setLifecycleEventsEnabled", { enabled: true });
outcome = await wait.promise;
}
}
}
if (outcome.reached === "cancelled" || abort.signal.aborted) {
return { code: "cancelled", message: "navigation recovery aborted" };
}
return {
tab_id: tabId,
final_url: await readTabUrl(deps.tabsApi, tabId),
reached: outcome.reached === "match" ? waitUntil : "timeout",
...(outcome.reached === "timeout"
? {
error_text: `timed out waiting for ${waitUntil} during navigation recovery after ${timeoutMs}ms`,
}
: {}),
};
} catch (error) {
return abort.signal.aborted
? { code: "cancelled", message: "navigation recovery aborted" }
: cdpError(error);
} finally {
abort.abort();
abort.cleanup();
}
}
// ---------------------------------------------------------------------------
// tool.navigate
// ---------------------------------------------------------------------------
@@ -587,20 +505,7 @@ export async function handleNavigate(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.update(target.tabId, { url: params.url }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, { ...recovered, url: params.url });
}
await ensureCdpReady(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
const beforeReadyState = await probeMainFrameReadyState(deps.cdp, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
@@ -669,7 +574,10 @@ export async function handleNavigate(
}`,
});
} catch (err) {
return cdpError(err);
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
@@ -819,24 +727,7 @@ export async function handleReload(
try {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
try {
await ensureCdpReady(deps.cdp, target.tabId);
} catch (error) {
if (!isCdpExtensionAccessDenied(error)) throw error;
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const recovered = await recoverBrowserNavigation(
deps,
target.tabId,
(api) => api.reload(target.tabId, { bypassCache: ignoreCache }),
waitUntil,
timeoutMs,
);
if (isRpcError(recovered)) return recovered;
return attachDialogs(deps.cdp, target.tabId, dialogCursor, {
...recovered,
previous_url: previousUrl,
});
}
await ensureCdpReady(deps.cdp, target.tabId);
const previousUrl = await readTabUrl(deps.tabsApi, target.tabId);
const beforeFrame = await readMainFrameInfo(deps.cdp, target.tabId);
const expected = cdpLifecycleName(waitUntil);
@@ -885,7 +776,10 @@ export async function handleReload(
}`,
});
} catch (err) {
return cdpError(err);
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
+3 -3
View File
@@ -17,7 +17,7 @@ import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { CdpTarget } from "@/browser-driver/frame-graph";
import {
type CaptureSuppressSendToTab,
withExtensionOverlayHidden,
withOverlaysHiddenForCapture,
} from "@/lib/capture-suppress-bridge";
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type {
@@ -324,7 +324,7 @@ export async function handleScreenshot(
tabId: target.tabId,
...(node.cdpSessionId ? { sessionId: node.cdpSessionId } : {}),
};
const captured = await withExtensionOverlayHidden(
const captured = await withOverlaysHiddenForCapture(
target.tabId,
() =>
captureElementScreenshot(
@@ -356,7 +356,7 @@ export async function handleScreenshot(
);
}
const captured = await withExtensionOverlayHidden(
const captured = await withOverlaysHiddenForCapture(
target.tabId,
() => captureFullTabPng(deps, ctx, target, signal),
deps.sendToTab,
+8 -94
View File
@@ -2,7 +2,6 @@ import { type SessionManager, SessionStartCleanupError } from "@/session-manager
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { clearRecordingForSession } from "./record";
import type { CdpRunner, ChromeTabsApi } from "./shared";
import { isRpcError } from "./shared";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
@@ -74,17 +73,11 @@ export interface SessionStopResult {
/** Tab ids whose return path failed; those entries remain borrowed so
* shutdown can be retried without closing the Agent Window. */
return_failures?: Array<{ tab_id: number; code: string; message: string }>;
/**
* True when the Agent Window was *released* to the user (instead of being
* closed) because it still contained user-created tabs after the agent's own
* tabs were closed. Single-session scope.
*/
window_released?: boolean;
}
export interface SessionStopDeps {
signal?: AbortSignal;
cdp?: Pick<CdpRunner, "releaseSessionTab"> & {
cdp?: {
detachSession(sessionId: string): Promise<void>;
};
/**
@@ -94,13 +87,6 @@ export interface SessionStopDeps {
* without a real browser.
*/
tabManagement?: TabManagementDeps;
/**
* Read-only Chrome tabs API used to *query* remaining tabs before deciding
* whether to release the window. Kept separate from `tabManagement.tabs`
* (a `TabMutationApi` that has no `query`) so the mutation interface stays
* pure. Tests can inject a fake `ChromeTabsApi` too.
*/
tabsQuery?: ChromeTabsApi;
}
/**
@@ -166,10 +152,8 @@ export async function handleSessionStart(
* cleanly (review parity with M6).
* 3. Detach CDP sessions the extension still holds for this
* session (no-op if M6/M7 didn't attach to any tab).
* 4. Close the agent-created tabs and the home tab (design §3.2).
* 5. If any tab remains — those are user-created tabs — release the
* window to the user (dropOnly) instead of closing it, so user
* tabs survive. Otherwise close the window as before.
* 4. Close the Agent Window. SessionManager.stop() removes the
* Chrome window and forgets the context.
*
* Failures in step 1 keep the Agent Window open: a failed borrowed tab
* may still be there, so closing the window would risk losing user
@@ -204,19 +188,15 @@ export async function handleSessionStop(
const returnFailures: SessionStopResult["return_failures"] = [];
const borrowedIds = Array.from(ctx.borrowedTabs.keys());
for (const tabId of borrowedIds) {
// A close event may have removed this borrow while another tab returned.
if (!ctx.borrowedTabs.has(tabId)) continue;
try {
const tabManagement = {
...(deps.tabManagement ?? {}),
cdp: deps.cdp ?? deps.tabManagement?.cdp,
signal: deps.signal,
isAgentWindowId:
deps.tabManagement?.isAgentWindowId ??
((windowId: number) => manager.findByWindowId(windowId) !== null),
};
const outcome = await returnBorrowedTab(ctx, tabId, tabManagement);
if (!ctx.borrowedTabs.has(tabId)) continue;
if (typeof outcome === "object" && "code" in outcome) {
console.warn(`[bsk session_stop] auto-return failed for tab ${tabId}`, outcome);
returnFailures?.push({
@@ -229,7 +209,6 @@ export async function handleSessionStop(
ctx.borrowedTabs.delete(tabId);
}
} catch (err) {
if (!ctx.borrowedTabs.has(tabId)) continue;
const message = err instanceof Error ? err.message : String(err);
console.warn(`[bsk session_stop] auto-return threw for tab ${tabId}: ${message}`);
returnFailures?.push({
@@ -240,15 +219,8 @@ export async function handleSessionStop(
}
}
// A previously failed tab may have closed during a later return. Only
// confirmed close events remove borrows; ordinary move failures stay retryable.
const pendingReturnFailures = returnFailures.filter((failure) =>
ctx.borrowedTabs.has(failure.tab_id),
);
if (deps.signal?.aborted) {
const nonCancelFailures = pendingReturnFailures.filter(
(failure) => failure.code !== "cancelled",
);
const nonCancelFailures = returnFailures?.filter((failure) => failure.code !== "cancelled");
if (nonCancelFailures && nonCancelFailures.length > 0) {
return {
...(returnedTabIds.length > 0 ? { returned_tab_ids: returnedTabIds } : {}),
@@ -260,8 +232,8 @@ export async function handleSessionStop(
const result: SessionStopResult = {};
if (returnedTabIds.length > 0) result.returned_tab_ids = returnedTabIds;
if (pendingReturnFailures.length > 0) {
result.return_failures = pendingReturnFailures;
if (returnFailures && returnFailures.length > 0) {
result.return_failures = returnFailures;
// A failed return means at least one borrowed user tab may still be
// inside the Agent Window. Keep the session/window alive so the user
// can retry `bsk session stop` or explicitly `bsk tab return` after the
@@ -281,66 +253,8 @@ export async function handleSessionStop(
return { code: "cancelled", message: "session_stop aborted before window close" };
}
// Step 4: close every tab explicitly created by the agent, including the
// home tab. `tabsApi` is a
// TabMutationApi (remove only); `queryApi` is a separate read-only
// ChromeTabsApi. If neither is injected, we conservatively fall back to
// closing the window (see Step 5).
const tabsApi = deps.tabManagement?.tabs;
const queryApi = deps.tabsQuery;
if (tabsApi) {
// Close each agent-created tab that still exists.
const agentCreatedTabIds = Array.from(ctx.agentCreatedTabs);
for (const tabId of agentCreatedTabIds) {
try {
await tabsApi.remove(tabId);
ctx.agentCreatedTabs.delete(tabId);
} catch (err) {
// Tab may already be gone (closed by the user). Non-fatal.
console.warn(`[bsk session_stop] failed to close agent tab ${tabId}`, err);
}
}
}
// Step 5: decide whether to release (keep) the window or close it.
let shouldRelease = false;
if (queryApi) {
try {
const liveWindowTabs = await queryApi.query({ windowId: ctx.agentWindowId });
// Only genuine *user* tabs count toward keeping the window open.
// An agent tab that failed to close in Step 4 may still be present
// here; if we counted it as a reason to release (dropOnly), the
// window would be kept and that agent tab would leak (issue #57
// regression). Exclude any id still tracked in agentCreatedTabs.
const userTabs = liveWindowTabs.filter((t) => {
if (t.id === undefined) return false;
return !ctx.agentCreatedTabs.has(t.id);
});
const leakedAgentTabs = liveWindowTabs.filter(
(t) => t.id !== undefined && ctx.agentCreatedTabs.has(t.id),
);
if (leakedAgentTabs.length > 0) {
console.warn(
`[bsk session_stop] ${leakedAgentTabs.length} agent tab(s) failed to close; forcing window close instead of release`,
leakedAgentTabs.map((t) => t.id),
);
}
shouldRelease = userTabs.length > 0;
} catch {
// Query failed (e.g. window already gone) — conservatively close it.
shouldRelease = false;
}
}
if (shouldRelease) {
// Keep the window + its user tabs; only drop the session binding.
await manager.stop(params.session_id, { dropOnly: true });
result.window_released = true;
} else {
// Window is empty (or we couldn't verify state) — close it.
await manager.stop(params.session_id);
}
// Step 4: close the Agent Window and drop the context.
await manager.stop(params.session_id);
return result;
}
-1
View File
@@ -57,7 +57,6 @@ export interface CdpRunner {
getFrameGraph?(tabId: number): Promise<CdpFrameGraph>;
ensureAttachedToUrl?(tabId: number, expectedUrl: string | undefined): Promise<void>;
trackSessionTab?(sessionId: string, tabId: number): void;
releaseSessionTab?(sessionId: string, tabId: number): Promise<void>;
onEvent?(handler: (source: CdpDebuggee, method: string, params: unknown) => void): {
dispose(): void;
};
+28 -45
View File
@@ -10,7 +10,7 @@ import {
import type { SessionContext, SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { rpcError } from "./errors";
import { type CdpRunner, isRpcError, lookupSession } from "./shared";
import { isRpcError, lookupSession } from "./shared";
export type TabScope = "user" | "agent" | "all";
@@ -287,10 +287,6 @@ export interface TabManagementDeps {
approveBorrow?: BorrowConfirmationApprover;
/** Clears Agent-scoped overlays after a borrowed tab is returned. */
agentOverlayReset?: AgentOverlayResetApi;
/** Releases this session's CDP claim after a borrowed tab is returned. */
cdp?: Pick<CdpRunner, "releaseSessionTab">;
/** Runs after tab_return validation, before moving the borrowed tab. */
beforeReturn?: (sessionId: string, tabId: number) => Promise<void>;
/**
* Reports whether `windowId` is any live session's Agent Window.
* `tab_return`'s fallback window picker uses this to avoid moving a
@@ -373,7 +369,6 @@ function buildCreateProps(
* Returns the created tab on success, or an `RpcError` on failure.
*/
async function createTabAndCleanup(
ctx: SessionContext,
deps: TabManagementDeps,
createProps: chrome.tabs.CreateProperties,
): Promise<CreatedChromeTab | RpcError> {
@@ -386,28 +381,25 @@ async function createTabAndCleanup(
message: err instanceof Error ? err.message : String(err),
};
}
if (typeof tab.id !== "number") {
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
}
// Claim the concrete id returned by Chrome. Ownership never depends on
// matching this request to an asynchronous onCreated event.
ctx.agentCreatedTabs.add(tab.id);
if (aborted(deps.signal, "tab_create")) {
try {
await getTabsApi(deps).remove(tab.id);
ctx.agentCreatedTabs.delete(tab.id);
} catch (cleanupErr) {
// Keep the claim when cleanup fails so session_stop can retry instead
// of releasing an agent-owned tab to the user.
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
// We already opened the tab; close it on abort so we don't leak.
if (typeof tab.id === "number") {
try {
await getTabsApi(deps).remove(tab.id);
} catch (cleanupErr) {
return rpcError(
"protocol_error",
"cleanup_failed",
`tab_create aborted but cleanup of tab ${tab.id} failed: ${describeError(cleanupErr)}`,
{ resource_type: "tab", resource_id: tab.id },
);
}
}
return { code: "cancelled", message: "tab_create aborted" };
}
if (typeof tab.id !== "number") {
return { code: "protocol_error", message: "chrome.tabs.create returned no tab id" };
}
return { ...tab, id: tab.id };
}
@@ -434,7 +426,7 @@ export async function handleTabCreate(
const paramErr = validateTabCreateParams(params);
if (paramErr) return paramErr;
const tab = await createTabAndCleanup(ctx, deps, buildCreateProps(ctx, params));
const tab = await createTabAndCleanup(deps, buildCreateProps(ctx, params));
if (isRpcError(tab)) return tab;
return {
@@ -534,9 +526,6 @@ export async function handleTabClose(
}
try {
await getTabsApi(deps).remove(params.tab_id);
// Keep the tracking set accurate so session_stop won't try to close a
// tab that's already gone (design §3.1).
ctx.agentCreatedTabs.delete(params.tab_id);
} catch (err) {
return {
code: "protocol_error",
@@ -764,6 +753,13 @@ async function executeBorrowCore(
);
if (moveErr) return moveErr;
// Best-effort activation — failure is non-fatal.
try {
await p.tabsApi.update(p.tabId, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return { originalWindowId, originalIndex };
}
@@ -827,12 +823,6 @@ export async function handleTabBorrow(
message: err instanceof Error ? err.message : String(err),
};
}
// Activate after commit so overlay/event observers see the tab as claimed.
try {
await tabsApi.update(params.tab_id, { active: true });
} catch (err) {
console.debug("[bsk tab_borrow] activate after move failed", err);
}
return {
tab_id: params.tab_id,
original_window_id: coreResult.originalWindowId,
@@ -949,22 +939,16 @@ async function cleanupUnusedFallbackWindow(
}
}
async function releaseReturnedTabState(
function resetAgentOverlaysInReturnedTab(
ctx: SessionContext,
tabId: number,
deps: TabManagementDeps,
): Promise<void> {
): void {
void getAgentOverlayResetApi(deps)
.resetAgentOverlays(tabId, ctx.sessionId)
.catch((err) => {
console.debug("[bsk tab_return] agent overlay reset failed", err);
});
try {
await deps.cdp?.releaseSessionTab?.(ctx.sessionId, tabId);
} catch (err) {
// A successful move must not be retried because debugger cleanup failed.
console.debug("[bsk tab_return] CDP release failed", err);
}
}
/**
@@ -1038,7 +1022,7 @@ export async function returnBorrowedTab(
});
const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : targetIndex;
await releaseReturnedTabState(ctx, tabId, deps);
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
return {
tabId,
toWindowId: targetWindowId,
@@ -1080,7 +1064,7 @@ export async function returnBorrowedTab(
});
const movedTab = Array.isArray(moved) ? moved[0] : moved;
const finalIndex = typeof movedTab?.index === "number" ? movedTab.index : target.index;
await releaseReturnedTabState(ctx, tabId, deps);
resetAgentOverlaysInReturnedTab(ctx, tabId, deps);
return {
tabId,
toWindowId: target.windowId,
@@ -1126,7 +1110,6 @@ export async function handleTabReturn(
message: `tab_return: tab ${params.tab_id} is not borrowed by this session`,
};
}
await deps.beforeReturn?.(ctx.sessionId, params.tab_id);
const outcome = await returnBorrowedTab(ctx, params.tab_id, {
...deps,
isAgentWindowId:
@@ -1,42 +0,0 @@
// Shared time boundary for browser-side file-transfer transactions. Concrete
// upload/download mechanisms own their state machines and effect semantics.
export class BoundedWaitError extends Error {
constructor(
readonly kind: "timeout" | "aborted",
message: string,
) {
super(message);
}
}
export function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
export async function waitBounded<T>(
promise: Promise<T>,
deadline: number,
signal: AbortSignal | undefined,
timeoutMessage: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (signal?.aborted) throw new BoundedWaitError("aborted", "file transfer aborted");
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const boundary = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
if (signal) {
onAbort = () => reject(new BoundedWaitError("aborted", "file transfer aborted"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
return await Promise.race([promise, boundary]);
} finally {
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
+11 -37
View File
@@ -2,13 +2,8 @@
// target, then delegate the browser protocol transaction to the file-input
// transaction module.
import {
type CaptureSuppressSendToTab,
withExtensionOverlayHidden,
} from "@/lib/capture-suppress-bridge";
import type { SessionManager } from "@/session-manager/manager";
import type { RpcError, UploadParams, UploadResult } from "@/transport/types";
import { uploadThroughFileDrop } from "./file-drop-transaction";
import { uploadThroughActivatedFileInput } from "./file-input-transaction";
import { clickResolvedTarget, type InteractionDeps, resolveActionTarget } from "./interaction";
import {
@@ -23,7 +18,6 @@ const DEFAULT_TIMEOUT_MS = 120_000;
export interface UploadDeps extends InteractionDeps {
cdp: CdpRunner;
sendToTab?: CaptureSuppressSendToTab;
}
export async function handleUpload(
@@ -47,39 +41,19 @@ export async function handleUpload(
const address = await resolveActionTarget(deps.cdp, ctx, target, params, "upload");
if (isRpcError(address)) return address;
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const files = params.files.map((file) => file.staged_path as string);
const mode = params.mode ?? "input";
let usedRef = address.usedRef;
let usedSelector = address.usedSelector;
if (mode === "drop") {
deps.cdp.trackSessionTab?.(ctx.sessionId, target.tabId);
const transaction = await uploadThroughFileDrop({
cdp: deps.cdp,
actionTarget: address,
files,
timeoutMs,
signal: deps.signal,
withOverlayHidden: (operation) =>
withExtensionOverlayHidden(target.tabId, operation, deps.sendToTab),
});
if (isRpcError(transaction)) return transaction;
} else {
const transaction = await uploadThroughActivatedFileInput({
cdp: deps.cdp,
actionTarget: address,
files,
timeoutMs,
signal: deps.signal,
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
});
if (isRpcError(transaction)) return transaction;
usedRef = transaction.click.used_ref;
usedSelector = transaction.click.used_selector;
}
const transaction = await uploadThroughActivatedFileInput({
cdp: deps.cdp,
actionTarget: address,
files: params.files.map((file) => file.staged_path as string),
timeoutMs,
signal: deps.signal,
trigger: () => clickResolvedTarget(ctx, address, {}, deps),
});
if (isRpcError(transaction)) return transaction;
return {
tab_id: target.tabId,
used_ref: usedRef,
used_selector: usedSelector,
used_ref: transaction.click.used_ref,
used_selector: transaction.click.used_selector,
file_names: params.files.map((file) => file.name),
};
}
@@ -215,29 +215,21 @@ function makeCdp(snapshot: unknown) {
if (method === "Runtime.evaluate") {
return {
result: {
deepSerializedValue: {
type: "array",
value: [
value: {
controls: [
{
type: "array",
value: [
{ type: "node", value: { backendNodeId: 13 } },
{
type: "string",
value: JSON.stringify({
state: "filled",
sensitive: true,
placeholder: "secret",
}),
},
],
state: "filled",
sensitive: true,
defaultValue: "hunter2",
value: "hunter2",
placeholder: "secret",
},
],
childFrames: [],
},
},
};
}
if (method === "Runtime.releaseObjectGroup") return {};
throw new Error(`unexpected ${method}`);
}) as unknown as <T>(tabId: number, method: string, params?: object) => Promise<T>,
};
@@ -281,11 +273,8 @@ describe("captureViewModel", () => {
expect(cdp.send).toHaveBeenCalledWith(
4,
"Runtime.evaluate",
expect.objectContaining({
serializationOptions: expect.objectContaining({ serialization: "deep" }),
}),
expect.objectContaining({ returnByValue: true }),
);
expect(cdp.send).toHaveBeenCalledWith(4, "Runtime.releaseObjectGroup", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "DOM.resolveNode", expect.anything());
expect(cdp.send).not.toHaveBeenCalledWith(4, "Runtime.callFunctionOn", expect.anything());
});
@@ -1,270 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import { type CdpRunner, cdpRunnerForTarget } from "../../shared";
import { captureViewModel } from "../capture";
interface Control {
id: number;
value: string;
type?: string;
}
function snapshot(groups: Control[][]) {
const strings: string[] = [];
const str = (value: string) => {
let index = strings.indexOf(value);
if (index < 0) index = strings.push(value) - 1;
return index;
};
const documents = groups.map((controls, groupIndex) => ({
frameId: `frame-${groupIndex}`,
nodes: {
parentIndex: [-1, 0, ...controls.map(() => 1)],
nodeName: [str("html"), str("body"), ...controls.map(() => str("input"))],
backendNodeId: [8000 + groupIndex * 2, 8001 + groupIndex * 2, ...controls.map((c) => c.id)],
attributes: [
[],
[],
...controls.map((c) => [
str("type"),
str(c.type ?? "text"),
str("placeholder"),
str("snapshot placeholder"),
]),
],
inputValue: {
index: controls.map((_, i) => i + 2),
value: controls.map((c) => str(c.value)),
},
contentDocumentIndex: { index: [] as number[], value: [] as number[] },
},
}));
const root = documents[0].nodes;
for (let i = 1; i < documents.length; i++) {
root.contentDocumentIndex.index.push(root.backendNodeId.length);
root.contentDocumentIndex.value.push(i);
root.parentIndex.push(1);
root.nodeName.push(str("iframe"));
root.backendNodeId.push(9000 + i);
root.attributes.push([]);
}
return { strings, documents };
}
function state(value: string) {
return {
value,
defaultValue: "",
placeholder: "live placeholder",
state: value ? "filled" : "empty",
sensitive: false,
};
}
function reply(entries: Array<[number, unknown]>) {
return {
result: {
deepSerializedValue: {
type: "array",
value: entries.map(([backendNodeId, fields]) => ({
type: "array",
value: [
{ type: "node", value: { backendNodeId } },
{ type: "string", value: JSON.stringify(fields) },
],
})),
},
},
};
}
function fakeCdp(groups: Control[][], evaluate: (params: Record<string, unknown>) => unknown) {
const snap = snapshot(groups);
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "DOMSnapshot.captureSnapshot") return snap;
if (method === "Runtime.evaluate") return evaluate(params as Record<string, unknown>);
return {};
});
return { send, cdp: { send: send as CdpRunner["send"] } };
}
describe("form state identity", () => {
it("matches reordered controls by identity and preserves controls missing from the batch", async () => {
// The snapshot includes a shadow input. The runtime query skips it,
// encounters a new input, and sees the remaining inputs in a new order.
const { cdp } = fakeCdp(
[
[
{ id: 10, value: "" },
{ id: 11, value: "old email" },
{ id: 12, value: "old city" },
],
],
() =>
reply([
[99, state("new control")],
[12, state("上海")],
[11, state("alice@example.com")],
]),
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 10)).toMatchObject({
formValue: "",
formState: "empty",
formPlaceholder: "snapshot placeholder",
});
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "alice@example.com",
});
expect(captured.nodes.find((n) => n.backendNodeId === 12)).toMatchObject({ formValue: "上海" });
expect(captured.nodes.some((n) => n.backendNodeId === 99)).toBe(false);
});
it("does not shift state between frames when one frame is absent from the runtime batch", async () => {
const { cdp } = fakeCdp(
[
[],
[{ id: 11, value: "inaccessible frame" }],
[{ id: 21, value: "old second" }],
[{ id: 31, value: "old third" }],
],
() =>
reply([
[31, state("third frame")],
[21, state("second frame")],
]),
);
const captured = await captureViewModel(cdp, 7);
const controls = [...captured.iframeNodes.values()].flat();
expect(controls.find((n) => n.backendNodeId === 11)?.formValue).toBe("inaccessible frame");
expect(controls.find((n) => n.backendNodeId === 21)?.formValue).toBe("second frame");
expect(controls.find((n) => n.backendNodeId === 31)?.formValue).toBe("third frame");
});
it("keeps identical backend ids in different OOPIF targets separate", async () => {
const sendToTarget = vi.fn(async (target, method) => {
if (method === "DOMSnapshot.captureSnapshot") return snapshot([[{ id: 11, value: "old" }]]);
if (method === "Runtime.evaluate") return reply([[11, state(target.sessionId)]]);
return {};
});
const send = vi.fn(async () => {
throw new Error("unexpected top-level target");
});
const cdp = { send, sendToTarget } as unknown as CdpRunner;
for (const sessionId of ["left-frame", "right-frame"]) {
const captured = await captureViewModel(cdpRunnerForTarget(cdp, { tabId: 7, sessionId }), 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe(sessionId);
expect(sendToTarget).toHaveBeenCalledWith(
{ tabId: 7, sessionId },
"Runtime.releaseObjectGroup",
expect.anything(),
);
}
expect(send).not.toHaveBeenCalled();
});
it.each([
"unsupported",
"legacy",
"malformed",
"invalid-state",
])("preserves snapshot data when enrichment is %s", async (mode) => {
const { cdp, send } = fakeCdp([[{ id: 11, value: "snapshot value" }]], () => {
if (mode === "unsupported") throw new Error("serialization unsupported");
if (mode === "legacy") return { result: { value: { controls: [state("wrong value")] } } };
const result = reply([
[11, mode === "invalid-state" ? { value: "wrong value" } : state("wrong value")],
]);
if (mode === "malformed")
result.result.deepSerializedValue.value[0].value[1].value = "not json";
return result;
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)).toMatchObject({
formValue: "snapshot value",
formPlaceholder: "snapshot placeholder",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("releases the batch's remote objects when capture is cancelled", async () => {
const controller = new AbortController();
const { cdp, send } = fakeCdp([[{ id: 11, value: "old" }]], () => {
controller.abort();
return reply([[11, state("new")]]);
});
await expect(captureViewModel(cdp, 7, { signal: controller.signal })).rejects.toMatchObject({
name: "AbortError",
});
expect(send).toHaveBeenCalledWith(7, "Runtime.releaseObjectGroup", expect.anything());
});
it("skips entries without node identity while still applying valid entries", async () => {
const { cdp } = fakeCdp(
[
[
{ id: 11, value: "snapshot value" },
{ id: 12, value: "old value" },
],
],
() => {
const result = reply([
[11, state("wrong value")],
[12, state("live value")],
]);
const element = result.result.deepSerializedValue.value[0].value[0];
delete (element.value as { backendNodeId?: number }).backendNodeId;
return result;
},
);
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 11)?.formValue).toBe("snapshot value");
expect(captured.nodes.find((n) => n.backendNodeId === 12)?.formValue).toBe("live value");
});
it.each([
"password",
"text",
])("redacts a sensitive %s control after identity matching", async (type) => {
const { cdp } = fakeCdp([[{ id: 11, value: "secret", type }]], () =>
reply([[11, { ...state("secret"), sensitive: true }]]),
);
const captured = await captureViewModel(cdp, 7);
const control = captured.nodes.find((n) => n.backendNodeId === 11);
expect(control?.formState).toBe("filled");
expect(control?.formValue).toBeUndefined();
expect(control?.formDefaultValue).toBeUndefined();
expect(control?.attrs.value).toBeUndefined();
});
it("keeps one bounded batch without per-control CDP queries", async () => {
const controls = Array.from({ length: 251 }, (_, i) => ({ id: i + 1, value: "old" }));
const elements = controls.map((c) => ({
backendNodeId: c.id,
tagName: "INPUT",
type: "text",
value: `live ${c.id}`,
defaultValue: "",
placeholder: "",
}));
const { cdp, send } = fakeCdp([controls], (params) => {
const doc = {
querySelectorAll: (selector: string) =>
selector === "input,textarea,select" ? elements : [],
};
const entries = new Function("document", `return ${params.expression}`)(doc) as Array<
[{ backendNodeId: number }, string]
>;
expect(entries).toHaveLength(250);
return reply(entries.map(([element, json]) => [element.backendNodeId, JSON.parse(json)]));
});
const captured = await captureViewModel(cdp, 7);
expect(captured.nodes.find((n) => n.backendNodeId === 250)?.formValue).toBe("live 250");
expect(captured.nodes.find((n) => n.backendNodeId === 251)?.formValue).toBe("old");
expect(send.mock.calls.filter(([, method]) => method === "Runtime.evaluate")).toHaveLength(1);
expect(
send.mock.calls.filter(([, method]) => method === "Runtime.releaseObjectGroup"),
).toHaveLength(1);
expect(send).not.toHaveBeenCalledWith(7, "DOM.resolveNode", expect.anything());
expect(send).not.toHaveBeenCalledWith(7, "Runtime.callFunctionOn", expect.anything());
});
});
+26 -57
View File
@@ -190,9 +190,9 @@ interface CapturedFormState {
sensitive?: boolean;
}
interface DeepSerializedValue {
type: string;
value?: unknown;
interface CapturedFrameFormState {
controls: CapturedFormState[];
childFrames: CapturedFrameFormState[];
}
function formStateBatchExpression(maxControls: number): string {
@@ -215,63 +215,43 @@ function formStateBatchExpression(maxControls: number): string {
...(sensitive ? {} : { value: rawValue, defaultValue }),
};
};
const controls = [];
const collect = (doc) => {
const controls = [];
for (const el of Array.from(doc.querySelectorAll(controlSelector))) {
if (remaining <= 0) break;
// Deep serialization supplies the node's backend id. Keep the
// state as JSON so decoding needs no general-purpose V8 deserializer.
controls.push([el, JSON.stringify(controlState(el))]);
controls.push(controlState(el));
remaining -= 1;
}
const childFrames = [];
for (const frame of Array.from(doc.querySelectorAll("iframe"))) {
if (remaining <= 0) break;
let childDoc = null;
try { childDoc = frame.contentDocument; } catch { childDoc = null; }
if (childDoc) collect(childDoc);
childFrames.push(childDoc && remaining > 0 ? collect(childDoc) : { controls: [], childFrames: [] });
}
return { controls, childFrames };
};
collect(document);
return controls;
return collect(document);
})()`;
}
function formStatesByBackendId(result: RuntimeEvaluateReply): Map<number, CapturedFormState> {
const states = new Map<number, CapturedFormState>();
const serialized = result.result?.deepSerializedValue;
if (serialized?.type !== "array" || !Array.isArray(serialized.value)) return states;
for (const entry of serialized.value as DeepSerializedValue[]) {
if (entry?.type !== "array" || !Array.isArray(entry.value)) continue;
const [element, json] = entry.value as DeepSerializedValue[];
if (element?.type !== "node" || json?.type !== "string" || typeof json.value !== "string") {
continue;
}
const backendNodeId = (element.value as { backendNodeId?: number } | undefined)?.backendNodeId;
if (typeof backendNodeId !== "number" || !Number.isSafeInteger(backendNodeId)) continue;
try {
const state = JSON.parse(json.value) as CapturedFormState | null;
if (
!state ||
!["empty", "filled", "default"].includes(state.state ?? "") ||
typeof state.sensitive !== "boolean" ||
typeof state.placeholder !== "string" ||
(!state.sensitive &&
(typeof state.value !== "string" || typeof state.defaultValue !== "string"))
) {
continue;
}
states.set(backendNodeId, state);
} catch {
// A malformed entry must not overwrite the snapshot's own state.
}
function flattenFrameFormStates(
frame: CapturedFrameFormState | undefined,
out: CapturedFormState[][] = [],
): CapturedFormState[][] {
if (!frame) return out;
out.push(Array.isArray(frame.controls) ? frame.controls : []);
for (const child of Array.isArray(frame.childFrames) ? frame.childFrames : []) {
flattenFrameFormStates(child, out);
}
return states;
return out;
}
function applyFormStates(nodes: CapturedNode[], states: Map<number, CapturedFormState>): void {
function applyFormStates(nodes: CapturedNode[], states: CapturedFormState[]): void {
let index = 0;
for (const node of nodes) {
if (!isFormControlTag(node.tag)) continue;
const state = states.get(node.backendNodeId);
const state = states[index];
index += 1;
if (!state) continue;
node.formState = state.state;
node.formPlaceholder = state.placeholder ?? "";
@@ -296,37 +276,26 @@ async function enrichFormControlStates(
nodes.some((node) => isFormControlTag(node.tag)),
);
if (!hasControls) return;
const objectGroup = `bsk-vom-forms-${crypto.randomUUID()}`;
try {
throwIfAborted(signal);
const result = await cdp.send<RuntimeEvaluateReply>(tabId, "Runtime.evaluate", {
expression: formStateBatchExpression(MAX_FORM_ENRICH_CONTROLS),
objectGroup,
serializationOptions: {
serialization: "deep",
maxDepth: 3,
additionalParameters: { maxNodeDepth: 0, includeShadowTree: "none" },
},
returnByValue: true,
});
throwIfAborted(signal);
// Backend ids are scoped to this capture's CDP target. OOPIF targets
// use their own captureViewModel call and never share this lookup.
const states = formStatesByBackendId(result);
for (const nodes of frameNodeGroups) {
applyFormStates(nodes, states);
const frameStates = flattenFrameFormStates(runtimeValue<CapturedFrameFormState>(result));
for (let i = 0; i < frameNodeGroups.length; i += 1) {
applyFormStates(frameNodeGroups[i], frameStates[i] ?? []);
}
} catch (error) {
if (isAbortError(error)) throw error;
// Best-effort enrichment. DOMSnapshot/AX data still carries the nodes.
} finally {
await cdp.send(tabId, "Runtime.releaseObjectGroup", { objectGroup }).catch(() => undefined);
}
}
interface RuntimeEvaluateReply {
result?: {
value?: unknown;
deepSerializedValue?: DeepSerializedValue;
};
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import {
DEFAULT_DAEMON_PORT,
normalizeDaemonPort,
parseDaemonPortInput,
resolveDaemonWsUrl,
} from "../daemon-endpoint";
describe("daemon-endpoint", () => {
it("resolveDaemonWsUrl(DEFAULT) matches the build-time constant", () => {
expect(resolveDaemonWsUrl(DEFAULT_DAEMON_PORT)).toBe(__BSK_DAEMON_WS_URL__);
});
it("resolveDaemonWsUrl replaces only the port", () => {
expect(resolveDaemonWsUrl(53200)).toBe("ws://127.0.0.1:53200");
});
it("normalizeDaemonPort falls back to default for invalid values", () => {
expect(normalizeDaemonPort(undefined)).toBe(DEFAULT_DAEMON_PORT);
expect(normalizeDaemonPort(0)).toBe(DEFAULT_DAEMON_PORT);
expect(normalizeDaemonPort(65536)).toBe(DEFAULT_DAEMON_PORT);
expect(normalizeDaemonPort("abc")).toBe(DEFAULT_DAEMON_PORT);
expect(normalizeDaemonPort(1.5)).toBe(DEFAULT_DAEMON_PORT);
});
it("normalizeDaemonPort accepts valid numbers and numeric strings", () => {
expect(normalizeDaemonPort(53200)).toBe(53200);
expect(normalizeDaemonPort("53200")).toBe(53200);
});
it("parseDaemonPortInput treats empty as default and rejects invalid", () => {
expect(parseDaemonPortInput("")).toBe(DEFAULT_DAEMON_PORT);
expect(parseDaemonPortInput(" ")).toBe(DEFAULT_DAEMON_PORT);
expect(parseDaemonPortInput("abc")).toBeNull();
expect(parseDaemonPortInput("0")).toBeNull();
expect(parseDaemonPortInput("65536")).toBeNull();
});
it("parseDaemonPortInput accepts valid ports", () => {
expect(parseDaemonPortInput("53200")).toBe(53200);
expect(parseDaemonPortInput(" 52800 ")).toBe(52800);
});
});
@@ -270,4 +270,19 @@ describe("WSTransport", () => {
lastSocket().receive({ id: "2", result: 2 });
expect(handler).toHaveBeenCalledTimes(1);
});
it("setUrl returns false for the same URL and uses the new URL on the next connect", async () => {
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
expect(t.setUrl("ws://127.0.0.1:52800")).toBe(false);
expect(t.setUrl("ws://127.0.0.1:53200")).toBe(true);
const p = t.connect();
expect(lastSocket().url).toBe("ws://127.0.0.1:53200");
lastSocket().open();
await p;
expect(t.state).toBe("connected");
});
});
@@ -0,0 +1,40 @@
/** Build-time default WebSocket URL (see wxt.config.ts). */
const base = new URL(__BSK_DAEMON_WS_URL__);
export const DEFAULT_DAEMON_PORT = Number(base.port) || 52800;
const MIN_PORT = 1;
const MAX_PORT = 65535;
function isValidPort(n: number): boolean {
return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT;
}
/**
* Compose the daemon WebSocket URL for a loopback port, preserving the
* build-time host and path from {@link __BSK_DAEMON_WS_URL__}.
*/
export function resolveDaemonWsUrl(port: number): string {
const path = base.pathname === "/" ? "" : base.pathname;
return `${base.protocol}//${base.hostname}:${port}${path}`;
}
/** Non-integer / out-of-range / unset → {@link DEFAULT_DAEMON_PORT}. */
export function normalizeDaemonPort(raw: unknown): number {
if (typeof raw === "number" && isValidPort(raw)) return raw;
if (typeof raw === "string" && raw.trim() !== "") {
const parsed = Number.parseInt(raw.trim(), 10);
if (isValidPort(parsed)) return parsed;
}
return DEFAULT_DAEMON_PORT;
}
/** Input semantics: empty string → default port; invalid → null. */
export function parseDaemonPortInput(text: string): number | null {
const trimmed = text.trim();
if (trimmed === "") return DEFAULT_DAEMON_PORT;
if (!/^\d+$/.test(trimmed)) return null;
const parsed = Number.parseInt(trimmed, 10);
if (!isValidPort(parsed)) return null;
return parsed;
}
-12
View File
@@ -26,25 +26,16 @@ export type RpcErrorReason =
| "ref_not_found"
| "selector_not_found"
| "target_not_fillable"
| "fill_value_invalid"
| "fill_target_changed"
| "fill_focus_lost"
| "fill_value_mismatch"
| "fill_failed"
| "target_not_select"
| "option_not_found"
| "single_select_value_count"
| "tab_not_active"
| "restricted_tab_url"
| "cdp_extension_access_denied"
| "borrow_conflict"
| "screenshot_capture_failed"
| "file_input_probe_failed"
| "file_input_not_activated"
| "set_file_input_failed"
| "upload_mechanism_unsupported"
| "file_drop_target_unavailable"
| "file_drop_failed"
| "download_capture_failed"
| "transfer_outcome_unknown"
| "transfer_timeout"
@@ -532,15 +523,12 @@ export interface UploadFile {
staged_path?: string;
}
export type UploadMode = "input" | "drop";
export interface UploadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
files: UploadFile[];
mode?: UploadMode;
timeout_ms?: number;
}
+8 -1
View File
@@ -40,7 +40,7 @@ interface MessageLikeEvent {
* (1s, 2s, 4s, …, capped at 5s) until `disconnect()` is called.
*/
export class WSTransport implements Transport {
private readonly url: string;
private url: string;
private readonly factory: WebSocketFactory;
private readonly initialDelayMs: number;
private readonly maxDelayMs: number;
@@ -69,6 +69,13 @@ export class WSTransport implements Transport {
return this.currentState;
}
/** Returns whether the URL changed. Does not reconnect. */
setUrl(url: string): boolean {
if (url === this.url) return false;
this.url = url;
return true;
}
connect(): Promise<void> {
if (this.currentState === "connected") return Promise.resolve();
+146 -37
View File
@@ -116,15 +116,156 @@ navigate navigate-back navigate-forward reload wait-for-navigation wai
observe snapshot get-html screenshot console network
click hover fill select press evaluate
tab list|create|close|select|borrow|return window resize emulate
upload download request-help record start|stop
request-help record start|stop
```
Required flags that are easy to get wrong:
```text
bsk fill <ref> --value <text> bsk select <ref> --value <option-value>
bsk screenshot --out <path> bsk emulate --device <preset-id>
bsk upload <ref> --file <path> bsk download <ref> --out <path>
### Tabs (require `--session <id>`)
| Command | Summary |
|---------|---------|
| `bsk tab list` | List tabs (`--scope user\|agent\|all`, default `all`) |
| `bsk tab create` | New tab in Agent Window (`--url`, `--no-active`, `--index`) |
| `bsk tab close <tab-id>` | Close an agent tab |
| `bsk tab select <tab-id>` | Focus an agent tab |
| `bsk tab borrow <tab-id>` | Move a user tab into the Agent Window |
| `bsk tab return <tab-id>` | Return a borrowed tab to its original window |
### Observation (require `--session` unless noted)
| Command | Summary |
|---------|---------|
| `bsk snapshot` | First-choice static page understanding: accessibility tree with `@eN` element refs |
| `bsk observe` | Semantic VOM observation with bounded perception probes for conditional surfaces |
| `bsk get-html` | Raw HTML dump after snapshot is insufficient (high token cost) |
| `bsk screenshot` | PNG capture after snapshot is insufficient: full visible tab, or `--ref @eN` to crop to one element (`--out` path optional) |
### Console & network debugging (read-only; require `--session`)
| Command | Summary |
|---------|---------|
| `bsk console` | Buffered page console messages, JS exceptions, and browser log entries (`--include-stack` for stack traces) |
| `bsk network` | Buffered network responses (status, method, URL, MIME/resource type) and failures (`net::ERR_*` reason) |
Both capture from the moment the tab is attached and read a bounded per-tab buffer: `--since <seq>` pages from a cursor (`next_since` in the result), `--limit` (default 50, max 200), `--max-text-chars` (default 1000, max 4096), `--tab-id` to target a non-active tab. Both are strictly read-only — they never intercept or modify traffic, and request/response headers, bodies, and timings are not captured.
### Navigation
| Command | Summary |
|---------|---------|
| `bsk navigate <url>` | Go to URL in agent tab (`--wait-until`, `--timeout`) |
| `bsk navigate-back` | History back one step |
| `bsk navigate-forward` | History forward one step |
| `bsk reload` | Reload current tab (`--hard` bypass cache) |
(`bsk navigate back` / `bsk navigate forward` are equivalent subcommands.)
### Interaction
| Command | Summary |
|---------|---------|
| `bsk click <ref-or-selector>` | Click element (`--button`, `--click-count`, `--modifiers`) |
| `bsk hover <ref-or-selector>` | Move the mouse to an element and wait for hover UI to settle (`--settle`, `--modifiers`) |
| `bsk fill <ref-or-selector> --value <text>` | Clear and type into input |
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### File transfer (require `--session`)
| Command | Summary |
|---------|---------|
| `bsk upload <ref-or-selector> --file <path>` | Click one upload trigger and attach an agent-readable local file (`--file` is repeatable) |
| `bsk download <ref-or-selector> --out <path>` | Click one download trigger and copy the single completed file to an exact local path (`--overwrite` is opt-in) |
The agent/harness decides whether a file transfer is appropriate and which local path belongs to the task. Treat upload as disclosure of that file to the current website, and download as accepting website-controlled bytes onto the local filesystem. Use only paths that are necessary for the user's bounded goal.
BrowserSkill enforces the mechanical boundary: files are staged under a session-scoped opaque transfer, only daemon-minted capabilities reach the extension, upload/download still obey Agent Window tab checks, and transfers are chunk/size bounded. Upload intercepts the native chooser for one transaction, locates the input activated in the resolved target's document, and assigns only the staged file paths. Download uniquely correlates one exact-target browser intent with one Chrome download in either event order, routes it through a daemon-minted relative directory, and lets the daemon validate and import it. Upload staging remains available for a later form submission and is removed when the session ends. Downloads cannot overwrite an existing destination unless `--overwrite` is explicit. BrowserSkill does not inspect file content or decide whether its meaning is sensitive.
Do not use `request-help` merely because a native file chooser or browser download is involved; try these commands first. For transfer failures, use the structured error instead of retrying blindly:
- `reason=file_input_not_activated` means the requested click did not activate exactly one `<input type="file">`; the page may use a non-input picker such as `window.showOpenFilePicker()`. Do not retry blindly. If human help is available, call `request-help` and tell the user the exact original local path to choose. The staged daemon path is internal and must not be shown to the user.
- `reason=file_input_probe_failed` means BrowserSkill could not safely establish the browser-side upload transaction. Do not repeat the same action; use `request-help` when available.
- `reason=set_file_input_failed` means BrowserSkill found the activated file input but Chrome rejected the staged path or assignment. Check the extension's file-URL access permission; otherwise use `request-help`.
- `reason=download_capture_failed` means BrowserSkill could not attribute exactly one completed download to the requested target. Do not retry blindly or accept an unrelated browser download; use `request-help` when available.
- `effect_state=none` means BrowserSkill confirmed that no file-transfer effect was committed. Follow the accompanying reason; a corrected target or explicit human fallback may be attempted.
- `effect_state=unknown` means the browser may already have attached or created the file. Do not repeat the transfer. Observe the page if that can establish the result; otherwise stop and report the uncertainty.
- `effect_state=committed` means the browser-side effect occurred even if later completion or cleanup failed. Do not repeat it; continue only after verifying the resulting page/download state.
If `request-help` returns `outcome="disabled"`, do not retry it. Stop gracefully and report the transfer mechanism that requires human intervention.
### Scripting & timing
| Command | Summary |
|---------|---------|
| `bsk evaluate <expression>` | Run JS in agent tab (see red lines). JS throw → stderr, **exit 0** (RPC success); use `--json` and check `.ok` to detect JS errors |
| `bsk wait-for-navigation` | Block until load/DOM idle/etc. (`--wait-until`, `--timeout`) |
| `bsk wait-ms <duration>` | Sleep (`500ms`, `2s`, `1m`; **no** `--session`) |
### Ask the human for help — `bsk request-help`
When a step needs a human (captcha, login, OTP) or you want the user to
confirm an important action, pause and ask:
bsk request-help --session <id> --prompt "Solve the captcha, then click Done only after the site accepts it" \
--title "Captcha required" --target @e7 --target "#submit" --timeout 5m
- `--prompt` (required): what the user should do.
- `--title` (optional): custom title for the overlay panel. When omitted,
the extension shows its default localized title.
- `--target` (repeatable): a snapshot ref (`@e7`) or CSS selector
(`#submit`) to scroll to and flash-highlight. **Strongly recommended** —
whenever the prompt refers to a concrete element (a button to click, a
field to fill, a checkbox to toggle), pass its `@eN` ref / selector so the
user is guided straight to the right spot instead of hunting for it. For
interaction scenarios, always include the relevant target(s); reserve a
prompt with no `--target` for cases where there is genuinely no specific
element to point at (e.g. "wait for the page to finish loading").
- `--timeout` (default `5m`): how long to wait.
- `--completion-criteria` (optional): JSON success detector. Use it only
when there is a concrete post-help success signal, e.g.
`{"any":[{"url_contains":"/dashboard"},{"selector_exists":"[data-testid='account-menu']"}],"stable_for_ms":1000}`.
The target tab is brought to the foreground; the page stays interactive
while the agent control mask is hidden. The call blocks until the user
explicitly acts, the timeout expires, cancellation arrives, or explicit
completion criteria match. Page reloads, SPA route changes, and captcha
refreshes do not return control by themselves. The result `outcome` is one of:
- `continued` — the user finished and clicked Done / return control (treat as confirm).
- `cancelled` — the user clicked Cancel (treat as reject/abort).
- `timed_out` — nobody acted within the timeout.
- `completed` — the explicit `--completion-criteria` matched while the user had control.
- `navigated` — deprecated legacy outcome. Do not rely on navigation as a completion signal.
`note` carries any text the user typed back. `resolved_targets` reports
which refs/selectors matched a live element.
`request-help` does not refresh the page model after the user returns
control. After a `continued` or `completed` result, issue a separate
observation tool call (usually `bsk snapshot --session <id>`) before using
new refs or reasoning about the post-help page state.
#### Disabling request-help (unattended mode)
Set `BSK_REQUEST_HELP=off` on unattended servers: `bsk request-help` then
returns immediately with `outcome="disabled"` (no overlay, no waiting,
exit 0). Any other value keeps it enabled. If you get `disabled`, do not
retry — complete the task autonomously or stop gracefully.
### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a **trace bundle**, for later LLM-driven automation. New CLI builds request **trace v3** (page observations + action chain); older extensions may still return **trace v2** (actions only), which the CLI exports as a single `trace.json` without `states/`.
```bash
bsk record start --browser <instance-id-or-label> \
[--url https://…] [--purpose "publish a wiki doc"] \
[--max-page-tokens 3000] [--redact-values] \
[--output trace]
# `--url` is optional; default https://example.com/ when omitted (must be http(s)).
# Blocks until the user clicks Finish in the recording panel, then writes:
# trace/trace.json — action chain (+ state index when v3)
# trace/states/ — v3 only: one `sN.txt` observe snapshot per settled page state
bsk record stop [--output trace] # terminal fallback if the browser panel is unavailable
```
`select` matches an option's `value` attribute, not its visible label. Device preset ids are
@@ -139,43 +280,11 @@ lowercase and hyphenated, such as `iphone-14`.
- `record` captures a user's actions for later replay. Read `bsk record start --help` before use,
and never record banking, SSO, password-manager, or other sensitive pages.
## File transfer
`upload` and `download` stage files through the daemon; the agent never touches browser-internal
paths. Treat upload as disclosure to the website, download as accepting website-controlled bytes.
Upload has two independent mechanisms — choose explicitly, never rely on automatic fallback:
- **Default (input mode):** for upload buttons, file-input labels, or "upload from computer"
actions. The command clicks the target and intercepts the native file chooser.
- **`--mode drop`:** for reliably identified attachment-receiving areas — an explicit drop zone,
chat composer, email editor, or form attachment area. Do not target page whitespace, generic
containers, or areas whose attachment ownership is ambiguous.
Decision sequence when uploading:
1. Try input mode (the default).
2. If it returns `reason=file_input_not_activated` with `effect_state=none`, re-observe. When a
reliable attachment target exists, try `--mode drop` once against that target.
3. Otherwise fall back to `request-help`.
4. **Never** switch mechanisms or repeat when `effect_state` is `unknown` or `committed` — the
browser may already have applied the file.
A successful drop means Chrome dispatched the native file-drop event; it does not prove the site
accepted the attachment. Observe the page once after the command.
Download default-refuses to overwrite; pass `--overwrite` when replacing an existing file is
intended. Read `bsk upload --help` and `bsk download --help` for all flags and error details.
## Recover without wandering
- Stale ref: observe again and retry the intended action once.
- Unknown tab or session: list current tabs/sessions; never guess identifiers.
- Timeout: inspect current page state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed (`fill_value_mismatch`): observe the field first; the page may have
formatted the value. Continue if the visible result satisfies the user's intent. Otherwise correct
the remaining difference; do not blindly repeat fill or immediately request human help. For other
fill errors, follow the returned hint and inspect current state before retrying.
- Unsupported command: continue with available capabilities; suggest updating only when the missing
command is necessary.
- Unrecoverable failure: report the blocker and stop the session in a finally-style path.
+1 -1
View File
@@ -182,7 +182,7 @@ pub enum Command {
/// Set `<select>` option values by `value` attribute.
Select(SelectArgs),
/// Upload files through a page file input or explicit drop target.
/// Upload one or more local files through a page file chooser.
Upload(UploadArgs),
/// Capture one browser download and write it to a local path.
+3 -166
View File
@@ -43,24 +43,15 @@ pub mod reason {
pub const REF_NOT_FOUND: &str = "ref_not_found";
pub const SELECTOR_NOT_FOUND: &str = "selector_not_found";
pub const TARGET_NOT_FILLABLE: &str = "target_not_fillable";
pub const FILL_VALUE_INVALID: &str = "fill_value_invalid";
pub const FILL_TARGET_CHANGED: &str = "fill_target_changed";
pub const FILL_FOCUS_LOST: &str = "fill_focus_lost";
pub const FILL_VALUE_MISMATCH: &str = "fill_value_mismatch";
pub const FILL_FAILED: &str = "fill_failed";
pub const TARGET_NOT_SELECT: &str = "target_not_select";
pub const OPTION_NOT_FOUND: &str = "option_not_found";
pub const SINGLE_SELECT_VALUE_COUNT: &str = "single_select_value_count";
pub const TAB_NOT_ACTIVE: &str = "tab_not_active";
pub const CDP_EXTENSION_ACCESS_DENIED: &str = "cdp_extension_access_denied";
pub const BORROW_CONFLICT: &str = "borrow_conflict";
pub const SCREENSHOT_CAPTURE_FAILED: &str = "screenshot_capture_failed";
pub const FILE_INPUT_PROBE_FAILED: &str = "file_input_probe_failed";
pub const FILE_INPUT_NOT_ACTIVATED: &str = "file_input_not_activated";
pub const SET_FILE_INPUT_FAILED: &str = "set_file_input_failed";
pub const UPLOAD_MECHANISM_UNSUPPORTED: &str = "upload_mechanism_unsupported";
pub const FILE_DROP_TARGET_UNAVAILABLE: &str = "file_drop_target_unavailable";
pub const FILE_DROP_FAILED: &str = "file_drop_failed";
pub const DOWNLOAD_CAPTURE_FAILED: &str = "download_capture_failed";
pub const TRANSFER_OUTCOME_UNKNOWN: &str = "transfer_outcome_unknown";
pub const TRANSFER_TIMEOUT: &str = "transfer_timeout";
@@ -212,13 +203,6 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
return base;
};
match (code, reason) {
(ErrorCode::CdpFailed, reason::CDP_EXTENSION_ACCESS_DENIED) => RenderInfo {
summary: "Chrome blocked CDP access to another extension's content in this tab",
hint: Some(
"a web page can contain a restricted extension frame; disable the conflicting extension and reload, or use `bsk navigate <url>` to leave this page; reconnecting BrowserSkill alone does not remove the restriction",
),
exit_code: base.exit_code,
},
(_, reason::TRANSFER_OUTCOME_UNKNOWN) => RenderInfo {
summary: "the file transfer outcome could not be confirmed",
hint: Some(
@@ -260,42 +244,7 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::InvalidParams, reason::TARGET_NOT_FILLABLE) => RenderInfo {
summary: "target element is not fillable",
hint: Some(
"observe the page and choose an enabled, editable text input, textarea, or contenteditable; use the appropriate interaction for other control types",
),
exit_code: base.exit_code,
},
(ErrorCode::InvalidParams, reason::FILL_VALUE_INVALID) => RenderInfo {
summary: "the requested text does not fit the field constraints",
hint: Some(
"check the field's input type and maxlength; use a compatible value without silently truncating or changing the user's intended data",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_TARGET_CHANGED) => RenderInfo {
summary: "the fill target changed during the action",
hint: Some(
"observe the current page and field value; if the intended result is missing, use a fresh editable target before retrying",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FOCUS_LOST) => RenderInfo {
summary: "the page moved focus away from the fill target",
hint: Some(
"observe the page for a dialog or rerender and resolve it before retrying; fill manages field focus and does not require bringing the browser to the foreground",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_VALUE_MISMATCH) => RenderInfo {
summary: "the fill result could not be confirmed",
hint: Some(
"observe the field before retrying; the page may have formatted the value. Continue if the visible result satisfies the user's intent; otherwise correct the remaining difference. Do not blindly repeat fill or immediately request human help",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::FILL_FAILED) => RenderInfo {
summary: "fill encountered a browser or page-script error",
hint: Some(
"observe the page before retrying because the field may already have changed; retry only if the intended result is missing and the target is still available",
"choose an input, textarea, or contenteditable element from the latest snapshot",
),
exit_code: base.exit_code,
},
@@ -354,7 +303,7 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
(ErrorCode::Unsupported, reason::FILE_INPUT_NOT_ACTIVATED) => RenderInfo {
summary: "the upload trigger did not activate a file input",
hint: Some(
"the page may use a non-input picker such as showOpenFilePicker(); when effect_state is none, re-observe and use `--mode drop` once only for a reliably identified attachment-receiving area — otherwise use `bsk request-help` with the exact original local path",
"the page may use a non-input picker such as showOpenFilePicker(); do not retry blindly — use `bsk request-help` with the exact original local path, or stop if human help is disabled",
),
exit_code: base.exit_code,
},
@@ -374,30 +323,6 @@ pub fn info_for_error(code: ErrorCode, data: Option<&serde_json::Value>) -> Rend
),
exit_code: base.exit_code,
},
(ErrorCode::Unsupported, reason::UPLOAD_MECHANISM_UNSUPPORTED) => RenderInfo {
summary: "the browser does not support native file-drop upload",
hint: Some(
"do not retry the same drop; use a standard file-input target or `bsk request-help`",
),
exit_code: base.exit_code,
},
(
ErrorCode::PermissionDenied | ErrorCode::Timeout | ErrorCode::CdpFailed,
reason::FILE_DROP_TARGET_UNAVAILABLE,
) => RenderInfo {
summary: "the file-drop target could not be safely resolved",
hint: Some(
"rerun observe and select the attachment-receiving editor or drop zone itself; do not guess another target — no file was delivered when effect_state is none",
),
exit_code: base.exit_code,
},
(ErrorCode::Timeout | ErrorCode::CdpFailed, reason::FILE_DROP_FAILED) => RenderInfo {
summary: "the browser could not complete the native file drop",
hint: Some(
"do not retry when effect_state is unknown; observe the page first, otherwise use `bsk request-help`",
),
exit_code: base.exit_code,
},
(ErrorCode::CdpFailed, reason::DOWNLOAD_CAPTURE_FAILED) => RenderInfo {
summary: "the browser download could not be attributed or completed",
hint: Some(
@@ -490,56 +415,6 @@ mod tests {
ErrorCode::NoBrowserConnected,
];
#[test]
fn fill_reasons_provide_recovery_without_changing_exit_codes() {
for (code, reason, hint_fragment) in [
(
ErrorCode::InvalidParams,
reason::TARGET_NOT_FILLABLE,
"enabled, editable",
),
(
ErrorCode::InvalidParams,
reason::FILL_VALUE_INVALID,
"maxlength",
),
(
ErrorCode::CdpFailed,
reason::FILL_TARGET_CHANGED,
"fresh editable target",
),
(
ErrorCode::CdpFailed,
reason::FILL_FOCUS_LOST,
"does not require bringing the browser",
),
(
ErrorCode::CdpFailed,
reason::FILL_VALUE_MISMATCH,
"Continue if the visible result satisfies",
),
(
ErrorCode::CdpFailed,
reason::FILL_FAILED,
"may already have changed",
),
] {
let data = serde_json::json!({ "reason": reason });
let info = info_for_error(code, Some(&data));
assert!(info.hint.unwrap().contains(hint_fragment), "{reason}");
assert_eq!(info.exit_code, info_for(code).exit_code);
}
let mismatch = serde_json::json!({ "reason": reason::FILL_VALUE_MISMATCH });
let info = info_for_error(ErrorCode::CdpFailed, Some(&mismatch));
assert_eq!(info.summary, "the fill result could not be confirmed");
assert!(info.hint.unwrap().contains("Do not blindly repeat fill"));
let unknown = serde_json::json!({ "reason": "future_fill_reason" });
assert_eq!(
info_for_error(ErrorCode::CdpFailed, Some(&unknown)),
info_for(ErrorCode::CdpFailed)
);
}
#[test]
fn element_not_visible_overrides_permission_denied_copy() {
let data = serde_json::json!({ "reason": reason::ELEMENT_NOT_VISIBLE });
@@ -552,18 +427,6 @@ mod tests {
assert!(!info.summary.contains("sandbox"));
}
#[test]
fn extension_access_denied_explains_the_frame_restriction_and_navigation_recovery() {
let data = serde_json::json!({ "reason": reason::CDP_EXTENSION_ACCESS_DENIED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&data));
assert!(info.summary.contains("another extension"));
let hint = info.hint.unwrap();
assert!(hint.contains("restricted extension frame"));
assert!(hint.contains("bsk navigate <url>"));
assert!(hint.contains("disable the conflicting extension"));
assert_eq!(info.exit_code, 3);
}
#[test]
fn session_busy_overrides_timeout_copy() {
let data = serde_json::json!({ "reason": reason::SESSION_BUSY });
@@ -614,10 +477,7 @@ mod tests {
info.summary,
"the upload trigger did not activate a file input"
);
let hint = info.hint.unwrap();
assert!(hint.contains("--mode drop"));
assert!(hint.contains("reliably identified attachment-receiving area"));
assert!(hint.contains("exact original local path"));
assert!(info.hint.unwrap().contains("exact original local path"));
let control = serde_json::json!({ "reason": reason::FILE_INPUT_PROBE_FAILED });
let info = info_for_error(ErrorCode::Timeout, Some(&control));
@@ -627,29 +487,6 @@ mod tests {
);
assert!(info.hint.unwrap().contains("do not retry"));
let unsupported_drop =
serde_json::json!({ "reason": reason::UPLOAD_MECHANISM_UNSUPPORTED });
let info = info_for_error(ErrorCode::Unsupported, Some(&unsupported_drop));
assert_eq!(
info.summary,
"the browser does not support native file-drop upload"
);
assert!(info.hint.unwrap().contains("standard file-input"));
let unavailable_target =
serde_json::json!({ "reason": reason::FILE_DROP_TARGET_UNAVAILABLE });
let info = info_for_error(ErrorCode::PermissionDenied, Some(&unavailable_target));
assert_eq!(
info.summary,
"the file-drop target could not be safely resolved"
);
assert!(info.hint.unwrap().contains("effect_state is none"));
let failed_drop = serde_json::json!({ "reason": reason::FILE_DROP_FAILED });
let info = info_for_error(ErrorCode::Timeout, Some(&failed_drop));
assert!(info.summary.contains("native file drop"));
assert!(info.hint.unwrap().contains("effect_state is unknown"));
let download = serde_json::json!({ "reason": reason::DOWNLOAD_CAPTURE_FAILED });
let info = info_for_error(ErrorCode::CdpFailed, Some(&download));
assert!(info.summary.contains("download could not be attributed"));
+71 -236
View File
@@ -6,7 +6,9 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
#[cfg(windows)]
mod windows;
use std::os::windows::process::CommandExt;
#[cfg(windows)]
use std::process::{Command, Stdio};
use anyhow::{Context, Result, bail};
use clap::Args;
@@ -361,8 +363,7 @@ fn install_candidate_with_client(
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
}
let restart_args = daemon_was_running.then(StartArgs::default);
let action = replace_binary_for_update(&target, &binary, restart_args.as_ref())?;
let action = replace_binary_for_update(&target, &binary, daemon_was_running)?;
if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default())
@@ -393,8 +394,8 @@ pub(crate) fn download_candidate_binary(
/// Daemon-side install: download, verify, and replace the executable at
/// `target` (on Windows: stage the replacement next to it). Unlike the
/// CLI path this never stops the daemon. On Windows the helper starts
/// its replacement after exit; elsewhere the daemon drives its own restart.
/// CLI path this never stops or starts the daemon — the daemon drives
/// its own restart once the binary is replaced.
///
/// `target` must be captured *before* any replacement happens: on Linux
/// `std::env::current_exe` starts returning a ` (deleted)`-suffixed
@@ -402,11 +403,10 @@ pub(crate) fn download_candidate_binary(
pub(crate) fn self_install_candidate(
candidate: &UpdateCandidate,
target: &Path,
restart_args: &StartArgs,
) -> Result<InstallAction> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let binary = download_candidate_binary(candidate, &client)?;
replace_binary_for_update(target, &binary, Some(restart_args))
replace_binary_at_path(target, &binary)
}
/// Outcome of one daemon auto-update step (see [`auto_update_step`]).
@@ -423,8 +423,8 @@ pub(crate) enum AutoUpdateOutcome {
/// The new binary replaced the old one; the daemon should now
/// restart into it.
Replaced { latest: String },
/// Windows started a helper that will replace the executable and
/// restart the daemon once the current process exits.
/// Windows staged the replacement next to the running binary; a
/// daemon/terminal restart finishes it.
Staged { latest: String },
}
@@ -668,7 +668,6 @@ fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
}
if matches!(report.install_action, Some("staged")) {
println!("the detached update helper will apply the replacement after exit");
println!("if it fails, see the *.update-*.log file next to the executable");
}
}
Format::Json => {
@@ -689,22 +688,22 @@ pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec
}
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
replace_binary_for_update(target, binary, None)
replace_binary_for_update(target, binary, false)
}
fn replace_binary_for_update(
target: &Path,
binary: &[u8],
restart_args: Option<&StartArgs>,
restart_daemon: bool,
) -> Result<InstallAction> {
#[cfg(windows)]
{
stage_windows_replacement(target, binary, restart_args)
stage_windows_replacement(target, binary, restart_daemon)
}
#[cfg(not(windows))]
{
let _ = restart_args;
let _ = restart_daemon;
replace_binary_atomically(target, binary)
}
}
@@ -750,69 +749,33 @@ fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallActi
fn stage_windows_replacement(
target: &Path,
binary: &[u8],
restart_args: Option<&StartArgs>,
restart_daemon: bool,
) -> Result<InstallAction> {
// The helper owns replacement/restart after this process exits. Do not
// report Staged until it has actually begun executing the script.
let _child = spawn_windows_replacement(target, binary, restart_args, 120)?;
Ok(InstallAction::Staged)
}
#[cfg(windows)]
fn spawn_windows_replacement(
target: &Path,
binary: &[u8],
restart_args: Option<&StartArgs>,
attempts: u32,
) -> Result<windows::Helper> {
let paths = staged_replacement_paths(target, std::process::id())?;
let ready_path = paths.script_path.with_extension("ready");
let log_path = paths.script_path.with_extension("log");
let result = (|| {
// A previous failed attempt by this process must not acknowledge a
// new helper. All paths are private to this target and process id.
match std::fs::remove_file(&ready_path) {
Ok(()) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(err.into()),
}
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(restart_args, attempts),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
let mut child = windows::spawn(
&paths.script_path,
&paths.binary_path,
target,
&ready_path,
&log_path,
)
.context("launch detached Windows update helper")?;
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
if std::fs::remove_file(&ready_path).is_ok() {
return Ok(child);
}
if let Some(status) = child.try_wait()? {
bail!("Windows update helper exited before becoming ready: {status}");
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
bail!("Windows update helper did not become ready within 5 seconds");
}
std::thread::sleep(Duration::from_millis(20));
}
})();
if result.is_err() {
std::fs::write(&paths.binary_path, binary)
.with_context(|| format!("write {}", paths.binary_path.display()))?;
std::fs::write(
&paths.script_path,
windows_replacement_script(target, &paths.binary_path, restart_daemon),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
let command = format!("\"{}\"", paths.script_path.display());
if let Err(err) = Command::new("cmd.exe")
.args(["/D", "/S", "/C"])
.arg(command)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.creation_flags(CREATE_NO_WINDOW)
.spawn()
{
let _ = std::fs::remove_file(&paths.binary_path);
let _ = std::fs::remove_file(&paths.script_path);
let _ = std::fs::remove_file(&ready_path);
return Err(err).context("launch detached Windows update helper");
}
result.with_context(|| format!("Windows update helper failed; see {}", log_path.display()))
Ok(InstallAction::Staged)
}
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
@@ -829,43 +792,25 @@ pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplace
}
#[cfg(any(windows, test))]
fn windows_replacement_script(restart_args: Option<&StartArgs>, attempts: u32) -> String {
// Only fixed switches and numeric values go into the ASCII script. Paths
// stay in Unicode environment variables, including %, ! and shell symbols.
let restart = restart_args.map_or_else(String::new, |args| {
format!(
"\"%BSK_UPDATE_TARGET%\" daemon start --port {} --session-idle {}ms --daemon-idle {}ms\r\nif errorlevel 1 goto failed_restart\r\n",
args.resolved_port(),
args.resolved_session_idle().as_millis(),
args.resolved_daemon_idle().as_millis(),
)
});
fn windows_replacement_script(target: &Path, staged_binary: &Path, restart_daemon: bool) -> String {
let target = target.display().to_string().replace('%', "%%");
let staged_binary = staged_binary.display().to_string().replace('%', "%%");
let restart = if restart_daemon {
format!("start \"\" /B \"{target}\" daemon start >nul 2>nul\r\n")
} else {
String::new()
};
format!(
"@echo off\r\n\
setlocal DisableDelayedExpansion\r\n\
> \"%BSK_UPDATE_READY%\" echo ready\r\n\
if errorlevel 1 exit /b 1\r\n\
set /a attempts=0\r\n\
setlocal\r\n\
:retry\r\n\
move /Y \"%BSK_UPDATE_SOURCE%\" \"%BSK_UPDATE_TARGET%\" >nul\r\n\
if not errorlevel 1 goto replaced\r\n\
set /a attempts+=1\r\n\
if %attempts% geq {attempts} goto failed_replace\r\n\
if not exist \"%BSK_UPDATE_SOURCE%\" goto failed_replace\r\n\
ping -n 2 127.0.0.1 >nul\r\n\
goto retry\r\n\
:failed_replace\r\n\
echo Failed to replace the executable after %attempts% attempts.\r\n\
goto failed\r\n\
:replaced\r\n\
move /Y \"{staged_binary}\" \"{target}\" >nul 2>nul\r\n\
if errorlevel 1 (\r\n\
timeout /t 1 /nobreak >nul\r\n\
goto retry\r\n\
)\r\n\
{restart}\
del /F /Q \"%BSK_UPDATE_LOG%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 0)\r\n\
:failed_restart\r\n\
echo Replaced the executable but failed to restart the daemon.\r\n\
:failed\r\n\
del /F /Q \"%BSK_UPDATE_SOURCE%\" >nul 2>nul\r\n\
(del /F /Q \"%BSK_UPDATE_SCRIPT%\" >nul 2>nul & exit 1)\r\n"
del /F /Q \"%~f0\" >nul 2>nul\r\n"
)
}
@@ -1019,7 +964,6 @@ mod tests {
assert_eq!(extracted, b"windows binary");
}
#[cfg(not(windows))]
#[test]
fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap();
@@ -1054,139 +998,29 @@ mod tests {
}
#[test]
fn windows_replacement_script_preserves_restart_config() {
let args = StartArgs {
port: Some(54321),
session_idle: Some(Duration::from_millis(1234)),
daemon_idle: Some(Duration::from_secs(75)),
..Default::default()
};
let script = windows_replacement_script(Some(&args), 120);
assert!(script.is_ascii());
assert!(
script
.contains("daemon start --port 54321 --session-idle 1234ms --daemon-idle 75000ms")
fn windows_replacement_script_retries_restarts_and_cleans_itself() {
let script = windows_replacement_script(
Path::new(r"C:\Program Files\bsk.exe"),
Path::new(r"C:\Program Files\bsk.exe.update-42"),
true,
);
assert!(!windows_replacement_script(None, 120).contains("daemon start"));
assert!(script.contains(":retry"));
assert!(script.contains("move /Y"));
assert!(script.contains(r#"start "" /B "C:\Program Files\bsk.exe" daemon start"#));
assert!(script.contains(r#"del /F /Q "%~f0""#));
}
#[cfg(windows)]
fn wait_for_helper(mut child: windows::Helper) -> std::process::ExitStatus {
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
if let Some(status) = child.try_wait().unwrap() {
return status;
}
if std::time::Instant::now() >= deadline {
let _ = child.kill();
let _ = child.wait();
panic!("Windows update helper did not exit");
}
std::thread::sleep(Duration::from_millis(20));
}
}
#[cfg(windows)]
#[test]
fn windows_helper_replaces_in_unicode_spaces_and_shell_symbol_paths() {
let tmp = tempfile::TempDir::new().unwrap();
for name in [
"ascii",
"with space",
"中文目录",
"literal %PATH% ! & (folder)",
] {
let dir = tmp.path().join(name);
std::fs::create_dir(&dir).unwrap();
let target = dir.join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let status = wait_for_helper(child);
assert!(
status.success(),
"{name}: {:?}",
std::fs::read_to_string(paths.script_path.with_extension("log"))
);
assert_eq!(std::fs::read(&target).unwrap(), b"new binary", "{name}");
// Successful updates leave no helper, staged binary, ready file or log.
assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 1, "{name}");
}
}
#[cfg(windows)]
#[test]
fn windows_helper_waits_for_unlock_then_replaces() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Deny delete sharing, like a running Windows executable.
let lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let mut child = spawn_windows_replacement(&target, b"new binary", None, 5).unwrap();
std::thread::sleep(Duration::from_millis(150));
assert!(child.try_wait().unwrap().is_none());
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
drop(lock);
assert!(wait_for_helper(child).success());
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
}
#[cfg(windows)]
#[test]
fn windows_helper_bounds_retries_and_preserves_old_binary_on_failure() {
use std::os::windows::fs::OpenOptionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
let _lock = std::fs::OpenOptions::new()
.read(true)
.share_mode(1)
.open(&target)
.unwrap();
let child = spawn_windows_replacement(&target, b"new binary", None, 2).unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"old binary");
assert!(!paths.binary_path.exists());
assert!(!paths.script_path.exists());
let log_bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&log_bytes);
assert!(
log.contains("Failed to replace the executable after 2 attempts."),
"{log}"
fn windows_replacement_script_omits_restart_when_daemon_was_not_running() {
let script = windows_replacement_script(
Path::new(r"C:\bsk.exe"),
Path::new(r"C:\bsk.exe.update-42"),
false,
);
}
#[cfg(windows)]
#[test]
fn windows_helper_reports_restart_failure_without_losing_replacement() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk.exe");
std::fs::write(&target, b"old binary").unwrap();
// Not a valid PE executable: replacement succeeds but restart fails.
let child = spawn_windows_replacement(
&target,
b"invalid executable",
Some(&StartArgs::default()),
2,
)
.unwrap();
assert!(!wait_for_helper(child).success());
let paths = staged_replacement_paths(&target, std::process::id()).unwrap();
let bytes = std::fs::read(paths.script_path.with_extension("log")).unwrap();
let log = String::from_utf8_lossy(&bytes);
assert!(
log.contains("Replaced the executable but failed to restart the daemon."),
"{log}"
);
assert_eq!(std::fs::read(&target).unwrap(), b"invalid executable");
assert!(!paths.script_path.exists());
assert!(!paths.binary_path.exists());
assert!(!script.contains("daemon start"));
assert!(script.contains(r#"del /F /Q "%~f0""#));
}
#[test]
@@ -1445,9 +1279,10 @@ mod tests {
}
#[test]
fn auto_update_step_reports_staged_handoff() {
// The helper, rather than this process, starts the new executable.
// The daemon must exit to let that staged handoff finish.
fn auto_update_step_staged_outcome_requests_no_restart() {
// The Windows shape: the replacement is staged next to the
// running binary, so the outcome must not ask for the immediate
// self-restart that `Replaced` triggers.
let candidate = test_candidate();
let outcome =
auto_update_step(Some(&candidate), true, 0, |_| Ok(InstallAction::Staged)).unwrap();
-203
View File
@@ -1,203 +0,0 @@
//! Launch the update helper with only its stdio handles inherited.
//!
//! std::process::Command on Windows also inherits other inheritable handles.
//! A daemon's listening socket must not survive in the helper: it would keep
//! the port occupied after the daemon exits and prevent its replacement starting.
use std::ffi::OsStr;
use std::fs::File;
use std::io;
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::{AsRawHandle, FromRawHandle, OwnedHandle};
use std::os::windows::process::ExitStatusExt;
use std::path::Path;
use std::process::ExitStatus;
use windows_sys::Win32::Foundation::{
HANDLE, HANDLE_FLAG_INHERIT, SetHandleInformation, WAIT_OBJECT_0, WAIT_TIMEOUT,
};
use windows_sys::Win32::System::Threading::{
CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW, CREATE_UNICODE_ENVIRONMENT, CreateProcessW,
DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetExitCodeProcess,
InitializeProcThreadAttributeList, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROCESS_INFORMATION,
STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute,
WaitForSingleObject,
};
pub(super) struct Helper(OwnedHandle);
impl Helper {
pub(super) fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
// SAFETY: the owned process handle remains valid for both calls.
match unsafe { WaitForSingleObject(self.0.as_raw_handle(), 0) } {
WAIT_OBJECT_0 => {
let mut code = 0;
if unsafe { GetExitCodeProcess(self.0.as_raw_handle(), &mut code) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(Some(ExitStatus::from_raw(code)))
}
WAIT_TIMEOUT => Ok(None),
_ => Err(io::Error::last_os_error()),
}
}
pub(super) fn kill(&mut self) -> io::Result<()> {
// SAFETY: this handle belongs to the helper we created.
if unsafe { TerminateProcess(self.0.as_raw_handle(), 1) } == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
pub(super) fn wait(&mut self) -> io::Result<()> {
// Only used after kill(); do not leave a live helper on startup failure.
if unsafe { WaitForSingleObject(self.0.as_raw_handle(), 5000) } != WAIT_OBJECT_0 {
return Err(io::Error::new(
io::ErrorKind::TimedOut,
"update helper did not exit",
));
}
Ok(())
}
}
// The opaque attribute list needs pointer-aligned storage and explicit teardown.
struct AttributeList(Vec<usize>);
impl Drop for AttributeList {
fn drop(&mut self) {
// SAFETY: this wrapper is constructed only after successful initialization.
unsafe { DeleteProcThreadAttributeList(self.0.as_mut_ptr().cast()) };
}
}
pub(super) fn spawn(
script: &Path,
source: &Path,
target: &Path,
ready: &Path,
log_path: &Path,
) -> io::Result<Helper> {
let root = std::env::var_os("SystemRoot")
.ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "SystemRoot is not set"))?;
let application = wide(Path::new(&root).join("System32/cmd.exe").as_os_str());
// /S /C strips one outer pair of quotes. Environment expansion keeps paths
// Unicode and avoids CRT escaping, batch-file encoding, and CALL expansion.
let mut command = wide(OsStr::new(
"cmd.exe /D /V:OFF /S /C \"\"%BSK_UPDATE_SCRIPT%\"\"",
));
let overrides = [
("BSK_UPDATE_SCRIPT", script.as_os_str()),
("BSK_UPDATE_SOURCE", source.as_os_str()),
("BSK_UPDATE_TARGET", target.as_os_str()),
("BSK_UPDATE_READY", ready.as_os_str()),
("BSK_UPDATE_LOG", log_path.as_os_str()),
];
let mut env: Vec<_> = std::env::vars_os()
.filter(|(key, _)| {
let key = key.to_string_lossy();
!overrides
.iter()
.any(|(name, _)| key.eq_ignore_ascii_case(name))
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMONIZED_ENV)
&& !key.eq_ignore_ascii_case(crate::daemon::start::DAEMON_REPLACEMENT_WAIT_ENV)
})
.collect();
env.extend(
overrides
.into_iter()
.map(|(key, value)| (key.into(), value.to_owned())),
);
env.sort_by_key(|(key, _)| key.to_string_lossy().to_uppercase());
let mut environment = Vec::new();
for (key, value) in env {
environment.extend(key.encode_wide());
environment.push(b'=' as u16);
environment.extend(value.encode_wide());
environment.push(0);
}
environment.push(0);
let input = File::open("NUL")?;
let log = File::create(log_path)?;
let handles: [HANDLE; 2] = [input.as_raw_handle(), log.as_raw_handle()];
for &handle in &handles {
// SAFETY: these are dedicated helper stdio handles, held until spawn
// finishes. The explicit handle list excludes every other parent handle.
if unsafe { SetHandleInformation(handle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) } == 0 {
return Err(io::Error::last_os_error());
}
}
let mut size = 0;
// SAFETY: the first call queries the allocation size, as required by Win32.
unsafe { InitializeProcThreadAttributeList(std::ptr::null_mut(), 1, 0, &mut size) };
if size == 0 {
return Err(io::Error::last_os_error());
}
let mut storage = vec![0usize; size.div_ceil(std::mem::size_of::<usize>())];
// SAFETY: storage has the requested size and pointer alignment.
if unsafe { InitializeProcThreadAttributeList(storage.as_mut_ptr().cast(), 1, 0, &mut size) }
== 0
{
return Err(io::Error::last_os_error());
}
let mut attributes = AttributeList(storage);
// SAFETY: the handle array and attribute storage outlive CreateProcessW.
if unsafe {
UpdateProcThreadAttribute(
attributes.0.as_mut_ptr().cast(),
0,
PROC_THREAD_ATTRIBUTE_HANDLE_LIST as usize,
handles.as_ptr().cast(),
std::mem::size_of_val(&handles),
std::ptr::null_mut(),
std::ptr::null(),
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: these Win32 structs permit zero initialization; required fields
// are populated below before the process is created.
let mut startup: STARTUPINFOEXW = unsafe { std::mem::zeroed() };
startup.StartupInfo.cb = std::mem::size_of::<STARTUPINFOEXW>() as u32;
startup.StartupInfo.dwFlags = STARTF_USESTDHANDLES;
startup.StartupInfo.hStdInput = handles[0];
startup.StartupInfo.hStdOutput = handles[1];
startup.StartupInfo.hStdError = handles[1];
startup.lpAttributeList = attributes.0.as_mut_ptr().cast();
let mut process: PROCESS_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: strings are NUL-terminated UTF-16, the environment is double-NUL
// terminated, and all buffers/handles remain alive for the duration of spawn.
if unsafe {
CreateProcessW(
application.as_ptr(),
command.as_mut_ptr(),
std::ptr::null(),
std::ptr::null(),
1,
CREATE_NO_WINDOW
| CREATE_NEW_PROCESS_GROUP
| CREATE_UNICODE_ENVIRONMENT
| EXTENDED_STARTUPINFO_PRESENT,
environment.as_ptr().cast(),
std::ptr::null(),
&startup.StartupInfo,
&mut process,
)
} == 0
{
return Err(io::Error::last_os_error());
}
// SAFETY: successful CreateProcessW transfers these two handles to us.
let _thread = unsafe { OwnedHandle::from_raw_handle(process.hThread) };
Ok(Helper(unsafe {
OwnedHandle::from_raw_handle(process.hProcess)
}))
}
fn wide(value: &OsStr) -> Vec<u16> {
value.encode_wide().chain(Some(0)).collect()
}
+4 -24
View File
@@ -10,35 +10,19 @@ use base64::Engine;
use bsk_protocol::Method;
use bsk_protocol::tools::{
TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult,
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadMode,
UploadParams, UploadResult,
TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadParams,
UploadResult,
};
use clap::{Args, ValueEnum};
use clap::Args;
use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format};
use crate::cli::interaction::split_target;
use crate::cli::navigate::parse_timeout_ms;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum UploadModeArg {
#[default]
Input,
Drop,
}
impl From<UploadModeArg> for UploadMode {
fn from(value: UploadModeArg) -> Self {
match value {
UploadModeArg::Input => Self::Input,
UploadModeArg::Drop => Self::Drop,
}
}
}
#[derive(Debug, Clone, Args)]
pub struct UploadArgs {
/// Snapshot ref (`@e3`) or CSS selector for the chooser trigger / drop target.
/// Snapshot ref (`@e3`) or CSS selector for the file input / chooser trigger.
pub target: Option<String>,
#[arg(long = "ref")]
pub ref_: Option<String>,
@@ -47,9 +31,6 @@ pub struct UploadArgs {
/// Local file to upload. Repeat for a multiple-file input.
#[arg(long = "file", required = true)]
pub files: Vec<PathBuf>,
/// Browser mechanism used to deliver the staged files.
#[arg(long, value_enum, default_value_t)]
pub mode: UploadModeArg,
#[arg(long)]
pub session: String,
#[arg(long = "tab-id")]
@@ -92,7 +73,6 @@ pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> {
staged_path: None,
})
.collect(),
mode: args.mode.into(),
timeout_ms: Some(args.timeout),
};
let result = crate::cli::business_rpc::call::<_, UploadResult>(
-134
View File
@@ -1399,28 +1399,6 @@ mod windows {
connected = pipe.connect() => {
match connected {
Ok(()) => {
// Keep the connected instance alive until its replacement
// exists. Otherwise a fast handler can close the last
// instance and make new clients fail with NotFound.
loop {
match ServerOptions::new()
.access_inbound(true)
.access_outbound(true)
.create(&listener.pipe_name)
{
Ok(next) => {
listener.first = Some(next);
break;
}
Err(err) => {
warn!(?err, "create next named-pipe instance failed");
tokio::select! {
_ = &mut shutdown => return,
_ = tokio::time::sleep(std::time::Duration::from_millis(50)) => {}
}
}
}
}
on_open();
let handler = handler.clone();
let on_act = on_activity.clone();
@@ -1489,118 +1467,6 @@ mod windows {
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
use std::time::Duration;
use tokio::net::windows::named_pipe::ClientOptions;
use tokio::sync::oneshot;
fn isolated_listener() -> NamedPipeListener {
let pipe_name = format!(r"\\.\pipe\bsk-test-{}", uuid::Uuid::new_v4());
let first = ServerOptions::new()
.first_pipe_instance(true)
.create(&pipe_name)
.unwrap();
NamedPipeListener {
pipe_name,
first: Some(first),
}
}
#[tokio::test]
async fn next_instance_exists_before_connection_is_handed_off() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let probe_name = name.clone();
let (probe_tx, probe_rx) = oneshot::channel();
let probe_tx = Mutex::new(Some(probe_tx));
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
move || {
if let Some(tx) = probe_tx.lock().unwrap().take() {
// This callback runs before the connection task can finish.
// A second client must already have an instance to open.
let result = ClientOptions::new().open(&probe_name).map(drop);
let _ = tx.send(result);
}
},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let client = ClientOptions::new().open(&name).unwrap();
let result = tokio::time::timeout(Duration::from_secs(5), probe_rx).await;
drop(client);
let _ = stop_tx.send(());
server.await.unwrap();
result
.expect("accept callback ran")
.unwrap()
.expect("next pipe instance is ready");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn rapid_disconnects_and_concurrent_rpc_connections() {
let listener = isolated_listener();
let name = listener.pipe_name.clone();
let (stop_tx, stop_rx) = oneshot::channel();
let server = tokio::spawn(serve(
listener,
crate::daemon::ipc::default_ping_handler(),
|| {},
|| {},
|| {},
async {
let _ = stop_rx.await;
},
));
let exercise = async {
// Include clients that close without sending any request.
for _ in 0..64 {
drop(
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap(),
);
}
let mut clients = tokio::task::JoinSet::new();
for _ in 0..8 {
let name = name.clone();
clients.spawn(async move {
for _ in 0..32 {
let mut client =
crate::ipc_client::Client::connect_path(name.clone().into())
.await
.unwrap();
let reply: bsk_protocol::PingResult = client
.call(
bsk_protocol::Method::SystemPing,
&serde_json::json!({}),
Duration::from_secs(5),
)
.await
.unwrap()
.unwrap();
assert!(reply.pong);
}
});
}
while let Some(result) = clients.join_next().await {
result.unwrap();
}
};
let result = tokio::time::timeout(Duration::from_secs(30), exercise).await;
let _ = stop_tx.send(());
server.await.unwrap();
result.expect("connection exercise completed");
}
}
}
#[cfg(windows)]
+4 -44
View File
@@ -90,21 +90,17 @@ pub fn pid_alive(pid: u32) -> bool {
#[cfg(windows)]
{
use windows_sys::Win32::Foundation::{CloseHandle, WAIT_TIMEOUT};
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Threading::{
OpenProcess, PROCESS_SYNCHRONIZE, WaitForSingleObject,
OpenProcess, PROCESS_QUERY_LIMITED_INFORMATION,
};
unsafe {
let handle = OpenProcess(PROCESS_SYNCHRONIZE, 0, pid);
let handle = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid);
if handle.is_null() {
return false;
}
// An exited process can still be opened while another handle
// keeps its kernel object alive. Poll its termination signal;
// unlike checking STILL_ACTIVE, this also handles exit code 259.
let alive = WaitForSingleObject(handle, 0) == WAIT_TIMEOUT;
CloseHandle(handle);
alive
true
}
}
@@ -114,39 +110,3 @@ pub fn pid_alive(pid: u32) -> bool {
false
}
}
#[cfg(all(test, windows))]
mod tests {
use super::pid_alive;
use std::os::windows::process::CommandExt;
use std::process::Command;
#[test]
fn current_process_is_alive() {
assert!(pid_alive(std::process::id()));
}
#[test]
fn invalid_process_is_not_alive() {
assert!(!pid_alive(0));
assert!(!pid_alive(u32::MAX));
}
#[test]
fn exited_process_with_retained_handle_is_not_alive() {
for exit_code in [0, 259] {
let mut child = Command::new("cmd.exe")
.args(["/D", "/C", &format!("exit {exit_code}")])
.creation_flags(0x0800_0000) // CREATE_NO_WINDOW
.spawn()
.expect("spawn short-lived process");
assert_eq!(child.wait().unwrap().code(), Some(exit_code));
// Keep Child (and its process handle) alive during the probe.
assert!(
!pid_alive(child.id()),
"exited process with code {exit_code}"
);
drop(child);
}
}
}
+9 -14
View File
@@ -232,8 +232,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state));
// Fired by the update check task after a successful auto-update:
// the replacement daemon (or Windows update helper) is ready, so
// this process should shut down and let it take over.
// the replacement daemon has already been spawned, so this
// process should shut down and let it take over.
let restart_notify = Arc::new(tokio::sync::Notify::new());
let update_check_task =
spawn_update_check_task(Arc::clone(&state), Arc::clone(&restart_notify));
@@ -539,8 +539,8 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
/// task spawns the replacement daemon (see
/// [`DAEMON_REPLACEMENT_WAIT_ENV`]) and fires `restart` so this process
/// shuts down and the new version takes over; on Windows the
/// helper waits for this process to exit, replaces the binary, and starts
/// the new daemon with the same configuration.
/// replacement can only be staged, so it just logs that a restart is
/// needed.
pub(crate) fn spawn_update_check_task(
state: Arc<DaemonState>,
restart: Arc<tokio::sync::Notify>,
@@ -605,11 +605,7 @@ pub(crate) fn spawn_update_check_task(
|candidate| {
let target =
exe_path.as_deref().context("current executable unknown")?;
update::self_install_candidate(
candidate,
target,
&restart_start_args(&state.config),
)
update::self_install_candidate(candidate, target)
},
)
})
@@ -639,11 +635,10 @@ pub(crate) fn spawn_update_check_task(
sessions,
"auto-update postponed: agent session(s) active; will retry on the next tick"
),
update::AutoUpdateOutcome::Staged { latest } => {
info!(%latest, "update helper ready; exiting so it can replace and restart the daemon");
restart.notify_one();
return;
}
update::AutoUpdateOutcome::Staged { latest } => warn!(
%latest,
"auto-update staged the new binary but cannot replace the running daemon in place; restart the daemon (or terminal) to finish the upgrade"
),
update::AutoUpdateOutcome::Replaced { latest } => {
info!(
current = env!("CARGO_PKG_VERSION"),
-49
View File
@@ -6,7 +6,6 @@ use bsk::cli::daemon::{DaemonCmd, parse_duration};
use bsk::cli::navigate::NavigateCmd;
use bsk::cli::record::{RecordCmd, RecordSub};
use bsk::cli::session::{SessionCmd, SessionSub};
use bsk::cli::upload::UploadModeArg;
use bsk::{Cli, Command};
use clap::Parser;
@@ -14,54 +13,6 @@ fn parse(args: &[&str]) -> Cli {
Cli::try_parse_from(args).expect("clap parse should succeed")
}
#[test]
fn parses_upload_modes() {
let cli = parse(&[
"bsk",
"upload",
"@e1",
"--file",
"image.png",
"--session",
"s1",
]);
let Command::Upload(default_args) = cli.command else {
panic!("expected upload subcommand");
};
assert_eq!(default_args.mode, UploadModeArg::Input);
let cli = parse(&[
"bsk",
"upload",
"@e1",
"--file",
"image.png",
"--session",
"s1",
"--mode",
"drop",
]);
let Command::Upload(drop_args) = cli.command else {
panic!("expected upload subcommand");
};
assert_eq!(drop_args.mode, UploadModeArg::Drop);
assert!(
Cli::try_parse_from([
"bsk",
"upload",
"@e1",
"--file",
"image.png",
"--session",
"s1",
"--mode",
"auto",
])
.is_err()
);
}
#[test]
fn parses_daemon_start_with_defaults() {
let cli = parse(&["bsk", "daemon", "start"]);
-343
View File
@@ -1,343 +0,0 @@
//! Exercise self-update with a real executable and a local release server.
#![cfg(windows)]
use std::fs;
use std::io::{Cursor, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::os::windows::process::CommandExt;
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::thread;
use std::time::{Duration, Instant};
use bsk::daemon::info::DaemonInfo;
use sha2::{Digest, Sha256};
const MARKER: &[u8] = b"windows-update-regression-fixture";
struct ReleaseServer {
url: String,
requests: Arc<AtomicUsize>,
stop: Arc<AtomicBool>,
worker: Option<thread::JoinHandle<()>>,
}
impl ReleaseServer {
fn new(binary: &[u8]) -> Self {
let mut archive = zip::ZipWriter::new(Cursor::new(Vec::new()));
archive
.start_file(
"bsk.exe",
zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Stored),
)
.unwrap();
archive.write_all(binary).unwrap();
let archive = archive.finish().unwrap().into_inner();
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let manifest = serde_json::to_vec(&serde_json::json!({
"version": "999.0.0",
"assets": {"windows-x64": {
"url": format!("{url}/bsk.zip"),
"sha256": Sha256::digest(&archive).iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
}},
}))
.unwrap();
let stop = Arc::new(AtomicBool::new(false));
let worker_stop = Arc::clone(&stop);
let requests = Arc::new(AtomicUsize::new(0));
let worker_requests = Arc::clone(&requests);
let worker = thread::spawn(move || {
while !worker_stop.load(Ordering::SeqCst) {
let (mut stream, _) = match listener.accept() {
Ok(connection) => connection,
Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(10));
continue;
}
Err(err) => panic!("accept release request: {err}"),
};
// Windows accept() inherits the listener's nonblocking mode.
// Keep accept polling for shutdown, but read/write each request
// in blocking mode with the bounded timeouts below.
stream.set_nonblocking(false).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut chunk = [0; 1024];
while !request.windows(4).any(|window| window == b"\r\n\r\n") {
let len = stream.read(&mut chunk).unwrap();
if len == 0 {
break;
}
request.extend_from_slice(&chunk[..len]);
}
let body = if request.starts_with(b"GET /bsk.zip ") {
worker_requests.fetch_add(1, Ordering::SeqCst);
&archive
} else {
&manifest
};
write!(
stream,
"HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
body.len()
)
.unwrap();
stream.write_all(body).unwrap();
}
});
Self {
url: format!("{url}/version.json"),
requests,
stop,
worker: Some(worker),
}
}
}
impl Drop for ReleaseServer {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
let _ = self.worker.take().unwrap().join();
}
}
#[test]
fn release_server_waits_for_delayed_and_fragmented_request_headers() {
let server = ReleaseServer::new(b"test binary");
let address = server
.url
.strip_prefix("http://")
.unwrap()
.strip_suffix("/version.json")
.unwrap();
let mut stream = TcpStream::connect(address).unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
// Give accept() time to run before any bytes arrive, then split the
// headers across writes. Both reads must wait rather than return WouldBlock.
thread::sleep(Duration::from_millis(100));
stream.write_all(b"GET /version.json HTTP/1.1\r\n").unwrap();
thread::sleep(Duration::from_millis(100));
stream.write_all(b"Host: localhost\r\n\r\n").unwrap();
let mut response = String::new();
stream.read_to_string(&mut response).unwrap();
assert!(response.starts_with("HTTP/1.1 200 OK\r\n"), "{response}");
let (_, body) = response.split_once("\r\n\r\n").unwrap();
let manifest: serde_json::Value = serde_json::from_str(body).unwrap();
assert_eq!(manifest["version"], "999.0.0");
}
struct Fixture {
_tmp: tempfile::TempDir,
exe: PathBuf,
home: PathBuf,
binary: Vec<u8>,
server: ReleaseServer,
daemon: Option<Child>,
}
impl Fixture {
fn new() -> Self {
let tmp = tempfile::TempDir::new().unwrap();
let dir = tmp.path().join("中文 space %PATH% ! & (update)");
fs::create_dir(&dir).unwrap();
let exe = dir.join("bsk.exe");
let home = tmp.path().join("home");
fs::create_dir(&home).unwrap();
fs::copy(env!("CARGO_BIN_EXE_bsk"), &exe).unwrap();
// A PE overlay distinguishes the replacement without requiring a
// second build or changing the executable's behavior/version.
let mut binary = fs::read(&exe).unwrap();
binary.extend_from_slice(MARKER);
let server = ReleaseServer::new(&binary);
Self {
_tmp: tmp,
exe,
home,
binary,
server,
daemon: None,
}
}
fn command(&self) -> Command {
let mut cmd = Command::new(&self.exe);
cmd.env("BSK_HOME", &self.home)
.env("BSK_UPDATE_MANIFEST_URL", &self.server.url)
.env("BSK_AUTO_UPDATE", "off")
.env("RUST_LOG", "info")
.env("NO_PROXY", "127.0.0.1,localhost")
.env_remove("BSK_DAEMONIZED")
.env_remove("BSK_DAEMON_REPLACES_PID")
.stdin(Stdio::null())
.creation_flags(0x0800_0000);
cmd
}
fn wait_for(&self, description: &str, mut check: impl FnMut() -> bool) {
let deadline = Instant::now() + Duration::from_secs(30);
while Instant::now() < deadline {
if check() {
return;
}
thread::sleep(Duration::from_millis(50));
}
let diagnostics: Vec<_> = fs::read_dir(self.exe.parent().unwrap())
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
let home_logs: Vec<_> = fs::read_dir(&self.home)
.unwrap()
.flatten()
.filter(|entry| entry.file_name().to_string_lossy().contains("log"))
.map(|entry| {
(
entry.path(),
String::from_utf8_lossy(&fs::read(entry.path()).unwrap_or_default())
.into_owned(),
)
})
.collect();
panic!(
"timed out waiting for {description}; helper logs: {diagnostics:?}; daemon logs: {home_logs:?}"
);
}
fn info(&self) -> Option<DaemonInfo> {
serde_json::from_slice(&fs::read(self.home.join("daemon.json")).ok()?).ok()
}
fn updated(&self) -> bool {
// A locked executable may briefly reject reads during replacement.
fs::read(&self.exe).is_ok_and(|binary| binary == self.binary)
}
}
impl Drop for Fixture {
fn drop(&mut self) {
// Stop only the daemon belonging to this isolated BSK_HOME. Also reap
// the foreground process on assertion failures to avoid leaking locks.
let _ = self.command().args(["daemon", "stop"]).output();
if let Some(child) = self.daemon.as_mut() {
let _ = child.kill();
let _ = child.wait();
}
}
}
#[test]
fn manual_update_replaces_the_running_executable_after_cli_exit() {
let fixture = Fixture::new();
let out = fixture
.command()
.args(["--json", "update", "--yes"])
.output()
.unwrap();
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let report: serde_json::Value = serde_json::from_slice(&out.stdout).unwrap();
assert_eq!(report["status"], "staged");
fixture.wait_for("CLI replacement and helper cleanup", || {
fixture.updated() && fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
assert!(
fixture.info().is_none(),
"an update must not start a previously absent daemon"
);
assert_eq!(fixture.server.requests.load(Ordering::SeqCst), 1);
}
#[test]
fn automatic_update_exits_old_daemon_and_restarts_on_the_same_port() {
let mut fixture = Fixture::new();
let port = unused_port();
let log = fs::File::create(fixture.home.join("foreground.log")).unwrap();
let child = fixture
.command()
.env("BSK_AUTO_UPDATE", "on")
// Exercise the environment inherited by a normally detached daemon.
.env("BSK_DAEMONIZED", "1")
.args([
"daemon",
"start",
"--port",
&port.to_string(),
"--session-idle",
"1234ms",
"--daemon-idle",
"30s",
])
.stdout(Stdio::from(log.try_clone().unwrap()))
.stderr(Stdio::from(log))
.spawn()
.unwrap();
let old_pid = child.id();
fixture.daemon = Some(child);
fixture.wait_for("replacement daemon", || {
fixture.updated()
&& fixture
.info()
.is_some_and(|info| info.pid != old_pid && info.ws_port == port)
});
assert!(
fixture
.daemon
.as_mut()
.unwrap()
.try_wait()
.unwrap()
.is_some(),
"old daemon must exit"
);
fixture.wait_for("helper cleanup", || {
fs::read_dir(fixture.exe.parent().unwrap()).unwrap().count() == 1
});
let status = fixture
.command()
.args(["--json", "status"])
.output()
.unwrap();
assert!(
status.status.success(),
"{}",
String::from_utf8_lossy(&status.stderr)
);
assert_eq!(
fixture.server.requests.load(Ordering::SeqCst),
1,
"the replacement must not stage another update immediately"
);
}
fn unused_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
.port()
}
@@ -13,9 +13,6 @@
"$ref": "#/definitions/UploadFile"
}
},
"mode": {
"$ref": "#/definitions/UploadMode"
},
"ref": {
"type": [
"string",
@@ -68,13 +65,6 @@
"type": "string"
}
}
},
"UploadMode": {
"type": "string",
"enum": [
"input",
"drop"
]
}
}
}
@@ -15,20 +15,6 @@ pub struct UploadFile {
pub staged_path: Option<String>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum UploadMode {
#[default]
Input,
Drop,
}
impl UploadMode {
fn is_input(&self) -> bool {
matches!(self, Self::Input)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct UploadParams {
pub session_id: String,
@@ -44,8 +30,6 @@ pub struct UploadParams {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
pub files: Vec<UploadFile>,
#[serde(default, skip_serializing_if = "UploadMode::is_input")]
pub mode: UploadMode,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
}
@@ -167,32 +151,11 @@ mod tests {
name: "image.png".into(),
staged_path: None,
}],
mode: UploadMode::Input,
timeout_ms: None,
})
.unwrap();
assert_eq!(value["ref"], "@e3");
assert!(value["files"][0].get("staged_path").is_none());
assert!(value.get("mode").is_none());
}
#[test]
fn upload_drop_mode_is_explicit_on_the_wire() {
let value = serde_json::to_value(UploadParams {
session_id: "s1".into(),
ref_: Some("@e3".into()),
selector: None,
tab_id: None,
files: vec![UploadFile {
transfer_id: "tr_1".into(),
name: "image.png".into(),
staged_path: None,
}],
mode: UploadMode::Drop,
timeout_ms: None,
})
.unwrap();
assert_eq!(value["mode"], "drop");
}
#[test]
+2 -2
View File
@@ -43,7 +43,7 @@ Key modules:
### bsk daemon (same binary: `bsk daemon`)
- Listens on loopback WebSocket (default **52800**) for extensions.
- Listens on loopback WebSocket (default **52800**, configurable in the extension popup) for extensions.
- Validates `Origin: chrome-extension://…` on handshake.
- Maintains `browsers` (connected extensions) and `sessions` (Agent Window bindings).
- **Per-session queue** serializes tool calls targeting one session.
@@ -148,7 +148,7 @@ flowchart LR
- The invoking agent/harness decides whether a transfer is authorized and supplies the task-local source or destination path.
- The CLI is the only component that reads an upload source or writes the final download destination. Before browser dispatch it owns rollback of partially staged uploads; after dispatch, ownership moves to the session because a transport timeout cannot prove that Chrome did not attach the file. Download output becomes visible through one atomic commit, and replacement is opt-in without a pre-delete window. The extension never receives either agent-facing path.
- The daemon is the authority for storage capabilities and limits. It issues opaque session-scoped transfer IDs, stages bounded chunks in a private runtime directory, and injects only private staged upload paths. For download it mints one relative Chrome directory capability. Only after validating the reported path, file type, symlink boundary, and authoritative byte limit does it take ownership of browser-file cleanup and import the bytes.
- The extension owns only the browser transaction. Every transfer resolves one `ResolvedActionTarget`. The default upload mechanism arms Chrome's chooser interception before clicking, then accepts either an exact `Page.fileChooserOpened` input node or an independent probe anchored in the trigger node's document; one verified input is committed with `DOM.setFileInputFiles`, while a non-input picker is rejected immediately. Explicit drop mode performs no click and never falls back to the chooser mechanism: after geometry resolution it temporarily excludes BrowserSkill's own overlay, verifies that the resolved drop zone still owns its local action point, and sends one native `dragEnter` / `dragOver` / `drop` transaction to that node's CDP target before restoring the overlay. OOPIF drops use target-local coordinates rather than top-level click coordinates. Download correlates exact-target CDP intent and `chrome.downloads` filename candidates in either arrival order, claims only one unique match, and never cancels an unclaimed candidate.
- The extension owns only the browser transaction. Both tools resolve one `ResolvedActionTarget` and use that same target for protocol setup and the triggering click. Upload arms Chrome's chooser interception before clicking, then accepts either an exact `Page.fileChooserOpened` input node or an independent probe anchored in the trigger node's own document; one verified input is committed with `DOM.setFileInputFiles`. A non-input picker is rejected immediately as unsupported. Download correlates exact-target CDP intent and `chrome.downloads` filename candidates in either arrival order, claims only one unique match, and never cancels an unclaimed candidate.
- Browser-side operations report `effect_state` (`none`, `committed`, or `unknown`), `phase`, and `cleanup_state`. Confirmed success wins over a late cancel; an unknown effect is preserved across timeout or transport loss and must not be retried blindly. A transfer deadline sends cancellation to the extension and keeps the session queue occupied for bounded compensation rather than abandoning an in-flight browser effect.
- Download staging is released after CLI commit. Upload staging remains until session teardown because the page may read an attached file only on a later form submission. Remaining staging is released on session stop/browser disconnect and on daemon startup after a crash. BrowserSkill does not inspect content or decide whether a transfer is appropriate.
+6 -27
View File
@@ -112,7 +112,7 @@ shell's theme cannot bleed back in.
- **Lifecycle**: hidden while the plugin owns no sessions; appears on the first
`browser_session` start action; disappears when all sessions stop (or the plugin unloads).
- **Focus view**: status row (green/idle/red dot + session + action + mm:ss), the latest page
frame (while viewed, refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
frame (refreshes every ~1.5s while active, ~8s when idle; the last good frame stays on
stage while the next one loads, and is kept on errors so the card does not flash),
and a compact icon toolbar (Interrupt + Pop out, hover for the label).
- **Interrupt**: one click kills the in-flight bsk command of the focus session (same semantics
@@ -129,32 +129,11 @@ shell's theme cannot bleed back in.
- **Pop out (PiP)**: upgrades the card into a native Document PiP window (requires a user
gesture, per browser rules), sized from the current card; closing the PiP falls back to the
in-page card with state intact. Browsers without Document PiP simply hide the button.
- **Screenshot demand**: the client requests periodic screenshots while an observation view is
visible, using the PiP document's visibility when popped out. Hidden or collapsed views can
keep subscribing to state without requesting screenshots. The first screenshot subscriber
starts capture scheduling; the last one's departure cancels timers and skips queued captures.
An already running capture may finish. Demand applies to the service's owned sessions, not just
the selected session.
- **Wire**: `GET /bsk-observation/events` is the live synchronization channel (SSE). Every connection,
including reconnects, starts with `{ type: "snapshot", sessions, available }`, followed by
`upsert`, `remove`, `reset`, and `availability` events on the same stream. Replace local state
with each snapshot before applying subsequent events. Use `?thumbnails=0` for state-only
subscriptions or `?thumbnails=1` to request screenshots; omitting the parameter defaults to
requesting screenshots for compatibility. Closing the stream releases its screenshot demand.
`GET /bsk-observation/state` remains a one-time `{ sessions, available }` read and does not
request screenshots. It has no ordering guarantee relative to a separate SSE connection;
live clients should initialize and resynchronize from the stream's snapshot instead.
The host also serves `POST /bsk-observation/interrupt`, `POST /bsk-observation/stop`, and
`GET /bsk-observation/thumbnail/<attachmentId>` through the dsh `webServer` route seam
(dsh 0.1's Typert Remote pipeline is closed to out-of-tree packages). All commands for one
session — tool calls and frame captures alike — run through a per-session FIFO, because the
daemon accepts only one unfinished command per session.
- **Programmatic subscription**: `ObservationService.subscribe(listener, { thumbnails: false })`
subscribes to state without requesting screenshots. On an active service, the listener receives
the initial `snapshot` synchronously, before `subscribe` returns, then receives subsequent
changes. `thumbnails` defaults to `true`, so callers should choose it explicitly. The returned
unsubscribe function is safe to call repeatedly and releases this subscription's screenshot
demand. Subscribing after service disposal is a no-op.
- **Wire**: the host serves `GET /bsk-observation/state`, `GET /bsk-observation/events` (SSE),
`POST /bsk-observation/interrupt`, and `GET /bsk-observation/thumbnail/<attachmentId>` over
the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree
packages). All commands for one session — tool calls and frame captures alike — run through a
per-session FIFO, because the daemon accepts only one unfinished command per session.
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
@@ -1,7 +1,7 @@
{
"name": "@wxg-prc-cpg/browser-skill-dsh-plugin",
"description": "DeepSeek Harness tool plugin that exposes BrowserSkill browser automation (browser_* tools) to the model",
"version": "0.2.0",
"version": "0.1.2",
"license": "MIT",
"publishConfig": {
"access": "public",
@@ -47,6 +47,7 @@
"client": {
"platform": "web",
"inject": [
"@deepseek-ai/dsh-client-runtime",
"@deepseek-ai/dsh-client-ui-tool",
"@deepseek-ai/dsh-client-ui-layout"
],
@@ -68,6 +69,7 @@
"peerDependencies": {
"@deepseek-ai/cordis": "^4.0.1",
"@deepseek-ai/dsh-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-runtime": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-attachment": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-primitives": "^0.1.0-rc.6",
"@deepseek-ai/dsh-client-ui-tool": "^0.1.0-rc.6",
@@ -80,6 +82,9 @@
"@deepseek-ai/dsh-attachment": {
"optional": true
},
"@deepseek-ai/dsh-client-runtime": {
"optional": true
},
"@deepseek-ai/dsh-client-ui-attachment": {
"optional": true
},
@@ -97,10 +97,6 @@ Continue from returned sequence cursors instead of rereading the same buffer.
- Unknown tab: list tabs instead of guessing.
- Unknown session: list owned sessions or start one; never try foreign ids.
- Timeout: inspect current state before deciding whether one longer purposeful wait is useful.
- Fill result unconfirmed: observe the field first; the page may have formatted the value. Continue
if the visible result satisfies the user's intent. Otherwise correct the remaining difference;
do not blindly repeat fill or immediately request human help. For other fill errors, follow the
returned hint and inspect current state before retrying.
- Unrecoverable failure: report the blocker and stop the owned session.
Arbitrary page-script evaluation and interaction recording are intentionally unsupported. Do not
@@ -142,8 +142,6 @@
right: 6px;
width: 16px;
height: 16px;
padding: 0;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
@@ -48,13 +48,7 @@ import { createPortal } from "react-dom";
import type { SessionObservation } from "../observation";
import css from "./ObservationOverlay.module.css";
import type { ObservationClientStore } from "./observation-store";
import {
focusOf,
statusOf,
useObservationView,
usePip,
useThumbnailObservation,
} from "./observation-view";
import { focusOf, statusOf, useObservationView, usePip } from "./observation-view";
import { getSidebarMode, subscribeSidebarMode } from "./sidebar-mode";
// The pure view helpers live in observation-view (shared with the sidebar
@@ -347,7 +341,6 @@ export function OverlayBody(props: {
onHeaderPointerDown,
} = props;
const [interrupting, setInterrupting] = useState(false);
const viewRef = useThumbnailObservation(store, sessions.length > 0);
const thumbId = focus?.thumbnailAttachmentId;
useEffect(() => {
@@ -380,12 +373,7 @@ export function OverlayBody(props: {
const state = !available ? "error" : focus !== undefined ? statusOf(focus) : "idle";
return (
<div
ref={viewRef}
className={cn(css.body, "bsk-obs")}
data-state={state}
data-in-pip={inPip || undefined}
>
<div className={cn(css.body, "bsk-obs")} data-state={state} data-in-pip={inPip || undefined}>
<div
className={css.header}
data-testid="obs-header"
@@ -433,15 +421,9 @@ export function OverlayBody(props: {
</div>
)}
{thumb?.status === "error" ? (
<button
type="button"
className={css.badge}
aria-label="Retry thumbnail"
title="Frame unavailable — retry"
onClick={() => store.retryThumbnail(thumbId)}
>
<span className={css.badge} aria-label="thumbnail failed">
<IconWarn size={12} />
</button>
</span>
) : null}
</div>
{sessions.length >= 2 ? (
@@ -7,7 +7,6 @@
*/
import type { ImageAttachmentRef } from "@deepseek-ai/dsh-attachment";
// Development-only types for the injected services; newer hosts no longer ship this package.
import type { ClientContext, ISessions, SessionId } from "@deepseek-ai/dsh-client-runtime/client";
// Type-only: pulls the 'shell.overlay' SlotMap merge into scope.
import type {} from "@deepseek-ai/dsh-client-ui-layout/client";
@@ -1,9 +1,10 @@
/**
* Client-side data layer for the observation overlay: an ordered SSE snapshot
* and increment stream (each reconnect starts with a fresh snapshot), on-demand
* thumbnail blob loading through the host's ephemeral-frame route,
* Client-side data layer for the observation overlay: initial state fetch,
* SSE increment stream (with a state refetch on every (re)open — events that
* fired while the stream was down are otherwise lost forever), on-demand
* thumbnail blob loading through the session-authorized readAttachment path,
* and the interrupt call. The last ready blob per session is held until its
* replacement loads so the overlay can swap frames in place. All I/O is
* replacement decodes so the overlay can swap frames in place. All I/O is
* injected so tests never touch a network.
*/
@@ -11,6 +12,8 @@ import type { ObservationEvent, SessionObservation } from "../observation";
export interface EventSourceLike {
onmessage: ((event: { data: string }) => void) | null;
/** Fires on the initial connect AND on every automatic reconnect. */
onopen?: (() => void) | null;
close(): void;
}
@@ -47,10 +50,10 @@ export interface OverlaySnapshot {
readonly available: boolean;
}
const STATE_URL = "/bsk-observation/state";
const EVENTS_URL = "/bsk-observation/events";
const INTERRUPT_URL = "/bsk-observation/interrupt";
const STOP_URL = "/bsk-observation/stop";
const THUMBNAIL_RETRY_DELAYS_MS = [1000, 3000];
function revoke(url: string | undefined): void {
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
@@ -63,10 +66,8 @@ export class ObservationClientStore {
private thumbs = new Map<string, ThumbnailState>();
/** sessionId → the attachment id currently advertised for it. */
private readonly thumbBySession = new Map<string, string>();
/** sessionId → last successfully loaded frame (held across the next load). */
/** sessionId → last successfully decoded frame (held across the next load). */
private readonly lastReady = new Map<string, { attachmentId: string; url: string }>();
private readonly retryTimers = new Map<string, ReturnType<typeof setTimeout>>();
private readonly retryCounts = new Map<string, number>();
private listeners = new Set<() => void>();
private events: EventSourceLike | undefined;
private snapshot: OverlaySnapshot = {
@@ -80,7 +81,6 @@ export class ObservationClientStore {
private started = false;
/** Refcount of mounted consumers (overlay card, sidebar tab, sidebar fiber). */
private consumers = 0;
private thumbnailConsumers = 0;
constructor(private readonly deps: ObservationClientDeps) {}
@@ -133,16 +133,37 @@ export class ObservationClientStore {
return undefined;
}
private connectEvents(): void {
const previous = this.events;
this.events = undefined;
previous?.close();
const events = this.deps.eventSourceFactory(
`${EVENTS_URL}?thumbnails=${this.thumbnailConsumers > 0 ? "1" : "0"}`,
);
this.events = events;
/** (Re)pull the full state: initial load and every SSE (re)open. */
private refreshState(): void {
void this.deps
.fetchFn(STATE_URL)
.then(async (res) => {
if (!res.ok) return;
const body = (await res.json()) as {
sessions?: SessionObservation[];
available?: boolean;
};
const sessions = body.sessions ?? [];
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
// Prune thumbnails of sessions that vanished while we were away.
const alive = new Set(sessions.map((s) => s.sessionId));
for (const sessionId of [...this.thumbBySession.keys()]) {
if (!alive.has(sessionId)) this.dropThumb(sessionId);
}
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
if (typeof body.available === "boolean") this.available = body.available;
this.publish();
})
.catch(() => {});
}
/** Initial fetch + SSE subscription. Idempotent. */
start(): void {
if (this.started) return;
this.started = true;
this.refreshState();
const events = this.deps.eventSourceFactory(EVENTS_URL);
events.onmessage = (message) => {
if (!this.started || this.events !== events) return;
let event: ObservationEvent;
try {
event = JSON.parse(message.data) as ObservationEvent;
@@ -151,24 +172,23 @@ export class ObservationClientStore {
}
this.apply(event);
};
}
/** Every initial connection and reconnect starts with the server's snapshot. */
start(): void {
if (this.started) return;
this.started = true;
this.connectEvents();
events.onopen = () => {
// Reconnects lose every event fired during the outage — resync.
if (this.events === events) this.refreshState();
};
this.events = events;
this.publish();
}
stop(): void {
const previous = this.events;
this.events?.close();
this.events = undefined;
this.started = false;
previous?.close();
this.clearThumbnails();
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
this.sessions.clear();
this.available = true;
this.publish();
}
@@ -189,33 +209,6 @@ export class ObservationClientStore {
if (this.consumers === 0) this.stop();
}
/** Only visible image surfaces request screenshots; metadata watchers do not. */
watchThumbnails(): () => void {
this.thumbnailConsumers += 1;
if (this.thumbnailConsumers === 1 && this.started) this.connectEvents();
let released = false;
return () => {
if (released) return;
released = true;
this.thumbnailConsumers -= 1;
if (this.thumbnailConsumers === 0 && this.started) this.connectEvents();
};
}
private forgetThumbnail(attachmentId: string): void {
clearTimeout(this.retryTimers.get(attachmentId));
this.retryTimers.delete(attachmentId);
this.retryCounts.delete(attachmentId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
private clearThumbnails(): void {
for (const attachmentId of this.thumbs.keys()) this.forgetThumbnail(attachmentId);
this.thumbBySession.clear();
this.lastReady.clear();
}
/** Forget one session's tracked + held frames, revoking their blob URLs. */
private dropThumb(sessionId: string): void {
const attachmentId = this.thumbBySession.get(sessionId);
@@ -223,45 +216,39 @@ export class ObservationClientStore {
this.thumbBySession.delete(sessionId);
this.lastReady.delete(sessionId);
if (attachmentId !== undefined) {
this.forgetThumbnail(attachmentId);
revoke(this.thumbs.get(attachmentId)?.url);
this.thumbs.delete(attachmentId);
}
if (held !== undefined && held.attachmentId !== attachmentId) {
this.forgetThumbnail(held.attachmentId);
revoke(held.url);
this.thumbs.delete(held.attachmentId);
}
}
/**
* Track the frame a session currently advertises. The last *ready* blob is
* kept until the replacement loads — dropping it on the upsert is what
* kept until the replacement decodes — dropping it on the upsert is what
* made the overlay flash a placeholder between breaths.
*/
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
if (attachmentId === undefined) {
this.dropThumb(sessionId);
return;
}
const previous = this.thumbBySession.get(sessionId);
if (previous === attachmentId) return;
this.thumbBySession.set(sessionId, attachmentId);
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
else this.thumbBySession.delete(sessionId);
const heldId = this.lastReady.get(sessionId)?.attachmentId;
if (previous !== undefined && previous !== heldId) {
this.forgetThumbnail(previous);
revoke(this.thumbs.get(previous)?.url);
this.thumbs.delete(previous);
}
}
private apply(event: ObservationEvent): void {
if (event.type === "snapshot") {
this.sessions = new Map(event.sessions.map((session) => [session.sessionId, session]));
for (const sessionId of this.thumbBySession.keys()) {
if (!this.sessions.has(sessionId)) this.dropThumb(sessionId);
}
for (const session of event.sessions) {
this.trackThumb(session.sessionId, session.thumbnailAttachmentId);
}
this.available = event.available;
} else if (event.type === "reset") {
if (event.type === "reset") {
this.sessions.clear();
this.clearThumbnails();
for (const thumb of this.thumbs.values()) revoke(thumb.url);
this.thumbs.clear();
this.thumbBySession.clear();
this.lastReady.clear();
} else if (event.type === "remove" && event.session !== undefined) {
this.sessions.delete(event.session.sessionId);
this.dropThumb(event.session.sessionId);
@@ -272,77 +259,44 @@ export class ObservationClientStore {
this.available = event.available;
}
this.publish();
if (event.type === "snapshot") {
for (const session of event.sessions) this.retryThumbnail(session.thumbnailAttachmentId);
}
}
/**
* Ensure a thumbnail load is in flight for one attachment reference. New
* frames replace the old URL only after they load; failures keep the last
* frames replace the old URL only after they decode; failures keep the last
* good frame (the caller renders `status: 'error'` as a small badge over
* the old image).
*/
ensureThumbnail(attachmentId: string | undefined): void {
if (
!this.started ||
attachmentId === undefined ||
this.sessionOf(attachmentId) === undefined ||
this.thumbs.has(attachmentId)
)
return;
const loading: ThumbnailState = { status: "loading" };
this.thumbs.set(attachmentId, loading);
if (attachmentId === undefined || this.thumbs.has(attachmentId)) return;
this.thumbs.set(attachmentId, { status: "loading" });
this.publish();
this.deps.loadImage(attachmentId).then(
(url) => {
if (this.thumbs.get(attachmentId) !== loading) {
if (!this.thumbs.has(attachmentId)) {
// Replaced or removed while loading: never resurrect (or leak) it.
revoke(url);
return;
}
this.retryCounts.delete(attachmentId);
this.thumbs.set(attachmentId, { status: "ready", url });
const sessionId = this.sessionOf(attachmentId);
if (sessionId !== undefined) {
const previous = this.lastReady.get(sessionId);
if (previous !== undefined && previous.attachmentId !== attachmentId) {
this.forgetThumbnail(previous.attachmentId);
revoke(previous.url);
this.thumbs.delete(previous.attachmentId);
}
this.lastReady.set(sessionId, { attachmentId, url });
}
this.publish();
},
() => {
if (this.thumbs.get(attachmentId) !== loading) return;
const failed: ThumbnailState = { status: "error" };
this.thumbs.set(attachmentId, failed);
const attempts = this.retryCounts.get(attachmentId) ?? 0;
const delay = THUMBNAIL_RETRY_DELAYS_MS[attempts];
if (delay !== undefined) {
this.retryCounts.set(attachmentId, attempts + 1);
this.retryTimers.set(
attachmentId,
setTimeout(() => {
this.retryTimers.delete(attachmentId);
if (this.thumbs.get(attachmentId) !== failed) return;
this.thumbs.delete(attachmentId);
this.ensureThumbnail(attachmentId);
}, delay),
);
}
this.thumbs.set(attachmentId, { status: "error" });
this.publish();
},
);
}
/** Explicit retry (also used on a fresh connection after an outage). */
retryThumbnail(attachmentId: string | undefined): void {
if (attachmentId === undefined || this.thumbs.get(attachmentId)?.status !== "error") return;
this.forgetThumbnail(attachmentId);
this.ensureThumbnail(attachmentId);
}
/** Interrupt the current or named session's in-flight call. */
async interrupt(sessionId?: string): Promise<boolean> {
try {
@@ -6,7 +6,7 @@
* without duplicating hooks.
*/
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import type { SessionObservation } from "../observation";
import type { ObservationClientStore, OverlaySnapshot } from "./observation-store";
@@ -114,49 +114,6 @@ export function useObservationView(
};
}
/** A mounted metadata watcher is not necessarily a visible screenshot viewer. */
export function useThumbnailObservation(store: ObservationClientStore, enabled: boolean) {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const element = ref.current;
if (!enabled || element === null) return;
// A portal uses its PiP document's visibility, independently of the main tab.
const doc = element.ownerDocument;
const Observer = doc.defaultView?.IntersectionObserver;
let active = true;
let intersecting = Observer === undefined;
let release: (() => void) | undefined;
const update = () => {
if (!active) return;
const visible = intersecting && doc.visibilityState !== "hidden";
if (visible && release === undefined) release = store.watchThumbnails();
else if (!visible && release !== undefined) {
release();
release = undefined;
}
};
const observer =
Observer === undefined
? undefined
: new Observer((entries) => {
intersecting = entries.some(
(entry) => entry.target === element && entry.isIntersecting,
);
update();
});
observer?.observe(element);
doc.addEventListener("visibilitychange", update);
update();
return () => {
active = false;
observer?.disconnect();
doc.removeEventListener("visibilitychange", update);
release?.();
};
}, [store, enabled]);
return ref;
}
export interface PipHandle {
/** The open PiP window, or null while the content renders in its carrier. */
readonly pipWindow: Window | null;
@@ -5,19 +5,12 @@
* the event allowlist lives in dsh-api-remotes), so the plugin serves its
* observation channel through the documented `webServer` route seam instead:
*
* GET /bsk-observation/state → one-time { sessions, available } read
* GET /bsk-observation/events → SSE snapshot, then ObservationEvent increments
* (on every connection, including reconnects)
* ?thumbnails=0: state only; =1: request screenshots
* Omitted parameter defaults to screenshots.
* GET /bsk-observation/state → { sessions, available }
* GET /bsk-observation/events → SSE stream of ObservationEvent
* POST /bsk-observation/interrupt → body {sessionId?} → {interrupted: boolean}
* POST /bsk-observation/stop → body {sessionId} → {stopped: boolean}
* GET /bsk-observation/thumbnail/<attachmentId> → image bytes
*
* Live clients initialize/resynchronize from the SSE snapshot; a separate
* /state read has no ordering guarantee relative to the stream and does not
* request screenshots. Closing an SSE connection releases its screenshot demand.
*
* Routes exist only when a webServer service is mounted (web composition);
* headless deployments skip registration entirely.
*/
@@ -106,7 +99,6 @@ export function registerObservationRoutes(
if (webServer === undefined) {
return () => {};
}
const streams = new Set<() => void>();
const disposers = [
webServer.register({
kind: "exact",
@@ -137,42 +129,14 @@ export function registerObservationRoutes(
"cache-control": "no-cache",
connection: "keep-alive",
});
const thumbnails =
new URL(req.url ?? "/", "http://localhost").searchParams.get("thumbnails") !== "0";
let unsubscribe: (() => void) | undefined;
let heartbeat: ReturnType<typeof setInterval> | undefined;
let closed = false;
const close = () => {
if (closed) return;
closed = true;
const unsubscribe = observation.subscribe((event) => {
res.write(`data: ${JSON.stringify(event)}\n\n`);
});
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
res.on("close", () => {
clearInterval(heartbeat);
unsubscribe?.();
streams.delete(close);
res.end();
};
const write = (data: string) => {
if (closed) return;
try {
res.write(data);
} catch {
close();
}
};
streams.add(close);
res.on("close", close);
res.on("error", close);
try {
unsubscribe = observation.subscribe(
(event) => {
write(`data: ${JSON.stringify(event)}\n\n`);
},
{ thumbnails },
);
if (closed) unsubscribe();
else heartbeat = setInterval(() => write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
} catch {
close();
}
unsubscribe();
});
},
}),
webServer.register({
@@ -264,7 +228,6 @@ export function registerObservationRoutes(
}),
];
return () => {
for (const close of streams) close();
for (const dispose of disposers) dispose();
};
}
@@ -49,9 +49,8 @@ export interface SessionObservation {
dshSessionIds?: string[];
}
/** Initial snapshot or incremental change carried to subscribers (SSE on the wire). */
/** Incremental event carried to subscribers (SSE on the wire). */
export type ObservationEvent =
| { type: "snapshot"; sessions: SessionObservation[]; available: boolean }
| { type: "upsert" | "remove" | "reset"; session?: SessionObservation }
| { type: "availability"; available: boolean };
@@ -90,7 +89,6 @@ export class ObservationService {
private readonly scratchNamespace = randomUUID();
private readonly observations = new Map<string, SessionObservation>();
private readonly listeners = new Set<(event: ObservationEvent) => void>();
private thumbnailViewers = 0;
private readonly captureTimers = new Map<string, unknown>();
private readonly captureInFlight = new Set<string>();
/** Captures intentionally cancelled to make way for foreground work. */
@@ -127,7 +125,7 @@ export class ObservationService {
return this.deps.scheduler ?? DEFAULT_SCHEDULER;
}
/** One-time state read; use subscribe for an ordered snapshot and subsequent changes. */
/** All current entries (client initial/resync snapshot). */
getState(): SessionObservation[] {
return [...this.observations.values()].map((entry) => ({ ...entry }));
}
@@ -143,42 +141,10 @@ export class ObservationService {
this.emit({ type: "availability", available });
}
/**
* Subscribe to an ordered initial snapshot and subsequent changes. On an
* active service, listener receives the snapshot synchronously before this
* method returns. Subscribing after disposal is a no-op.
*
* `thumbnails` defaults to true for compatibility: this subscription counts
* as a screenshot viewer for the service's owned sessions. Pass false for
* state-only observation. The first viewer starts capture scheduling.
*
* @returns An idempotent unsubscribe function releasing this subscription's
* screenshot demand. When the last viewer leaves, scheduled/queued captures
* are cancelled/skipped; an already running capture may finish.
*/
subscribe(
listener: (event: ObservationEvent) => void,
{ thumbnails = true }: { thumbnails?: boolean } = {},
): () => void {
if (this.disposed) return () => {};
// Each subscription owns its lease, even if a caller reuses a callback.
const receive = (event: ObservationEvent) => listener(event);
this.listeners.add(receive);
try {
listener({ type: "snapshot", sessions: this.getState(), available: this.available });
} catch (error) {
this.listeners.delete(receive);
throw error;
}
if (thumbnails && ++this.thumbnailViewers === 1) {
for (const sessionId of this.observations.keys()) this.scheduleCapture(sessionId, 0);
}
return () => {
if (!this.listeners.delete(receive)) return;
if (thumbnails && --this.thumbnailViewers === 0) {
for (const sessionId of this.captureTimers.keys()) this.cancelCapture(sessionId);
}
};
/** Subscribe to incremental changes; returns an unsubscribe function. */
subscribe(listener: (event: ObservationEvent) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
private emit(event: ObservationEvent): void {
@@ -389,7 +355,6 @@ export class ObservationService {
this.observations.clear();
this.emit({ type: "reset" });
this.listeners.clear();
this.thumbnailViewers = 0;
}
// ------------------------------------------------------------------
@@ -404,21 +369,12 @@ export class ObservationService {
}
}
private canCapture(sessionId: string): boolean {
const entry = this.observations.get(sessionId);
return (
this.deps.options.enabled &&
!this.disposed &&
this.thumbnailViewers > 0 &&
!this.foregroundDepth.has(sessionId) &&
entry !== undefined &&
entry.dead !== true
);
}
/** Schedule the next capture for a session; `delayMs` 0 means "as soon as the event loop allows". */
private scheduleCapture(sessionId: string, delayMs?: number): void {
if (!this.canCapture(sessionId)) return;
if (!this.deps.options.enabled || this.disposed) return;
if (this.foregroundDepth.has(sessionId)) return;
const entry = this.observations.get(sessionId);
if (entry === undefined || entry.dead === true) return;
this.cancelCapture(sessionId);
const now = this.scheduler.now();
const lastSeen = this.lastActivity.get(sessionId) ?? 0;
@@ -443,7 +399,9 @@ export class ObservationService {
* Failures keep the previous frame and back off silently.
*/
private async capture(sessionId: string): Promise<void> {
if (!this.canCapture(sessionId)) return;
if (this.disposed) return;
const current = this.observations.get(sessionId);
if (current === undefined || current.dead === true) return;
if (this.captureInFlight.has(sessionId)) return;
this.captureInFlight.add(sessionId);
// Stable within this service, isolated from captures owned by other instances.
@@ -452,8 +410,8 @@ export class ObservationService {
let writtenPath = outPath;
try {
const result = await this.deps.queue.run(sessionId, () => {
// The last viewer may have left, or foreground work arrived, while queued.
if (!this.canCapture(sessionId)) return Promise.resolve(undefined);
// A foreground lease may have arrived while this capture was queued.
if (this.foregroundDepth.has(sessionId)) return Promise.resolve(undefined);
return this.deps.runner.run(["screenshot", "--session", sessionId, "--out", outPath], {
timeoutMs: 15_000,
tag: `observation:${sessionId}`,
@@ -487,7 +445,6 @@ export class ObservationService {
this.setAvailable(true);
this.publishFrame(sessionId, entry, data);
} catch {
if (this.disposed || !this.observations.has(sessionId)) return;
// Foreground preemption is healthy scheduling, not browser/daemon
// unavailability; do not let normal tool traffic trip capture backoff.
if (!this.capturePreempted.delete(sessionId)) {
@@ -636,9 +636,7 @@ function defineBrowserOperations(deps: ToolDeps, register: DefinitionRegistrar):
session: SESSION_PARAM,
noClear: {
type: "boolean",
description:
"Append to the end of the existing value instead of clearing it. " +
"The tool moves the caret automatically; no prior click or selection is needed.",
description: "Skip the default wipe-the-field-first pass (append instead).",
},
},
output: {
@@ -19,12 +19,10 @@ interface Harness {
emitRaw: (event: unknown) => void;
fetches: { url: string; init?: { body?: string } }[];
loadImage: ReturnType<typeof vi.fn>;
eventUrls: string[];
}
function makeHarness(initial: SessionObservation[]): Harness {
const fetches: Harness["fetches"] = [];
const eventUrls: string[] = [];
let es: EventSourceLike | undefined;
let current = initial;
const loadImage = vi.fn(async (id: string) => `blob:${id}`);
@@ -36,16 +34,9 @@ function makeHarness(initial: SessionObservation[]): Harness {
}
return { ok: true, json: async () => ({ interrupted: true }) };
},
eventSourceFactory: (url) => {
eventUrls.push(url);
const connection = { onmessage: null, close: vi.fn() } as EventSourceLike;
es = connection;
queueMicrotask(() =>
connection.onmessage?.({
data: JSON.stringify({ type: "snapshot", sessions: current, available: true }),
}),
);
return connection;
eventSourceFactory: () => {
es = { onmessage: null, close: vi.fn() };
return es;
},
loadImage,
});
@@ -67,120 +58,10 @@ function makeHarness(initial: SessionObservation[]): Harness {
},
fetches,
loadImage,
eventUrls,
};
}
afterEach(() => {
cleanup();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("visible thumbnail demand", () => {
function intersections() {
const observers: Array<{
notify: (visible: boolean) => void;
disconnect: ReturnType<typeof vi.fn>;
}> = [];
vi.stubGlobal(
"IntersectionObserver",
class {
private target?: Element;
disconnect = vi.fn();
constructor(private callback: IntersectionObserverCallback) {
observers.push(this);
}
observe(target: Element) {
this.target = target;
}
notify(visible: boolean) {
this.callback(
[{ target: this.target, isIntersecting: visible } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
},
);
return observers;
}
it("pauses screenshots on collapse while keeping the metadata feed", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
const { unmount } = render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
expect(h.es().close).not.toHaveBeenCalled();
expect(observers[0].disconnect).toHaveBeenCalledOnce();
// A callback queued before disconnect must not reclaim screenshot demand.
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=0");
fireEvent.click(screen.getByRole("button", { name: /Expand browser observation/ }));
act(() => observers[1].notify(true));
expect(h.eventUrls.at(-1)).toBe("/bsk-observation/events?thumbnails=1");
unmount();
expect(h.es().close).toHaveBeenCalledOnce();
});
it("ignores an initial visibility notification delivered after collapse", async () => {
const observers = intersections();
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
fireEvent.click(screen.getByRole("button", { name: "Collapse" }));
act(() => observers[0].notify(true));
expect(h.eventUrls).toEqual(["/bsk-observation/events?thumbnails=0"]);
});
it("pauses for a hidden document or hidden panel and resumes when visible", async () => {
const observers = intersections();
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
await screen.findByTestId("obs-card");
act(() => observers[0].notify(true));
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
act(() => {
visibility = "visible";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
act(() => observers[0].notify(false));
expect(h.eventUrls.at(-1)).toContain("thumbnails=0");
});
it("keeps a visible PiP watching when the main document is hidden", async () => {
// The detached PiP test document has no window/IntersectionObserver.
vi.stubGlobal("IntersectionObserver", undefined);
let visibility: DocumentVisibilityState = "visible";
vi.spyOn(document, "visibilityState", "get").mockImplementation(() => visibility);
const pipDoc = document.implementation.createHTMLDocument("pip");
Object.defineProperty(pipDoc, "visibilityState", { value: "visible" });
(window as unknown as Record<string, unknown>).documentPictureInPicture = {
requestWindow: async () => ({ document: pipDoc, addEventListener: () => {}, close: vi.fn() }),
};
const h = makeHarness([BUSY]);
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: /Pop out/ }));
await waitFor(() => expect(pipDoc.body.textContent).toContain("s1"));
act(() => {
visibility = "hidden";
document.dispatchEvent(new Event("visibilitychange"));
});
expect(h.eventUrls.at(-1)).toContain("thumbnails=1");
});
});
afterEach(cleanup);
describe("ObservationOverlay", () => {
beforeEach(() => {
@@ -211,16 +92,6 @@ describe("ObservationOverlay", () => {
await screen.findByRole("img", { name: "session s1 view" });
});
it("offers a retry after a thumbnail fails without requiring a new frame id", async () => {
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-9" }]);
h.loadImage.mockRejectedValueOnce(new Error("temporary failure"));
render(<ObservationOverlay store={h.store} />);
fireEvent.click(await screen.findByRole("button", { name: "Retry thumbnail" }));
await screen.findByRole("img", { name: "session s1 view" });
expect(h.loadImage).toHaveBeenCalledTimes(2);
expect(screen.queryByRole("button", { name: "Retry thumbnail" })).toBeNull();
});
it("keeps the current frame on stage while the next thumbnail loads", async () => {
let resolveNext: ((url: string) => void) | undefined;
const h = makeHarness([{ ...BUSY, thumbnailAttachmentId: "att-1" }]);

Some files were not shown because too many files have changed in this diff Show More