Compare commits

...
36 Commits
Author SHA1 Message Date
“haonan” ff74db48c8 chore(release): bsk 0.1.8 + extension 0.1.4 2026-07-22 15:29:02 +08:00
polarday bc8d6fa190 Merge pull request #28 from shnpd/feat/semantic-record-v3
feat: add semantic browser recording via bsk record
2026-07-22 15:17:56 +08:00
“haonan” 19981526c7 fix: record prompt copy 2026-07-22 15:05:54 +08:00
“haonan” d1de850b42 fix(record): use example.com as the default record start URL 2026-07-22 14:15:53 +08:00
“haonan” 585308df45 feat(record): add popup quick-actions launcher and make record --url optional
Add a popup launcher + action-recording panel that builds a copyable
`bsk record start` prompt, add `--browser` targeting, and default `--url`
to https://example.com/ when omitted. Drop the popup browser-alias field.
2026-07-22 13:27:30 +08:00
“haonan” ac028a7a18 feat: add bsk record for semantic user-action traces
Capture user actions in the Agent Window and export a record-only
trace (pages[] + navigate/click/fill/select/press steps with target
descriptors). Adds the record overlay UI aligned with the control
overlay, and wires record start/stop/await through CLI, daemon,
protocol, and extension.
2026-07-22 11:27:30 +08:00
BB-fat ccf3c32140 Merge pull request #8 from hjxccc/feat/console-network-commands
feat: add bsk network read-only command
2026-07-18 22:53:20 +08:00
BB-fatandCursor 9799b98937 fix: address network command review feedback
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:52:53 +08:00
hjxccc 259b4826b7 feat: add bsk network read-only command
Add `bsk network` as a sibling of the `console` command from #7,
reading the buffered network responses / failures for a tab. Fills the
remaining half of the request in #2 (console landed in #7; network did
not).

Mirrors the console command's conventions exactly:

  bsk network --session <id> [--tab-id N] [--since C]
                             [--limit N] [--max-text-chars N]

The extension enables `Network` on attach (best-effort) and buffers
`responseReceived` / `loadingFailed` events per tab with a monotonic
sequence, correlating them to `requestWillBeSent` for method/URL. Reads
are cursor-paginated (`since` -> `next_since`) and bounded (`limit`,
`max_text_chars`) for agent-context safety, same as console. Entries
carry the real HTTP status code (response) or the CDP failure reason
(failure) — things `performance.getEntriesByType('resource')` can't
give.

Classified read-only (pass-through under the pending-interrupt gate);
defaults to the Agent Window's active tab.

Wires bsk-protocol (Method + params/result/entry structs + schema),
bsk-cli (network subcommand + daemon dispatch allow-list) and the
extension (handler + dispatcher route), with unit / parse / IPC tests
paralleling the console command.

Refs #2.
2026-07-18 22:37:57 +08:00
BB-fat 3d6c95c22a Merge pull request #16 from NianJiuZst/codex/fail-closed-tab-borrow
fix(extension): fail closed on borrow confirmation
2026-07-18 22:34:31 +08:00
BB-fatandCursor 11ec51dfb8 fix(extension): dismiss overlay on borrow timeout
Send borrow-cancel when the confirmation timeout denies, and add a
timer fallback so auto-deny does not depend only on CSS transitionend.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-18 22:31:27 +08:00
BB-fat e16b37aaa0 Merge pull request #22 from NianJiuZst/codex/enforce-session-idle-timeout
fix(daemon): enforce session idle timeout
2026-07-17 16:53:21 +08:00
BB-fat d27ef1f5e0 Merge pull request #23 from NianJiuZst/codex/doctor-fails-nonzero
fix(doctor): exit nonzero on failed checks
2026-07-17 16:50:19 +08:00
BB-fat e55ce8ad99 Merge pull request #29 from klren0312/fixbug/errortips
Correct the incorrect command examples in the error message that does not indicate the mistake.
2026-07-17 16:13:49 +08:00
klkl 7bee906ffb fix(bsk-cli): Correct the incorrect command examples in the error message that does not indicate the mistake. 2026-07-17 15:42:52 +08:00
NianJiuZst bf6c384b2e test: avoid probe race in idle daemon helper 2026-07-10 23:07:43 +08:00
NianJiuZst ec2cde4467 test: bind daemon ports without probe races 2026-07-10 23:06:51 +08:00
NianJiuZst c5e83eac4c test: bind daemon ports without probe races 2026-07-10 23:06:46 +08:00
NianJiuZst 9f82d89788 test: bind daemon ports without probe races 2026-07-10 23:06:02 +08:00
NianJiuZst 2964f6219d ci: retry stalled Rust job 2026-07-10 22:59:01 +08:00
NianJiuZst f8907e5f5a ci: retry stalled Rust job 2026-07-10 22:58:57 +08:00
NianJiuZst d422f655d4 ci: retry stalled Rust job 2026-07-10 22:50:15 +08:00
NianJiuZst d4d4c3beeb ci: retry stalled Rust job 2026-07-10 22:50:01 +08:00
NianJiuZst 3c7a22e7c7 chore(ci): match Biome 2.4 formatting 2026-07-10 22:40:27 +08:00
NianJiuZst dca2b58cbd fix(doctor): exit nonzero on failed checks 2026-07-10 22:39:41 +08:00
NianJiuZst 13279a7f56 fix(daemon): enforce session idle timeout 2026-07-10 22:36:13 +08:00
NianJiuZst ed6d14eab3 fix(extension): fail closed on borrow confirmation 2026-07-10 22:01:46 +08:00
BB-fat 913a3f80a4 Merge pull request #13 from zjxzjw/fix2
config(extension): 配置WebSocket连接URL为构建时变量
2026-07-10 00:30:40 +08:00
BB-fat f60d1e65a7 Merge pull request #12 from NianJiuZst/codex/issue-10-pr-ci
Add PR CI workflow
2026-07-10 00:23:36 +08:00
BB-fat d4a89e89f1 Merge pull request #14 from Tencent/chore/fix-pnpm-lint
Fix pnpm lint formatting
2026-07-10 00:21:36 +08:00
BB-fatandCursor 3c936982a3 Fix Biome and Stylelint formatting so pnpm lint passes.
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-10 00:21:19 +08:00
engineer 37056b3c6e config(extension): 配置WebSocket连接URL为构建时变量
- 移除DEFAULT_DAEMON_PORT常量定义
- 使用__BSK_DAEMON_WS_URL__构建时变量替代硬编码端口
- 在wxt.config.ts和vitest.config.ts中添加环境变量配置
- 为__BSK_DAEMON_WS_URL__添加类型声明和文档注释
- 默认值保持为ws://127.0.0.1:52800,可通过BSK_DAEMON_WS_URL环境变量覆盖
2026-07-09 13:44:48 +08:00
NianJiuZst fa3133a094 Add PR CI workflow 2026-07-09 12:42:42 +08:00
BB-fat d395faa0ab Merge pull request #9 from Tencent/feat/bsk-cli-auto-update
Add bsk CLI auto update support
2026-07-08 14:20:31 +08:00
BB-fatandCursor a7c48275f9 feat: add bsk CLI auto update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 14:14:00 +08:00
BB-fatandCursor 647ae659b7 feat: add placeholder for bsk CLI auto update
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-07 17:20:59 +08:00
128 changed files with 11006 additions and 636 deletions
+84
View File
@@ -0,0 +1,84 @@
name: CI
on:
pull_request:
types:
- opened
- synchronize
- reopened
- ready_for_review
push:
branches:
- main
permissions:
contents: read
concurrency:
group: ci-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
rust:
name: Rust fmt, clippy, tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: dtolnay/rust-toolchain@stable
- name: Check Rust formatting
run: cargo fmt --all -- --check
- name: Run clippy
run: cargo clippy --workspace --all-targets --locked -- -D warnings
- name: Run Rust tests
run: cargo test --workspace --locked
frontend:
name: Frontend lint, typecheck, tests, build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v6
- uses: actions/setup-node@v6
with:
node-version: 22
cache: pnpm
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare extension types
run: pnpm --filter @browser-skill/extension exec wxt prepare
- name: Run lint checks
run: pnpm lint
- name: Compile extension TypeScript
run: pnpm --filter @browser-skill/extension compile
- name: Run extension tests
run: pnpm ext:test
- name: Build extension
run: pnpm ext:build
node-scripts:
name: Node script tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 22
- name: Run script tests
run: node --test scripts/*.test.mjs
+1
View File
@@ -139,6 +139,7 @@ jobs:
--repo "${{ github.repository }}" \
--server-url "${{ github.server_url }}" \
--branch "${{ github.event.repository.default_branch }}" \
--dist dist \
--out dist/version.json
- name: Publish GitHub Release
Generated
+1220 -10
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -7,7 +7,7 @@ members = [
default-members = ["crates/bsk-cli"]
[workspace.package]
version = "0.1.7"
version = "0.1.8"
edition = "2024"
rust-version = "1.85"
license = "MIT"
@@ -31,6 +31,11 @@ console = "0.15"
dialoguer = "0.11"
dirs = "5"
fs2 = "0.4"
reqwest = { version = "0.13.4", default-features = false, features = ["blocking", "rustls"] }
flate2 = "1.1.9"
tar = "0.4.46"
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
sha2 = "0.11.0"
tempfile = "3"
nix = { version = "0.29", features = ["signal", "process"] }
rand = "0.8"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.1.3",
"version": "0.1.4",
"private": true,
"type": "module",
"scripts": {
@@ -139,6 +139,25 @@ describe("ChromiumCdp", () => {
expect(enableCalls).toHaveLength(2);
});
it("retries and surfaces Network.enable failures for explicit capture", async () => {
const { api } = fakeApi();
(api.sendCommand as ReturnType<typeof vi.fn>).mockImplementation(
async (_target, method: string) => {
if (method === "Network.enable") throw new Error("network restricted");
return {};
},
);
const cdp = new ChromiumCdp(api);
await expect(cdp.ensureAttached(13)).resolves.toBeUndefined();
await expect(cdp.ensureNetworkCapture(13)).rejects.toThrow("network restricted");
const enableCalls = (api.sendCommand as ReturnType<typeof vi.fn>).mock.calls.filter(
([, method]) => method === "Network.enable",
);
expect(enableCalls).toHaveLength(2);
});
it("records javascriptDialogOpening and auto-accepts", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
@@ -321,6 +340,30 @@ describe("ChromiumCdp", () => {
truncated: true,
entries: [{ sequence: 2, text: "message 2" }],
});
expect(cdp.consoleEntriesSince(23, undefined, 1, 1000, false)).toMatchObject({
next_since: 3,
truncated: true,
entries: [{ sequence: 3, text: "message 3" }],
});
});
it("reports dropped buffered entries only when they affect the cursor", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(25);
for (let i = 1; i <= 201; i += 1) {
onEvent.fire({ tabId: 25 }, "Runtime.consoleAPICalled", {
type: "log",
args: [{ value: `message ${i}` }],
});
}
expect(cdp.consoleEntriesSince(25, 0, 200, 1000, false).truncated).toBe(true);
expect(cdp.consoleEntriesSince(25, 200, 200, 1000, false)).toMatchObject({
next_since: 201,
truncated: false,
entries: [{ sequence: 201, text: "message 201" }],
});
});
it("clears console state on detach", async () => {
@@ -336,6 +379,103 @@ describe("ChromiumCdp", () => {
expect(cdp.consoleEntriesSince(24, 0, 50, 1000, false).entries).toHaveLength(0);
});
it("isolates network request metadata by tab and consumes it", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(31);
await cdp.ensureAttached(32);
onEvent.fire({ tabId: 31 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://one.test/api", method: "GET" },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.requestWillBeSent", {
requestId: "shared-id",
request: { url: "https://two.test/script.js", method: "POST" },
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.responseReceived", {
requestId: "shared-id",
response: { status: 204 },
type: "Fetch",
});
onEvent.fire({ tabId: 32 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_FAILED",
type: "Script",
});
onEvent.fire({ tabId: 31 }, "Network.loadingFailed", {
requestId: "shared-id",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(31, 0, 50, 1000).entries).toMatchObject([
{
kind: "response",
method: "GET",
url: "https://one.test/api",
status: 204,
},
{
kind: "failure",
url: undefined,
error_text: "net::ERR_ABORTED",
},
]);
expect(cdp.networkEntriesSince(32, 0, 50, 1000).entries).toMatchObject([
{
kind: "failure",
method: "POST",
url: "https://two.test/script.js",
error_text: "net::ERR_FAILED",
},
]);
});
it("bounds request metadata and clears it on detach", async () => {
const { api, onEvent } = fakeApi();
const cdp = new ChromiumCdp(api);
await cdp.ensureAttached(33);
const longUrl = `https://example.test/${"x".repeat(5000)}`;
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long",
request: { url: longUrl, method: "GET" },
});
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "long",
errorText: "net::ERR_FAILED",
});
const bounded = cdp.networkEntriesSince(33, 0, 50, 10_000).entries[0];
expect(bounded.url).toHaveLength(4096);
expect(bounded.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "long-method",
request: { url: "https://example.test/ok", method: "M".repeat(5000) },
});
onEvent.fire({ tabId: 33 }, "Network.responseReceived", {
requestId: "long-method",
response: { url: "https://example.test/ok", status: 200 },
});
const boundedResponse = cdp.networkEntriesSince(33, 1, 50, 10_000).entries[0];
expect(boundedResponse.method).toHaveLength(4096);
expect(boundedResponse.truncated).toBe(true);
onEvent.fire({ tabId: 33 }, "Network.requestWillBeSent", {
requestId: "stale",
request: { url: "https://example.test/stale", method: "GET" },
});
await cdp.detach(33);
await cdp.ensureAttached(33);
onEvent.fire({ tabId: 33 }, "Network.loadingFailed", {
requestId: "stale",
errorText: "net::ERR_ABORTED",
});
expect(cdp.networkEntriesSince(33, 0, 50, 1000).entries[0].url).toBeUndefined();
});
it("detachSession only detaches tabs no other session owns", async () => {
const { api } = fakeApi();
const cdp = new ChromiumCdp(api);
+321 -27
View File
@@ -27,6 +27,9 @@ import type {
ConsoleStackFrame,
JavaScriptDialogInfo,
JavaScriptDialogType,
NetworkEntry,
NetworkEntryKind,
NetworkResult,
} from "@/transport/types";
/**
@@ -87,6 +90,9 @@ const MAX_DIALOG_FIELD_LENGTH = 4096;
const MAX_CONSOLE_BUFFER = 200;
const MAX_CONSOLE_FIELD_LENGTH = 4096;
const MAX_CONSOLE_STACK_FRAMES = 20;
const MAX_NETWORK_BUFFER = 200;
const MAX_NETWORK_FIELD_LENGTH = 4096;
const MAX_NETWORK_REQUEST_META = 1024;
interface ParsedDialogOpening {
type: JavaScriptDialogType;
@@ -98,6 +104,16 @@ interface ParsedDialogOpening {
interface ParsedConsoleEntry extends Omit<ConsoleEntry, "sequence"> {}
interface ParsedNetworkEntry extends Omit<NetworkEntry, "sequence"> {}
/** Bounded request metadata remembered from `requestWillBeSent`. */
interface NetworkRequestMeta {
url: string;
method?: string;
resourceType?: string;
truncated: boolean;
}
/**
* Wrapper around `chrome.debugger` that owns the "attach once per
* tabId" cache and exposes typed `send<T>()`.
@@ -112,15 +128,21 @@ export class ChromiumCdp {
private readonly consoleBuffers = new Map<number, ConsoleEntry[]>();
private readonly consoleSequences = new Map<number, number>();
private readonly consoleDomainsAttemptedTabs = new Set<number>();
private readonly networkBuffers = new Map<number, NetworkEntry[]>();
private readonly networkSequences = new Map<number, number>();
private readonly networkDomainsEnabledTabs = new Set<number>();
private readonly networkRequestMeta = new Map<number, Map<string, NetworkRequestMeta>>();
private detachSubscription: { dispose(): void } | null = null;
private dialogSubscription: { dispose(): void } | null = null;
private consoleSubscription: { dispose(): void } | null = null;
private networkSubscription: { dispose(): void } | null = null;
constructor(api: CdpDebuggerApi = chromeDebuggerApi) {
this.api = api;
this.bindAutoDetach();
this.bindDialogHandler();
this.bindConsoleHandler();
this.bindNetworkHandler();
}
/** Attach to `tabId` if we haven't already in this driver. */
@@ -135,6 +157,7 @@ export class ChromiumCdp {
await this.api.attach({ tabId }, CDP_PROTOCOL_VERSION);
await this.enablePageDomain(tabId);
await this.enableConsoleDomains(tabId);
await this.enableNetworkDomainBestEffort(tabId);
this.attachedTabs.add(tabId);
})()
.catch((err) => {
@@ -195,19 +218,44 @@ export class ChromiumCdp {
includeStack: boolean,
): ConsoleResult {
const buf = this.consoleBuffers.get(tabId) ?? [];
const hasCursor = typeof since === "number";
const candidates = hasCursor
? buf.filter((entry) => entry.sequence > since)
: buf.slice(Math.max(0, buf.length - limit));
const limited = hasCursor ? candidates.slice(0, limit) : candidates;
const entries = limited.map((entry) => projectConsoleEntry(entry, maxTextChars, includeStack));
const lastReturned = entries.at(-1)?.sequence;
const nextSince = lastReturned ?? this.consoleSequences.get(tabId) ?? 0;
const droppedEntries = (this.consoleSequences.get(tabId) ?? 0) > buf.length;
const truncated =
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated);
const { entries, nextSince, truncated } = readBufferedEntries(
buf,
this.consoleSequences.get(tabId) ?? 0,
since,
limit,
(entry) => projectConsoleEntry(entry, maxTextChars, includeStack),
);
return {
tab_id: tabId,
entries,
next_since: nextSince,
truncated,
};
}
/** Ensure CDP domains for network capture are enabled for this tab. */
async ensureNetworkCapture(tabId: number): Promise<void> {
if (!this.attachedTabs.has(tabId)) {
await this.ensureAttached(tabId);
}
await this.enableNetworkDomain(tabId);
}
/** Network entries observed on `tabId`, bounded for agent context safety. */
networkEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
): NetworkResult {
const buf = this.networkBuffers.get(tabId) ?? [];
const { entries, nextSince, truncated } = readBufferedEntries(
buf,
this.networkSequences.get(tabId) ?? 0,
since,
limit,
(entry) => projectNetworkEntry(entry, maxTextChars),
);
return {
tab_id: tabId,
entries,
@@ -223,6 +271,7 @@ export class ChromiumCdp {
this.attachedTabs.delete(tabId);
this.clearDialogState(tabId);
this.clearConsoleState(tabId);
this.clearNetworkState(tabId);
try {
await this.api.detach({ tabId });
} catch (err) {
@@ -265,6 +314,10 @@ export class ChromiumCdp {
this.consoleBuffers.clear();
this.consoleSequences.clear();
this.consoleDomainsAttemptedTabs.clear();
this.networkBuffers.clear();
this.networkSequences.clear();
this.networkDomainsEnabledTabs.clear();
this.networkRequestMeta.clear();
await Promise.all(
tabs.map(async (tabId) => {
try {
@@ -292,6 +345,20 @@ export class ChromiumCdp {
}
}
private async enableNetworkDomain(tabId: number): Promise<void> {
if (this.networkDomainsEnabledTabs.has(tabId)) return;
await this.api.sendCommand({ tabId }, "Network.enable", {});
this.networkDomainsEnabledTabs.add(tabId);
}
private async enableNetworkDomainBestEffort(tabId: number): Promise<void> {
try {
await this.enableNetworkDomain(tabId);
} catch (err) {
console.debug("[bsk cdp] network domain enable failed", { tabId, err });
}
}
private bindDialogHandler(): void {
if (this.dialogSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
@@ -372,12 +439,7 @@ export class ChromiumCdp {
if (!entry) return;
const sequence = (this.consoleSequences.get(tabId) ?? 0) + 1;
this.consoleSequences.set(tabId, sequence);
const buf = this.consoleBuffers.get(tabId) ?? [];
buf.push({ ...entry, sequence });
while (buf.length > MAX_CONSOLE_BUFFER) {
buf.shift();
}
this.consoleBuffers.set(tabId, buf);
appendBoundedEntry(this.consoleBuffers, tabId, { ...entry, sequence }, MAX_CONSOLE_BUFFER);
}
private clearConsoleState(tabId: number): void {
@@ -386,6 +448,93 @@ export class ChromiumCdp {
this.consoleDomainsAttemptedTabs.delete(tabId);
}
private bindNetworkHandler(): void {
if (this.networkSubscription) return;
const listener = (source: chrome.debugger.Debuggee, method: string, params: unknown) => {
const tabId = source.tabId;
if (typeof tabId !== "number") return;
switch (method) {
case "Network.requestWillBeSent":
this.rememberNetworkRequest(tabId, params);
break;
case "Network.responseReceived":
this.appendNetwork(
tabId,
parseNetworkResponse(this.takeNetworkRequest(tabId, params), params),
);
break;
case "Network.loadingFailed":
this.appendNetwork(
tabId,
parseNetworkFailure(this.takeNetworkRequest(tabId, params), params),
);
break;
}
};
this.api.onEvent.addListener(listener);
this.networkSubscription = {
dispose: () => this.api.onEvent.removeListener(listener),
};
}
private appendNetwork(tabId: number, entry: ParsedNetworkEntry | null): void {
if (!entry) return;
const sequence = (this.networkSequences.get(tabId) ?? 0) + 1;
this.networkSequences.set(tabId, sequence);
appendBoundedEntry(this.networkBuffers, tabId, { ...entry, sequence }, MAX_NETWORK_BUFFER);
}
/** Remember `requestWillBeSent` metadata so responses/failures can be attributed. */
private rememberNetworkRequest(tabId: number, params: unknown): void {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
const request = (raw.request ?? {}) as Record<string, unknown>;
if (!id || typeof request.url !== "string") return;
const url = truncateText(request.url, MAX_NETWORK_FIELD_LENGTH);
const method = truncateOptionalText(
typeof request.method === "string" ? request.method : undefined,
MAX_NETWORK_FIELD_LENGTH,
);
const resourceType = truncateOptionalText(
typeof raw.type === "string" ? raw.type : undefined,
MAX_NETWORK_FIELD_LENGTH,
);
const tabMeta = this.networkRequestMeta.get(tabId) ?? new Map<string, NetworkRequestMeta>();
tabMeta.delete(id);
tabMeta.set(id, {
url: url.text,
method: method?.text,
resourceType: resourceType?.text,
truncated:
url.truncated || (method?.truncated ?? false) || (resourceType?.truncated ?? false),
});
while (tabMeta.size > MAX_NETWORK_REQUEST_META) {
const oldest = tabMeta.keys().next().value;
if (oldest === undefined) break;
tabMeta.delete(oldest);
}
this.networkRequestMeta.set(tabId, tabMeta);
}
/** Consume request metadata once its response or failure has been observed. */
private takeNetworkRequest(tabId: number, params: unknown): NetworkRequestMeta | undefined {
const raw = (params ?? {}) as Record<string, unknown>;
const id = typeof raw.requestId === "string" ? raw.requestId : undefined;
if (!id) return undefined;
const tabMeta = this.networkRequestMeta.get(tabId);
const info = tabMeta?.get(id);
tabMeta?.delete(id);
if (tabMeta?.size === 0) this.networkRequestMeta.delete(tabId);
return info;
}
private clearNetworkState(tabId: number): void {
this.networkBuffers.delete(tabId);
this.networkSequences.delete(tabId);
this.networkDomainsEnabledTabs.delete(tabId);
this.networkRequestMeta.delete(tabId);
}
private bindAutoDetach(): void {
if (this.detachSubscription) return;
const listener = (source: chrome.debugger.Debuggee, _reason: string) => {
@@ -395,6 +544,7 @@ export class ChromiumCdp {
this.tabOwners.delete(source.tabId);
this.clearDialogState(source.tabId);
this.clearConsoleState(source.tabId);
this.clearNetworkState(source.tabId);
}
};
this.api.onDetach.addListener(listener);
@@ -411,6 +561,8 @@ export class ChromiumCdp {
this.dialogSubscription = null;
this.consoleSubscription?.dispose();
this.consoleSubscription = null;
this.networkSubscription?.dispose();
this.networkSubscription = null;
}
/** Detach tabs only when no other live session has claimed them. */
@@ -427,6 +579,45 @@ export class ChromiumCdp {
}
}
function appendBoundedEntry<T>(
buffers: Map<number, T[]>,
tabId: number,
entry: T,
maxEntries: number,
): void {
const buffer = buffers.get(tabId) ?? [];
buffer.push(entry);
while (buffer.length > maxEntries) buffer.shift();
buffers.set(tabId, buffer);
}
function readBufferedEntries<
TEntry extends { sequence: number },
TProjected extends { sequence: number; truncated: boolean },
>(
buffer: TEntry[],
currentSequence: number,
since: number | undefined,
limit: number,
project: (entry: TEntry) => TProjected,
): { entries: TProjected[]; nextSince: number; truncated: boolean } {
const hasCursor = typeof since === "number";
const candidates = hasCursor ? buffer.filter((entry) => entry.sequence > since) : buffer;
const limited = hasCursor ? candidates.slice(0, limit) : candidates.slice(-limit);
const entries = limited.map(project);
const oldestSequence = buffer[0]?.sequence ?? currentSequence + 1;
const droppedEntries =
currentSequence > buffer.length && (!hasCursor || (since ?? 0) < oldestSequence - 1);
return {
entries,
nextSince: entries.at(-1)?.sequence ?? currentSequence,
truncated:
droppedEntries ||
candidates.length > limited.length ||
entries.some((entry) => entry.truncated),
};
}
function parseDialogOpeningParams(params: unknown): ParsedDialogOpening {
const raw = (params ?? {}) as Record<string, unknown>;
const type = normalizeDialogType(raw.type);
@@ -518,7 +709,7 @@ function makeConsoleEntry(input: {
timestamp?: number;
stack_trace: ConsoleStackFrame[];
}): ParsedConsoleEntry {
const projected = truncateConsoleText(input.text, MAX_CONSOLE_FIELD_LENGTH);
const projected = truncateText(input.text, MAX_CONSOLE_FIELD_LENGTH);
return {
kind: input.kind,
level: input.level,
@@ -542,7 +733,7 @@ function projectConsoleEntry(
maxTextChars: number,
includeStack: boolean,
): ConsoleEntry {
const projected = truncateConsoleText(entry.text, maxTextChars);
const projected = truncateText(entry.text, maxTextChars);
return {
sequence: entry.sequence,
kind: entry.kind,
@@ -591,17 +782,120 @@ function firstLine(value: string | undefined): string | undefined {
return value.split(/\r?\n/, 1)[0] || undefined;
}
function truncateConsoleText(
value: string,
maxChars: number,
): { text: string; truncated: boolean } {
function truncateText(value: string, maxChars: number): { text: string; truncated: boolean } {
if (value.length <= maxChars) return { text: value, truncated: false };
return { text: value.slice(0, maxChars), truncated: true };
}
function truncateOptionalText(
value: string | undefined,
maxChars: number,
): { text: string; truncated: boolean } | undefined {
return value === undefined ? undefined : truncateText(value, maxChars);
}
function truncateOptionalConsoleField(value: string | undefined): string | undefined {
if (value === undefined) return undefined;
return truncateConsoleText(value, MAX_CONSOLE_FIELD_LENGTH).text;
return truncateOptionalText(value, MAX_CONSOLE_FIELD_LENGTH)?.text;
}
function parseNetworkResponse(
info: NetworkRequestMeta | undefined,
params: unknown,
): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
const response = (raw.response ?? {}) as Record<string, unknown>;
const hasResponseUrl = typeof response.url === "string";
const url = hasResponseUrl ? (response.url as string) : info?.url;
if (!url) return null;
return makeNetworkEntry({
kind: "response",
method: info?.method,
url,
status: typeof response.status === "number" ? response.status : undefined,
status_text: typeof response.statusText === "string" ? response.statusText : undefined,
mime_type: typeof response.mimeType === "string" ? response.mimeType : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
});
}
function parseNetworkFailure(
info: NetworkRequestMeta | undefined,
params: unknown,
): ParsedNetworkEntry | null {
const raw = (params ?? {}) as Record<string, unknown>;
return makeNetworkEntry({
kind: "failure",
method: info?.method,
url: info?.url,
error_text: typeof raw.errorText === "string" ? raw.errorText : undefined,
resource_type: typeof raw.type === "string" ? raw.type : info?.resourceType,
timestamp: typeof raw.timestamp === "number" ? raw.timestamp : undefined,
truncated: info?.truncated === true,
});
}
function makeNetworkEntry(input: {
kind: NetworkEntryKind;
method?: string;
url?: string;
status?: number;
status_text?: string;
mime_type?: string;
resource_type?: string;
error_text?: string;
timestamp?: number;
truncated?: boolean;
}): ParsedNetworkEntry {
const projectedMethod = truncateOptionalText(input.method, MAX_NETWORK_FIELD_LENGTH);
const projectedUrl = truncateOptionalText(input.url, MAX_NETWORK_FIELD_LENGTH);
const projectedStatusText = truncateOptionalText(input.status_text, MAX_NETWORK_FIELD_LENGTH);
const projectedMimeType = truncateOptionalText(input.mime_type, MAX_NETWORK_FIELD_LENGTH);
const projectedResourceType = truncateOptionalText(input.resource_type, MAX_NETWORK_FIELD_LENGTH);
const projectedError =
input.error_text !== undefined
? truncateText(input.error_text, MAX_NETWORK_FIELD_LENGTH)
: undefined;
return {
kind: input.kind,
method: projectedMethod?.text,
url: projectedUrl?.text,
status: input.status,
status_text: projectedStatusText?.text,
mime_type: projectedMimeType?.text,
resource_type: projectedResourceType?.text,
error_text: projectedError?.text,
timestamp: input.timestamp,
truncated:
input.truncated === true ||
(projectedMethod?.truncated ?? false) ||
(projectedUrl?.truncated ?? false) ||
(projectedStatusText?.truncated ?? false) ||
(projectedMimeType?.truncated ?? false) ||
(projectedResourceType?.truncated ?? false) ||
(projectedError?.truncated ?? false),
};
}
function projectNetworkEntry(entry: NetworkEntry, maxTextChars: number): NetworkEntry {
const projectedUrl = truncateOptionalText(entry.url, maxTextChars);
const projectedError =
entry.error_text !== undefined ? truncateText(entry.error_text, maxTextChars) : undefined;
return {
sequence: entry.sequence,
kind: entry.kind,
method: entry.method,
url: projectedUrl?.text,
status: entry.status,
status_text: entry.status_text,
mime_type: entry.mime_type,
resource_type: entry.resource_type,
error_text: projectedError?.text,
timestamp: entry.timestamp,
truncated:
entry.truncated || (projectedUrl?.truncated ?? false) || (projectedError?.truncated ?? false),
};
}
function normalizeError(err: unknown): Error {
@@ -1,6 +1,6 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiCloseLine } from "@remixicon/react";
import { useEffect, useRef, useState, type TransitionEvent } from "react";
import { type TransitionEvent, useEffect, useRef, useState } from "react";
import logoUrl from "../../assets/logo.png";
export interface BorrowRequestData {
@@ -62,33 +62,25 @@ function BorrowToastStack({ children }: { children: React.ReactNode }) {
);
}
/** Matches BorrowProgressBar / CountdownRing transition duration (duration-1000). */
const PROGRESS_TRANSITION_MS = 1000;
function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; isModal: boolean }) {
const { t } = useTranslation("extension");
const totalSeconds = Math.ceil(request.timeoutMs / 1000);
const [secondsLeft, setSecondsLeft] = useState(totalSeconds);
const [exiting, setExiting] = useState(false);
const [awaitingAutoAllow, setAwaitingAutoAllow] = useState(false);
const allowedRef = useRef(false);
const [awaitingAutoDeny, setAwaitingAutoDeny] = useState(false);
const settledRef = useRef(false);
const onAllowRef = useRef(request.onAllow);
const onDenyRef = useRef(request.onDeny);
onAllowRef.current = request.onAllow;
onDenyRef.current = request.onDeny;
useEffect(() => {
if (secondsLeft <= 0) {
if (!allowedRef.current) {
setAwaitingAutoAllow(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
function triggerAllow() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
if (settledRef.current) return;
settledRef.current = true;
setAwaitingAutoDeny(false);
setExiting(true);
setTimeout(() => onAllowRef.current(), EXIT_ANIMATION_MS);
}
@@ -98,13 +90,34 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
}
function handleDeny() {
if (allowedRef.current) return;
allowedRef.current = true;
setAwaitingAutoAllow(false);
if (settledRef.current) return;
settledRef.current = true;
setAwaitingAutoDeny(false);
setExiting(true);
setTimeout(() => onDenyRef.current(), EXIT_ANIMATION_MS);
}
useEffect(() => {
if (secondsLeft <= 0) {
if (!settledRef.current) {
setAwaitingAutoDeny(true);
}
return;
}
const id = setTimeout(() => setSecondsLeft((s) => s - 1), 1000);
return () => clearTimeout(id);
}, [secondsLeft]);
// Prefer transitionend for visual sync, but fall back to a timer so
// background tabs / suppressed CSS transitions still auto-deny.
useEffect(() => {
if (!awaitingAutoDeny || settledRef.current) return;
const id = setTimeout(() => {
handleDeny();
}, PROGRESS_TRANSITION_MS);
return () => clearTimeout(id);
}, [awaitingAutoDeny]);
const progress = secondsLeft / totalSeconds;
const truncatedTitle =
request.tabTitle.length > 30 ? `${request.tabTitle.slice(0, 30)}…` : request.tabTitle;
@@ -128,13 +141,13 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
progress={progress}
seconds={secondsLeft}
onProgressTransitionEnd={(propertyName) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (!awaitingAutoDeny || secondsLeft !== 0) return;
if (propertyName !== "stroke-dashoffset") return;
triggerAllow();
handleDeny();
}}
/>
<span className="text-[13px] text-gray-500">
{t("borrowConfirmation.autoAllow", { count: secondsLeft })}
{t("borrowConfirmation.autoDeny", { count: secondsLeft })}
</span>
</div>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} />
@@ -156,9 +169,9 @@ function BorrowRequestItem({ request, isModal }: { request: BorrowRequestData; i
<BorrowProgressBar
progress={progress}
onProgressTransitionEnd={(event) => {
if (!awaitingAutoAllow || secondsLeft !== 0) return;
if (!awaitingAutoDeny || secondsLeft !== 0) return;
if (event.propertyName !== "width") return;
triggerAllow();
handleDeny();
}}
/>
<ActionButtons onDeny={handleDeny} onAllow={handleAllow} gapClass="gap-2" />
@@ -58,6 +58,7 @@ export function ControlOverlay({
/>
<div
data-slot="control-overlay-blocker"
onPointerDown={(event) => {
if (automationBypass) return;
event.preventDefault();
@@ -80,13 +81,16 @@ export function ControlOverlay({
/>
<div
data-slot="control-overlay-pill"
style={{
position: "fixed",
bottom: 32,
left: "50%",
transform: "translateX(-50%)",
zIndex: 2147483647,
pointerEvents,
// Always receive clicks so Interrupt works even while the page
// blocker is in automation-bypass mode (CDP pass-through).
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 12,
@@ -122,7 +126,7 @@ export function ControlOverlay({
disabled={interrupting}
onClick={onInterrupt}
style={{
pointerEvents,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 6,
@@ -1,12 +1,12 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiArrowDownSLine } from "@remixicon/react";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import logoUrl from "../../assets/logo.png";
@@ -239,25 +239,29 @@ export function HelpRequestOverlay({ request }: Props) {
};
}, [request]);
const onHeaderPointerDown = useCallback((e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select")) return;
const banner = bannerRef.current;
if (!banner) return;
const rect = banner.getBoundingClientRect();
dragStartRef.current = {
pointerX: e.clientX,
pointerY: e.clientY,
originLeft: rect.left,
originTop: rect.top,
panelW: banner.offsetWidth || PANEL_WIDTH,
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
};
userMovedRef.current = true;
setDragPos({ top: rect.top, left: rect.left });
setDragging(true);
e.preventDefault();
}, [collapsed]);
const onHeaderPointerDown = useCallback(
(e: ReactPointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (e.target instanceof Element && e.target.closest("button, input, textarea, a, select"))
return;
const banner = bannerRef.current;
if (!banner) return;
const rect = banner.getBoundingClientRect();
dragStartRef.current = {
pointerX: e.clientX,
pointerY: e.clientY,
originLeft: rect.left,
originTop: rect.top,
panelW: banner.offsetWidth || PANEL_WIDTH,
panelH: banner.offsetHeight || (collapsed ? FALLBACK_PANEL_H_COLLAPSED : FALLBACK_PANEL_H),
};
userMovedRef.current = true;
setDragPos({ top: rect.top, left: rect.left });
setDragging(true);
e.preventDefault();
},
[collapsed],
);
useEffect(() => {
if (!dragging) return;
@@ -636,7 +640,11 @@ export function HelpRequestOverlay({ request }: Props) {
</div>
<div className="bsk-help-header">
<img src={logoUrl} alt="browser-skill" style={{ width: 22, height: 22, borderRadius: 4 }} />
<img
src={logoUrl}
alt="browser-skill"
style={{ width: 22, height: 22, borderRadius: 4 }}
/>
<span className="bsk-help-title">{request.title ?? t("helpRequest.title")}</span>
<div className="bsk-help-header-actions" aria-hidden={!collapsed}>
<button
@@ -0,0 +1,114 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { RiStopCircleLine } from "@remixicon/react";
import { useEffect, useState } from "react";
export interface RecordRequestData {
id: string;
onFinish: () => void;
}
type Props = {
request: RecordRequestData | null;
};
export function RecordOverlay({ request }: Props) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
useEffect(() => {
if (request) {
const raf = requestAnimationFrame(() => setShow(true));
return () => cancelAnimationFrame(raf);
}
setShow(false);
}, [request]);
if (!request) return null;
return (
<>
<style>{`
@keyframes bsk-rec-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.35; transform: scale(0.82); }
}
`}</style>
<div
data-slot="record-overlay-pill"
style={{
position: "fixed",
bottom: 32,
left: "50%",
zIndex: 2147483647,
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 10,
backgroundColor: "#fff",
borderRadius: 9999,
padding: "10px 10px 10px 20px",
boxShadow: "0 8px 32px rgba(15,23,42,0.16), 0 2px 8px rgba(0,0,0,0.1)",
opacity: show ? 1 : 0,
transform: show ? "translateX(-50%) translateY(0)" : "translateX(-50%) translateY(8px)",
transition: "opacity 300ms ease-out, transform 300ms ease-out",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif',
maxWidth: "min(420px, calc(100vw - 32px))",
}}
>
<span
data-slot="record-overlay-indicator"
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: "#ef4444",
flexShrink: 0,
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
}}
/>
<span
style={{
flex: 1,
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
overflow: "hidden",
textOverflow: "ellipsis",
}}
>
{t("recordOverlay.recording")}
</span>
<button
type="button"
data-slot="record-overlay-finish"
onClick={request.onFinish}
style={{
pointerEvents: "auto",
display: "flex",
alignItems: "center",
gap: 6,
border: "none",
borderRadius: 9999,
padding: "8px 20px 8px 16px",
fontSize: 15,
fontWeight: 600,
color: "#fff",
backgroundColor: "#f97316",
cursor: "pointer",
transition: "background-color 150ms ease-out, opacity 150ms ease-out",
whiteSpace: "nowrap",
lineHeight: 1,
flexShrink: 0,
}}
>
<RiStopCircleLine size={18} color="#fff" />
{t("recordOverlay.finish")}
</button>
</div>
</>
);
}
@@ -12,7 +12,7 @@ describe("BorrowConfirmationOverlay", () => {
vi.useRealTimers();
});
it("auto-allows after countdown reaches zero and progress transition ends", async () => {
it("auto-denies after countdown reaches zero and progress transition ends", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
@@ -37,10 +37,7 @@ describe("BorrowConfirmationOverlay", () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动允许")).toBeTruthy();
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
const progressCircle = container.querySelectorAll("svg circle")[1];
expect(progressCircle).toBeTruthy();
await act(() => {
@@ -54,8 +51,44 @@ describe("BorrowConfirmationOverlay", () => {
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onAllow).toHaveBeenCalledTimes(1);
expect(onDeny).not.toHaveBeenCalled();
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
it("auto-denies via timer fallback when progress transitionend never fires", async () => {
const onAllow = vi.fn();
const onDeny = vi.fn();
render(
<BorrowConfirmationOverlay
requests={[
{
id: "req-fallback",
isActiveTab: true,
tabTitle: "Example",
timeoutMs: 5000,
onAllow,
onDeny,
},
]}
/>,
);
for (let i = 0; i < 5; i++) {
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
}
expect(screen.getByText("0 秒后自动拒绝")).toBeTruthy();
// No transitionend — the PROGRESS_TRANSITION_MS fallback must still deny.
await act(async () => {
await vi.advanceTimersByTimeAsync(1000);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(150);
});
expect(onDeny).toHaveBeenCalledTimes(1);
expect(onAllow).not.toHaveBeenCalled();
});
it("only invokes onDeny once when deny is clicked repeatedly", async () => {
@@ -7,7 +7,7 @@ describe("ControlOverlay", () => {
cleanup();
});
it("sets pointer-events none on blocker and pill when automationBypass is true", () => {
it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => {
const { container } = render(
<ControlOverlay
visible={true}
@@ -17,13 +17,17 @@ describe("ControlOverlay", () => {
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("none");
const pill = container.querySelector("[data-slot='control-overlay-pill']");
expect(pill).toBeTruthy();
expect((pill as HTMLElement).style.pointerEvents).toBe("auto");
const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']");
expect(stopBtn).toBeTruthy();
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("none");
expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto");
});
it("uses pointer-events auto on blocker when automationBypass is false", () => {
@@ -36,7 +40,7 @@ describe("ControlOverlay", () => {
/>,
);
const blocker = container.querySelector("[data-slot='control-overlay']")?.nextElementSibling;
const blocker = container.querySelector("[data-slot='control-overlay-blocker']");
expect(blocker).toBeTruthy();
expect((blocker as HTMLElement).style.pointerEvents).toBe("auto");
});
@@ -3,7 +3,7 @@ import { I18nextProvider } from "@browser-skill/i18n/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { createElement } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { HelpRequestOverlay, type HelpRequestData } from "../HelpRequestOverlay";
import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay";
function renderOverlay(req: HelpRequestData) {
return render(
@@ -79,7 +79,7 @@ describe("HelpRequestOverlay", () => {
renderOverlay(baseRequest({ selectors: ["#login", "#missing"] }));
// One highlight box rendered for the matched selector.
expect(document.querySelectorAll("[data-slot='help-highlight']").length).toBe(1);
expect((el.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect(el.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
el.remove();
});
@@ -115,8 +115,8 @@ describe("HelpRequestOverlay", () => {
),
);
expect((elA.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect((elB.scrollIntoView as ReturnType<typeof vi.fn>)).toHaveBeenCalled();
expect(elA.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
expect(elB.scrollIntoView as ReturnType<typeof vi.fn>).toHaveBeenCalled();
elA.remove();
elB.remove();
});
@@ -286,9 +286,7 @@ describe("HelpRequestOverlay", () => {
it("does not start drag when pointer down on a button in the header", () => {
const { container } = renderOverlay(baseRequest());
const banner = container.querySelector("[data-slot='help-request-banner']") as HTMLElement;
const collapseBtn = screen.getByLabelText(
i18n.t("helpRequest.collapse", { ns: "extension" }),
);
const collapseBtn = screen.getByLabelText(i18n.t("helpRequest.collapse", { ns: "extension" }));
fireEvent.pointerDown(collapseBtn, { button: 0, clientX: 10, clientY: 10, pointerId: 1 });
@@ -0,0 +1,37 @@
import { cleanup, fireEvent, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { RecordOverlay } from "../RecordOverlay";
describe("RecordOverlay", () => {
afterEach(() => {
cleanup();
});
it("renders the bottom pill with orange finish CTA and no logo", () => {
const onFinish = vi.fn();
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish }} />);
const pill = container.querySelector("[data-slot='record-overlay-pill']");
expect(pill).toBeTruthy();
expect(pill?.querySelector("img")).toBeNull();
expect((pill as HTMLElement).style.borderRadius).toBe("9999px");
expect((pill as HTMLElement).style.backgroundColor).toBe("#fff");
const finish = container.querySelector("[data-slot='record-overlay-finish']");
expect(finish).toBeTruthy();
expect((finish as HTMLElement).style.backgroundColor).toBe("#f97316");
expect((finish as HTMLElement).style.borderRadius).toBe("9999px");
fireEvent.click(finish!);
expect(onFinish).toHaveBeenCalledTimes(1);
});
it("shows a pulsing recording indicator and no full-screen glow layer", () => {
const { container } = render(<RecordOverlay request={{ id: "rec-1", onFinish: vi.fn() }} />);
expect(container.querySelector("[data-slot='record-overlay']")).toBeNull();
const indicator = container.querySelector("[data-slot='record-overlay-indicator']");
expect(indicator).toBeTruthy();
expect((indicator as HTMLElement).style.animation).toContain("bsk-rec-pulse");
});
});
@@ -1,5 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { OverlayController } from "../overlay-controller";
import { OverlayController, shouldShowAgentControlOverlay } from "../overlay-controller";
describe("OverlayController", () => {
it("resets agent overlays without clearing user-tab borrow requests", () => {
@@ -31,6 +31,7 @@ describe("OverlayController", () => {
expect(state.controlVisible).toBe(false);
expect(state.activeSessionId).toBeNull();
expect(state.activeHelp).toBeNull();
expect(state.activeRecord).toBeNull();
expect(state.automationBypassCount).toBe(0);
});
@@ -69,4 +70,50 @@ describe("OverlayController", () => {
expect(previous?.id).toBe("help-1");
expect(controller.snapshot().activeHelp?.id).toBe("help-2");
});
it("tracks active record request state", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
expect(controller.snapshot().activeRecord?.id).toBe("rec-1");
});
it("hides agent control overlay while recording is active", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(true);
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
expect(controller.snapshot().controlVisible).toBe(true);
});
it("keeps control hidden after record ends until session overlays reset", () => {
const controller = new OverlayController();
controller.activateAgentSession("sess-1");
controller.setAgentRecordRequest({
id: "rec-1",
onFinish: vi.fn(),
});
controller.setAutomationBypass(true);
controller.setAutomationBypass(true);
controller.clearAgentRecordRequest("rec-1");
expect(controller.snapshot().activeRecord).toBeNull();
expect(controller.snapshot().suppressControlAfterRecord).toBe(true);
expect(controller.snapshot().automationBypassCount).toBe(0);
expect(shouldShowAgentControlOverlay(controller.snapshot())).toBe(false);
controller.resetAgentOverlays("sess-1");
expect(controller.snapshot().suppressControlAfterRecord).toBe(false);
expect(controller.snapshot().controlVisible).toBe(false);
});
});
@@ -0,0 +1,159 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { RECORD_STOP, type RecordStepPayload } from "@/lib/record-bridge";
import { handleRecordContentMessage, startRecordCapture } from "../record-capture";
vi.stubGlobal("chrome", {
runtime: {
sendMessage: vi.fn(() => Promise.resolve()),
},
});
describe("handleRecordContentMessage stop/cancel", () => {
it("ignores STOP when no recording is active", () => {
const dispose = vi.fn();
const onStop = vi.fn();
const setActiveRequestId = vi.fn();
const setCapture = vi.fn();
const sendResponse = vi.fn();
const needsAsync = handleRecordContentMessage(
{ type: RECORD_STOP, requestId: "rec-stale" },
{
activeRequestId: null,
capture: { dispose },
setActiveRequestId,
setCapture,
onStart: vi.fn(),
onStop,
},
sendResponse,
);
expect(needsAsync).toBe(false);
expect(dispose).not.toHaveBeenCalled();
expect(onStop).not.toHaveBeenCalled();
expect(setActiveRequestId).not.toHaveBeenCalled();
expect(setCapture).not.toHaveBeenCalled();
expect(sendResponse).not.toHaveBeenCalled();
});
it("ignores STOP for a mismatched requestId", () => {
const dispose = vi.fn();
const onStop = vi.fn();
const needsAsync = handleRecordContentMessage(
{ type: RECORD_STOP, requestId: "rec-other" },
{
activeRequestId: "rec-1",
capture: { dispose },
setActiveRequestId: vi.fn(),
setCapture: vi.fn(),
onStart: vi.fn(),
onStop,
},
vi.fn(),
);
expect(needsAsync).toBe(false);
expect(dispose).not.toHaveBeenCalled();
expect(onStop).not.toHaveBeenCalled();
});
});
describe("record-capture semantic", () => {
let steps: RecordStepPayload[];
beforeEach(() => {
steps = [];
document.body.innerHTML = `
<label for="q">查询</label>
<input id="q" name="q" />
<button type="button" aria-label="搜索">搜索</button>
<div role="listbox" id="sug">
<div role="option">建议项</div>
</div>
`;
});
it("commits final fill value and records semantic click", () => {
const capture = startRecordCapture("rec-1", (step) => steps.push(step));
const input = document.querySelector("input")!;
const button = document.querySelector("button")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "hello";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
button.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
capture.dispose();
expect(steps).toEqual(
expect.arrayContaining([
expect.objectContaining({
op: "fill",
value: "hello",
target: expect.objectContaining({ name: "查询", tag: "input" }),
}),
expect.objectContaining({
op: "click",
target: expect.objectContaining({ name: "搜索", role: "button" }),
}),
]),
);
expect(JSON.stringify(steps)).not.toMatch(/@e\d+/);
expect(steps.some((s) => "selector" in s)).toBe(false);
});
it("ignores autocomplete suggestion clicks while a fill session is open", () => {
const capture = startRecordCapture("rec-2", (step) => steps.push(step));
const input = document.querySelector("input")!;
const option = document.querySelector('[role="option"]')!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "hel";
input.dispatchEvent(new Event("input", { bubbles: true }));
option.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
input.value = "hello";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
capture.dispose();
expect(steps.filter((s) => s.op === "click")).toHaveLength(0);
expect(steps.some((s) => s.op === "fill" && s.value === "hello")).toBe(true);
});
it("records Enter press but not bare typing keys", () => {
const capture = startRecordCapture("rec-press", (step) => steps.push(step));
const input = document.querySelector("input")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
capture.dispose();
expect(steps.filter((s) => s.op === "press")).toEqual([
expect.objectContaining({ op: "press", key: "Enter" }),
]);
});
it("does not record clicks on anonymous layout divs", () => {
document.body.innerHTML = `
<div id="chrome">page chrome</div>
<button type="button" aria-label="下一步">下一步</button>
`;
const capture = startRecordCapture("rec-3", (step) => steps.push(step));
document
.querySelector("#chrome")!
.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
document
.querySelector("button")!
.dispatchEvent(new MouseEvent("click", { bubbles: true, button: 0, detail: 1 }));
capture.dispose();
expect(steps).toHaveLength(1);
expect(steps[0]).toMatchObject({
op: "click",
target: { name: "下一步", role: "button" },
});
});
});
@@ -1,13 +1,21 @@
import type { BorrowRequestData } from "./BorrowConfirmationOverlay";
import type { HelpRequestData } from "./HelpRequestOverlay";
import type { RecordRequestData } from "./RecordOverlay";
export interface OverlayState {
borrowRequests: BorrowRequestData[];
activeHelp: HelpRequestData | null;
activeRecord: RecordRequestData | null;
controlVisible: boolean;
interrupting: boolean;
activeSessionId: string | null;
automationBypassCount: number;
/**
* After record Finish/Stop clears `activeRecord`, the session is still
* alive until CLI `session.stop`. Suppress the control mask for that gap
* so 「Agent 正在控制」does not flash between RecordOverlay and teardown.
*/
suppressControlAfterRecord: boolean;
}
type MutableOverlayState = Omit<OverlayState, "controlVisible">;
@@ -20,9 +28,11 @@ export class OverlayController {
private state: MutableOverlayState = {
borrowRequests: [],
activeHelp: null,
activeRecord: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
suppressControlAfterRecord: false,
};
snapshot(): OverlayState {
@@ -70,6 +80,25 @@ export class OverlayController {
this.state.activeHelp = null;
}
setAgentRecordRequest(request: RecordRequestData): RecordRequestData | null {
const previous = this.state.activeRecord;
this.state.activeRecord = request;
this.state.suppressControlAfterRecord = false;
return previous;
}
clearAgentRecordRequest(requestId?: string): void {
if (!this.state.activeRecord) return;
if (requestId && this.state.activeRecord.id !== requestId) return;
this.state.activeRecord = null;
// Hide control until session teardown — see OverlayState.suppressControlAfterRecord.
this.state.suppressControlAfterRecord = true;
// Record start/rearm may have stacked automation-bypass refs; drop them so a
// stray ControlOverlay cannot sit with pointer-events:none (page usable,
// Interrupt unclickable).
this.state.automationBypassCount = 0;
}
setAutomationBypass(enabled: boolean): void {
if (enabled) {
this.state.automationBypassCount += 1;
@@ -86,10 +115,22 @@ export class OverlayController {
this.state = {
...this.state,
activeHelp: null,
activeRecord: null,
interrupting: false,
activeSessionId: null,
automationBypassCount: 0,
suppressControlAfterRecord: false,
};
return previousHelp;
}
}
/** Control mask ("Agent 正在控制") must hide while help/record overlays own the chrome. */
export function shouldShowAgentControlOverlay(state: OverlayState): boolean {
return (
state.controlVisible &&
!state.suppressControlAfterRecord &&
state.activeHelp === null &&
state.activeRecord === null
);
}
@@ -0,0 +1,537 @@
import { describeEventTarget, describeTarget, type TargetDescriptor } from "@/lib/describe-target";
import {
isRecordCancelMessage,
isRecordStartMessage,
isRecordStopMessage,
RECORD_CANCEL,
RECORD_START,
RECORD_STEP,
RECORD_STOP,
type RecordCancelMessage,
type RecordStartAck,
type RecordStartMessage,
type RecordStepPayload,
type RecordStopAck,
type RecordStopMessage,
} from "@/lib/record-bridge";
import { shouldRecordPress } from "@/lib/trace-reducer";
const pendingStepSends = new Map<string, Set<Promise<boolean>>>();
const failedStepDeliveries = new Set<string>();
const knownRecordRequests = new Set<string>();
function sendRecordStep(requestId: string, step: RecordStepPayload): void {
const payload = { type: RECORD_STEP, requestId, step };
const pending = Promise.resolve(chrome.runtime.sendMessage(payload)).then(
() => true,
() => {
failedStepDeliveries.add(requestId);
return false;
},
);
const sends = pendingStepSends.get(requestId) ?? new Set<Promise<boolean>>();
sends.add(pending);
pendingStepSends.set(requestId, sends);
void pending.then(() => {
sends.delete(pending);
if (sends.size === 0) pendingStepSends.delete(requestId);
});
}
export interface RecordCaptureController {
dispose(): void;
}
interface FillSession {
element: FillableElement;
target: TargetDescriptor;
baselineValue: string;
lastValue: string;
}
type FillableElement = HTMLInputElement | HTMLTextAreaElement | HTMLElement;
function eventTarget(event: Event): EventTarget | null {
return event.composedPath()[0] ?? event.target;
}
function isOverlayTarget(target: EventTarget | null): boolean {
if (!(target instanceof Node)) return false;
const root = document.documentElement;
let node: Node | null = target;
while (node && node !== root) {
if (node instanceof Element && node.hasAttribute("data-bsk-overlay")) {
return true;
}
const rootNode: Node | Document | ShadowRoot = node.getRootNode();
if (rootNode instanceof ShadowRoot) {
const host: Element = rootNode.host;
if (host.hasAttribute("data-bsk-overlay")) {
return true;
}
node = host;
} else {
node = node.parentNode;
}
}
return false;
}
function isTextFillable(el: Element): el is FillableElement {
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
return true;
}
if (el instanceof HTMLTextAreaElement) return true;
if (!(el instanceof HTMLInputElement)) return false;
const type = el.type.toLowerCase();
return type !== "checkbox" && type !== "radio" && type !== "file" && type !== "button";
}
function fillableFromTarget(target: EventTarget | null): FillableElement | null {
if (!(target instanceof Element)) return null;
if (isTextFillable(target)) return target;
const el = target.closest('input,textarea,[contenteditable]:not([contenteditable="false"])');
if (el && isTextFillable(el)) return el;
return null;
}
/** Clicks on search chrome that only focus the nearby input should not become steps. */
function nearbyFillableFromSearchChrome(target: Element): FillableElement | null {
const container = target.closest(
'[id*="chat-input"], [id*="search"], [class*="search"], form, [role="search"]',
);
if (!container) return null;
const fillable = container.querySelector(
'textarea, input[type="search"], input[name="q"], #chat-textarea',
);
if (
fillable instanceof HTMLElement &&
isTextFillable(fillable) &&
fillable !== target &&
!fillable.contains(target)
) {
return fillable;
}
return null;
}
function fillableValue(el: FillableElement): string {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
return el.value;
}
return el.textContent ?? "";
}
/** Clicks that only pick an autocomplete/suggestion value — not a semantic submit action. */
function isInputCompletionClick(target: EventTarget | null, session: FillSession | null): boolean {
if (!session || !(target instanceof Element)) return false;
if (session.element.contains(target)) return false;
const option = target.closest('[role="option"]');
if (option?.closest('[role="listbox"]')) return true;
const suggestionRoot = target.closest(
[
'[id*="Sug"]',
'[id*="sug"]',
'[class*="suggest"]',
'[class*="autocomplete"]',
'[class*="typeahead"]',
"[data-autocomplete]",
].join(", "),
);
if (suggestionRoot && !suggestionRoot.contains(session.element)) return true;
return false;
}
function scheduleInputCompletionCommit(
sessionElement: FillableElement,
syncFillSessionValue: (el: FillableElement) => void,
commitFillSession: () => void,
hasSessionFor: (el: FillableElement) => boolean,
): void {
const syncAndCommit = () => {
if (!hasSessionFor(sessionElement)) return;
syncFillSessionValue(sessionElement);
commitFillSession();
};
syncFillSessionValue(sessionElement);
queueMicrotask(syncAndCommit);
setTimeout(syncAndCommit, 0);
}
export function startRecordCapture(
_requestId: string,
sendStep: (step: RecordStepPayload) => void,
): RecordCaptureController {
const emitStep = (step: RecordStepPayload) => {
sendStep({ page_url: location.href, ...step });
};
let fillSession: FillSession | null = null;
let composing = false;
let lastUrl = location.href;
let keyboardActivation: { target: EventTarget | null; recordedAt: number } | undefined;
let generatedControlClick: Element | null = null;
let navigationActionPending = false;
let navigationActionVersion = 0;
const committedValues = new WeakMap<FillableElement, string>();
const markNavigationAction = () => {
navigationActionPending = true;
const version = ++navigationActionVersion;
setTimeout(() => {
if (navigationActionVersion === version) {
navigationActionPending = false;
}
}, 0);
};
const emitFill = (session: FillSession) => {
if (session.lastValue === session.baselineValue) return;
const isPassword =
session.element instanceof HTMLInputElement && session.element.type === "password";
const value = isPassword ? "***" : session.lastValue;
emitStep({
op: "fill",
target: session.target,
value,
...(isPassword ? { redacted: true } : {}),
});
};
const commitFillSession = () => {
if (!fillSession || composing) return;
const session = fillSession;
fillSession = null;
emitFill(session);
committedValues.set(session.element, session.lastValue);
};
const syncFillSessionValue = (el: FillableElement) => {
if (!fillSession || fillSession.element !== el) return;
fillSession.lastValue = fillableValue(el);
};
const ensureFillSession = (el: FillableElement) => {
if (fillSession?.element === el) return;
if (fillSession) commitFillSession();
const currentValue = fillableValue(el);
const baselineValue = committedValues.get(el) ?? currentValue;
fillSession = {
element: el,
target: describeTarget(el),
baselineValue,
lastValue: currentValue,
};
};
const emitNavigateIfChanged = (causedByAction?: boolean) => {
if (location.href === lastUrl) return;
commitFillSession();
lastUrl = location.href;
emitStep({
op: "navigate",
url: location.href,
...(causedByAction !== undefined ? { navigation_caused_by_action: causedByAction } : {}),
});
};
const emitClick = (event: MouseEvent) => {
// Only record clicks an LLM can re-identify (named interactive controls).
const target = describeEventTarget(eventTarget(event));
if (!target) return;
markNavigationAction();
emitStep({
op: "click",
target,
expects_navigation: true,
});
};
const onClick = (event: MouseEvent) => {
if (event.button !== 0) return;
const target = eventTarget(event);
if (isOverlayTarget(target)) return;
if (event.detail === 0 && generatedControlClick !== null && target === generatedControlClick) {
generatedControlClick = null;
return;
}
if (
event.detail === 0 &&
keyboardActivation?.target === target &&
Date.now() - keyboardActivation.recordedAt < 500
) {
keyboardActivation = undefined;
return;
}
const label = target instanceof Element ? target.closest("label") : null;
const nestedInteractive =
target instanceof Element ? target.closest("a,button,input,select,textarea") : null;
if (label instanceof HTMLLabelElement && !nestedInteractive) {
commitFillSession();
generatedControlClick = label.control;
emitClick(event);
return;
}
const fillable = fillableFromTarget(target);
if (fillable) {
ensureFillSession(fillable);
return;
}
if (target instanceof Element) {
const nearbyFillable = nearbyFillableFromSearchChrome(target);
if (nearbyFillable) {
ensureFillSession(nearbyFillable);
return;
}
}
if (isInputCompletionClick(target, fillSession)) {
markNavigationAction();
const sessionElement = fillSession!.element;
scheduleInputCompletionCommit(
sessionElement,
syncFillSessionValue,
commitFillSession,
(el) => fillSession?.element === el,
);
return;
}
commitFillSession();
if (target instanceof Element && target.closest("select")) return;
emitClick(event);
};
const onFocusIn = (event: FocusEvent) => {
const target = fillableFromTarget(eventTarget(event));
if (target) ensureFillSession(target);
};
const onFocusOut = (event: FocusEvent) => {
const target = fillableFromTarget(eventTarget(event));
if (!target) return;
if (!fillSession || fillSession.element !== target) return;
syncFillSessionValue(target);
commitFillSession();
};
const onInput = (event: Event) => {
const target = fillableFromTarget(eventTarget(event));
if (!target) return;
if (composing) return;
ensureFillSession(target);
syncFillSessionValue(target);
};
const onCompositionStart = () => {
composing = true;
};
const onCompositionEnd = (event: CompositionEvent) => {
composing = false;
const target = fillableFromTarget(eventTarget(event));
if (target) {
ensureFillSession(target);
syncFillSessionValue(target);
}
};
const onChange = (event: Event) => {
commitFillSession();
const target = eventTarget(event);
if (target instanceof HTMLSelectElement) {
const values = Array.from(target.selectedOptions).map((opt) => opt.value);
const labels = Array.from(target.selectedOptions).map((opt) =>
(opt.label || opt.textContent || opt.value).trim(),
);
const desc = describeTarget(target);
markNavigationAction();
emitStep({
op: "select",
target: desc,
values,
labels,
expects_navigation: true,
});
}
};
const onKeyDown = (event: KeyboardEvent) => {
if (isOverlayTarget(eventTarget(event))) return;
const target = eventTarget(event);
const fillable = fillableFromTarget(target);
if (fillable) {
ensureFillSession(fillable);
syncFillSessionValue(fillable);
}
const modifiers = [
...(event.altKey ? (["alt"] as const) : []),
...(event.ctrlKey ? (["ctrl"] as const) : []),
...(event.metaKey ? (["meta"] as const) : []),
...(event.shiftKey ? (["shift"] as const) : []),
];
if (!shouldRecordPress(event.key, modifiers)) {
return;
}
markNavigationAction();
if (event.key === "Enter" || event.key === " ") {
const submitTarget =
event.key === "Enter" && fillable
? fillable
.closest("form")
?.querySelector(
'button:not([type]),button[type="submit"],input[type="submit"],input[type="image"]',
)
: null;
keyboardActivation = {
target: submitTarget ?? target,
recordedAt: Date.now(),
};
}
if (fillable) {
commitFillSession();
}
const desc = describeEventTarget(target);
if (!desc && !event.key) return;
emitStep({
op: "press",
key: event.key,
...(desc ? { target: desc } : {}),
...(modifiers.length ? { modifiers } : {}),
expects_navigation: event.key === "Enter",
});
};
document.addEventListener("click", onClick, true);
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("focusout", onFocusOut, true);
document.addEventListener("input", onInput, true);
document.addEventListener("compositionstart", onCompositionStart, true);
document.addEventListener("compositionend", onCompositionEnd, true);
document.addEventListener("change", onChange, true);
document.addEventListener("keydown", onKeyDown, true);
const urlObserver = new MutationObserver(() => emitNavigateIfChanged());
urlObserver.observe(document, { subtree: true, childList: true });
const onUrlEvent = () => emitNavigateIfChanged();
window.addEventListener("hashchange", onUrlEvent);
window.addEventListener("popstate", onUrlEvent);
window.addEventListener("pagehide", commitFillSession);
const originalPushState = history.pushState;
const originalReplaceState = history.replaceState;
history.pushState = function (...args: Parameters<History["pushState"]>) {
originalPushState.apply(this, args);
emitNavigateIfChanged(navigationActionPending ? true : undefined);
};
history.replaceState = function (...args: Parameters<History["replaceState"]>) {
originalReplaceState.apply(this, args);
emitNavigateIfChanged(navigationActionPending ? true : undefined);
};
return {
dispose() {
commitFillSession();
document.removeEventListener("click", onClick, true);
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("focusout", onFocusOut, true);
document.removeEventListener("input", onInput, true);
document.removeEventListener("compositionstart", onCompositionStart, true);
document.removeEventListener("compositionend", onCompositionEnd, true);
document.removeEventListener("change", onChange, true);
document.removeEventListener("keydown", onKeyDown, true);
urlObserver.disconnect();
window.removeEventListener("hashchange", onUrlEvent);
window.removeEventListener("popstate", onUrlEvent);
window.removeEventListener("pagehide", commitFillSession);
history.pushState = originalPushState;
history.replaceState = originalReplaceState;
},
};
}
export type RecordContentMessage = RecordStartMessage | RecordStopMessage | RecordCancelMessage;
export function isRecordContentMessage(msg: unknown): msg is RecordContentMessage {
return isRecordStartMessage(msg) || isRecordStopMessage(msg) || isRecordCancelMessage(msg);
}
export function handleRecordContentMessage(
message: RecordContentMessage,
state: {
activeRequestId: string | null;
capture: RecordCaptureController | null;
setActiveRequestId(id: string | null): void;
setCapture(capture: RecordCaptureController | null): void;
onStart(requestId: string): void;
onStop(): void;
},
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
): boolean {
if (isRecordStartMessage(message)) {
if (!knownRecordRequests.has(message.requestId)) {
knownRecordRequests.add(message.requestId);
failedStepDeliveries.delete(message.requestId);
}
state.capture?.dispose();
state.setCapture(
startRecordCapture(message.requestId, (step) => {
sendRecordStep(message.requestId, step);
}),
);
state.setActiveRequestId(message.requestId);
state.onStart(message.requestId);
sendResponse?.({ ok: true });
return sendResponse !== undefined;
}
if (isRecordStopMessage(message) || isRecordCancelMessage(message)) {
// Require an active recording that matches this requestId — otherwise a
// stray STOP/CANCEL (e.g. after teardown) would still run finishStop and
// clear overlay state even though nothing was capturing.
if (!state.activeRequestId || state.activeRequestId !== message.requestId) {
return false;
}
state.capture?.dispose();
state.setCapture(null);
const finishStop = () => {
state.onStop();
state.setActiveRequestId(null);
};
if (isRecordStopMessage(message) && sendResponse) {
const pending = [...(pendingStepSends.get(message.requestId) ?? [])];
void Promise.all(pending).then((delivered) => {
finishStop();
const succeeded = delivered.every(Boolean) && !failedStepDeliveries.has(message.requestId);
if (succeeded) {
failedStepDeliveries.delete(message.requestId);
knownRecordRequests.delete(message.requestId);
}
sendResponse(
succeeded
? { ok: true }
: {
ok: false,
error: "failed to deliver one or more recorded steps",
},
);
});
return true;
}
if (isRecordCancelMessage(message)) {
failedStepDeliveries.delete(message.requestId);
knownRecordRequests.delete(message.requestId);
}
finishStop();
return false;
}
return false;
}
export { RECORD_CANCEL, RECORD_START, RECORD_STEP, RECORD_STOP };
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
// `background.ts` is a WXT entrypoint that calls the global `defineBackground`
// at module load. WXT injects that helper at build time via auto-imports;
+34 -11
View File
@@ -8,6 +8,7 @@ import {
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
import {
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_INTERRUPT,
OVERLAY_MSG_WHO_AM_I,
type OverlayInterruptRequest,
@@ -27,16 +28,18 @@ import {
requestBorrowConfirmation,
} from "@/tools/borrow-confirmation";
import { ToolDispatcher } from "@/tools/dispatcher";
import {
attachRecordFinishListener,
attachRecordQueryListener,
attachRecordStepListener,
} from "@/tools/record";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
export const DEFAULT_DAEMON_PORT = 52800;
export default defineBackground(() => {
const port = DEFAULT_DAEMON_PORT;
const controller = new ConnectionController();
const transport = new WSTransport({ url: `ws://127.0.0.1:${port}` });
const transport = new WSTransport({ url: __BSK_DAEMON_WS_URL__ });
const sessions = new SessionManager();
const cdp = new ChromiumCdp();
const sessionsLive = attachSessionsLiveFlag({ manager: sessions });
@@ -57,8 +60,7 @@ export default defineBackground(() => {
deps: {
// Skip every Agent Window when choosing where to render the
// overlay — Agent Windows boot on about:blank, which has no
// content script, so sendMessage would fail-open and silently
// allow the borrow without any UI shown.
// content script, so they cannot surface an authorization decision.
isAgentWindowId: (windowId) => sessions.findByWindowId(windowId) !== null,
// Resolve i18n strings per-borrow so language switches take effect
// without re-creating the dispatcher.
@@ -71,6 +73,27 @@ export default defineBackground(() => {
}),
});
dispatcher.start();
const recordDeps = {
tabsApi: chrome.tabs,
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
chrome.tabs.sendMessage(tabId, msg),
bypassOverlay: async (tabId: number, enabled: boolean) => {
try {
await chrome.tabs.sendMessage(tabId, {
type: OVERLAY_AUTOMATION_BYPASS,
enabled,
});
} catch {
// Content script may be unavailable on restricted pages.
}
},
};
// Message listeners stay up (cheap; fire only for record message types).
// Tab / webNavigation observation attaches lazily while a recording is
// active — see ensureBrowserObservationListeners in tools/record.ts.
attachRecordStepListener();
attachRecordFinishListener(recordDeps);
attachRecordQueryListener(recordDeps);
if (typeof chrome.notifications?.onClicked?.addListener === "function") {
attachBorrowNotificationClickHandler({
onClicked: chrome.notifications.onClicked,
@@ -85,8 +108,8 @@ export default defineBackground(() => {
// The Allow / Deny buttons on the OS notification are the *explicit*
// authorization fallback when every candidate user window's content
// script was missing (extension just reloaded, page in BFCache, etc.).
// Without this listener those button clicks would land nowhere and we'd
// be back to fail-open-after-sendMessage-failure.
// Without this listener those button clicks would land nowhere and the
// request would only resolve via the fail-closed background timeout.
if (typeof chrome.notifications?.onButtonClicked?.addListener === "function") {
attachBorrowNotificationButtonHandler({
onButtonClicked: chrome.notifications.onButtonClicked,
@@ -167,9 +190,9 @@ export default defineBackground(() => {
// 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).then(() =>
persistConnectionEnabled(msg.value),
);
void controller
.setConnectionEnabled(msg.value)
.then(() => persistConnectionEnabled(msg.value));
}
}
});
+107 -14
View File
@@ -1,8 +1,18 @@
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay";
import { ControlOverlay } from "@/content/ControlOverlay";
import { HelpRequestOverlay } from "@/content/HelpRequestOverlay";
import overlayCss from "@/content/overlay.css?inline";
import { OverlayController } from "@/content/overlay-controller";
import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller";
import { RecordOverlay } from "@/content/RecordOverlay";
import {
handleRecordContentMessage,
isRecordContentMessage,
type RecordCaptureController,
} from "@/content/record-capture";
import {
HELP_RESPONSE,
type HelpCancelMessage,
@@ -12,24 +22,26 @@ import {
isHelpRequestMessage,
} from "@/lib/help-bridge";
import {
isOverlayAgentOverlayResetMessage,
OVERLAY_AUTOMATION_BYPASS,
OVERLAY_MSG_WHO_AM_I,
type OverlayAgentOverlayResetMessage,
type OverlayAutomationBypassMessage,
type OverlayWhoAmIResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import {
RECORD_FINISH,
RECORD_QUERY,
type RecordStartAck,
type RecordStopAck,
} from "@/lib/record-bridge";
import { SESSIONS_LIVE_FLAG_KEY } from "@/lib/sessions-live-flag";
import type {
BorrowCancelMessage,
BorrowRequestMessage,
BorrowResponseMessage,
} from "@/tools/borrow-confirmation";
import { i18n } from "@browser-skill/i18n";
import { I18nextProvider } from "@browser-skill/i18n/react";
import React from "react";
import ReactDOM from "react-dom/client";
// Run at document_end so the overlay does not block first paint. Only attach
// in the top-level frame so iframes do not double-render overlays.
@@ -45,6 +57,8 @@ export default defineContentScript({
const overlays = new OverlayController();
let activeHelpRespond: ((outcome: "continued" | "cancelled", note?: string) => void) | null =
null;
let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null;
let hostLossReported = false;
@@ -87,12 +101,13 @@ export default defineContentScript({
requests: overlayState.borrowRequests,
}),
React.createElement(ControlOverlay, {
visible: overlayState.controlVisible && overlayState.activeHelp === null,
visible: shouldShowAgentControlOverlay(overlayState),
interrupting: overlayState.interrupting,
automationBypass: overlayState.automationBypassCount > 0,
onInterrupt: handleInterrupt,
}),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
),
),
);
@@ -104,6 +119,9 @@ export default defineContentScript({
activeHelpRespond?.("cancelled");
activeHelpRespond = null;
}
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
renderOverlay();
}
@@ -117,10 +135,7 @@ export default defineContentScript({
}
overlays.setInterrupting(true);
renderOverlay();
void sendInterrupt(
(msg) => chrome.runtime.sendMessage(msg),
sessionId,
).then((reply) => {
void sendInterrupt((msg) => chrome.runtime.sendMessage(msg), sessionId).then((reply) => {
// Always retract the mask after the round trip resolves
// (success, failure, or timeout). Cancellation is fire-and-
// forget on the daemon side; the user must not be stuck
@@ -143,6 +158,42 @@ export default defineContentScript({
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpResponseMessage) => void,
) => {
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
message,
{
activeRequestId: activeRecordRequestId,
capture: recordCapture,
setActiveRequestId: (id) => {
activeRecordRequestId = id;
},
setCapture: (capture) => {
recordCapture = capture;
},
onStart: (requestId) => {
overlays.setAgentRecordRequest({
id: requestId,
onFinish: () => {
void chrome.runtime.sendMessage({
type: RECORD_FINISH,
requestId,
});
},
});
renderOverlay();
},
onStop: () => {
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
renderOverlay();
},
},
sendResponse as unknown as
| ((response: RecordStartAck | RecordStopAck) => void)
| undefined,
);
return needsAsync;
}
if (
message &&
typeof message === "object" &&
@@ -231,23 +282,59 @@ export default defineContentScript({
return false;
};
async function mountOverlayIfAgent(): Promise<void> {
async function syncAgentOverlay(): Promise<void> {
if (!(await anySessionLive())) return;
try {
const reply = (await chrome.runtime.sendMessage({
kind: OVERLAY_MSG_WHO_AM_I,
})) as OverlayWhoAmIResponse | undefined;
if (!reply?.sessionId) return;
// Query / re-arm recording *before* activating the control overlay so
// record start (navigate → content load) does not flash
// 「Agent 正在控制」before RecordOverlay mounts.
let recordQuery: { active?: boolean; requestId?: string } | undefined;
try {
recordQuery = (await chrome.runtime.sendMessage({
type: RECORD_QUERY,
})) as { active?: boolean; requestId?: string } | undefined;
} catch (err) {
console.debug("[bsk overlay] record query failed", err);
}
if (
recordQuery?.active &&
typeof recordQuery.requestId === "string" &&
overlays.snapshot().activeRecord === null
) {
const requestId = recordQuery.requestId;
overlays.setAgentRecordRequest({
id: requestId,
onFinish: () => {
void chrome.runtime.sendMessage({
type: RECORD_FINISH,
requestId,
});
},
});
}
overlays.activateAgentSession(reply.sessionId);
renderOverlay();
} catch (err) {
console.debug("[bsk overlay] who_am_i failed", err);
console.debug("[bsk overlay] syncAgentOverlay failed", err);
}
}
const onPageShow = (event: PageTransitionEvent) => {
if (event.persisted) void syncAgentOverlay();
};
ui.mount();
chrome.runtime.onMessage.addListener(onMessage);
void mountOverlayIfAgent();
void syncAgentOverlay();
window.addEventListener("pageshow", onPageShow);
const hostObserver = new MutationObserver(() => {
const connected = overlayHost?.isConnected ?? false;
@@ -257,6 +344,7 @@ export default defineContentScript({
remountInProgress = true;
try {
ui.mount();
void syncAgentOverlay();
} finally {
remountInProgress = false;
}
@@ -271,6 +359,11 @@ export default defineContentScript({
ctx.onInvalidated(() => {
hostObserver.disconnect();
chrome.runtime.onMessage.removeListener(onMessage);
window.removeEventListener("pageshow", onPageShow);
// Restore history hooks / remove capture listeners before the CS unloads.
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
});
},
});
@@ -24,6 +24,11 @@ const baseSnapshot: SnapshotInfo = {
connectionEnabled: true,
};
function openRecordView() {
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
}
describe("App", () => {
const setLabel = vi.fn();
const setConnectionEnabled = vi.fn();
@@ -63,13 +68,35 @@ describe("App", () => {
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
});
it("uses flex gap for label field spacing", () => {
const { container } = render(<App />);
it("does not render record UI on the main view", () => {
render(<App />);
const labelField = container.querySelector("[data-slot='popup-label-field']");
expect(labelField?.className).toContain("flex");
expect(labelField?.className).toContain("flex-col");
expect(labelField?.className).toContain("gap-3");
expect(screen.queryByRole("button", { name: "复制录制指令" })).toBeNull();
expect(screen.queryByRole("button", { name: "操作录制" })).toBeNull();
});
it("opens the feature list from the launcher and navigates to record", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
expect(screen.getByText("操作录制")).toBeTruthy();
expect(screen.getByText("录制你的操作,供 Agent 参考")).toBeTruthy();
expect(screen.queryByText("未连接")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /操作录制/ }));
expect(screen.getByRole("button", { name: "复制录制指令" })).toBeTruthy();
});
it("returns from the feature list to the main view", () => {
render(<App />);
fireEvent.click(screen.getByRole("button", { name: "快捷功能" }));
fireEvent.click(screen.getByRole("button", { name: "返回" }));
expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.queryByText("录制你的操作,供 Agent 参考")).toBeNull();
});
it("shows single-line compact metadata and copies the instance id", async () => {
@@ -132,8 +159,88 @@ describe("App", () => {
render(<App />);
expect(screen.getByText("连接已关闭")).toBeTruthy();
expect(screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked")).toBe(
"false",
);
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("false");
});
it("copies the record prompt with instance id and --browser when connected", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
openRecordView();
fireEvent.change(screen.getByPlaceholderText("例如:发布一篇文章"), {
target: { value: "发布 wiki 文档" },
});
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
expect(copyButton.getAttribute("disabled")).toBeNull();
fireEvent.click(copyButton);
expect(navigator.clipboard.writeText).toHaveBeenCalledTimes(1);
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
expect(copied).toContain("03c3e47f");
expect(copied).toContain("--browser 03c3e47f");
expect(copied).toContain('--purpose "发布 wiki 文档"');
expect(copied).not.toMatch(/bsk record start[^\n]*--url/);
// Button label stays static; a transient toast confirms the copy.
expect(copyButton.textContent).toContain("复制录制指令");
await waitFor(() => expect(screen.getByRole("status").textContent).toContain("已复制"));
});
it("includes --url in record prompt when a start URL is provided", async () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
state: "connected",
instanceId: "03c3e47f",
handshake: {
server: "bh",
version: mockDaemonVersion,
protocol_version: "1.0",
},
},
statusState: "connected",
setLabel,
setConnectionEnabled,
});
render(<App />);
openRecordView();
fireEvent.change(screen.getByPlaceholderText("https://…"), {
target: { value: "https://example.com/" },
});
fireEvent.click(screen.getByRole("button", { name: "复制录制指令" }));
const copied = vi.mocked(navigator.clipboard.writeText).mock.calls[0]?.[0] ?? "";
expect(copied).toContain("--url https://example.com/");
expect(copied).not.toContain("--purpose");
});
it("disables record copy when disconnected", () => {
render(<App />);
openRecordView();
const copyButton = screen.getByRole("button", { name: "复制录制指令" });
expect(copyButton.getAttribute("disabled")).not.toBeNull();
expect(screen.getByText("连接后可用")).toBeTruthy();
});
});
+312 -134
View File
@@ -1,9 +1,16 @@
import { useTranslation } from "@browser-skill/i18n/react";
import { Badge, Button, Input, Label, cn } from "@browser-skill/ui";
import { RiCheckLine, RiFileCopyLine } from "@remixicon/react";
import { Badge, Button, cn, Input, Label } from "@browser-skill/ui";
import {
RiApps2Line,
RiArrowLeftLine,
RiArrowRightSLine,
RiCheckLine,
RiFileCopyLine,
} from "@remixicon/react";
import { type ChangeEvent, useEffect, useState } from "react";
import { PROTOCOL_VERSION } from "@/transport/handshake";
import { ConnectionStatusIndicator } from "./connection-status-indicator";
import { POPUP_FEATURES, type PopupView } from "./features";
import { type PopupStatusState, useConnectionState } from "./use-connection-state";
const STATE_LABEL_KEYS = {
@@ -29,9 +36,15 @@ function getLogoSrc() {
export function App() {
const { t } = useTranslation("extension");
const { snapshot, statusState, setLabel, setConnectionEnabled } = useConnectionState();
const [labelDraft, setLabelDraft] = useState(snapshot.label);
const { snapshot, statusState, setConnectionEnabled } = useConnectionState();
const [view, setView] = useState<PopupView>("main");
const [copiedInstanceId, setCopiedInstanceId] = useState(false);
const [purposeDraft, setPurposeDraft] = useState("");
const [startUrlDraft, setStartUrlDraft] = useState("");
// Bumped on every successful copy so the "copied" toast re-shows (and its
// auto-hide timer restarts) even when the copied content is unchanged.
const [copiedTick, setCopiedTick] = useState(0);
const showCopiedToast = copiedTick > 0;
useEffect(() => {
const query = window.matchMedia("(prefers-color-scheme: dark)");
@@ -44,17 +57,22 @@ export function App() {
return () => query.removeEventListener("change", onChange);
}, []);
useEffect(() => {
setLabelDraft(snapshot.label);
}, [snapshot.label]);
useEffect(() => {
setCopiedInstanceId(false);
setCopiedTick(0);
}, [snapshot.instanceId]);
const onLabelBlur = () => {
if (labelDraft !== snapshot.label) setLabel(labelDraft);
};
// Hide the copied toast when the command changes (topic / start URL edits).
useEffect(() => {
setCopiedTick(0);
}, [purposeDraft, startUrlDraft]);
// Auto-hide the copied toast shortly after it appears.
useEffect(() => {
if (copiedTick === 0) return;
const timer = window.setTimeout(() => setCopiedTick(0), 1500);
return () => window.clearTimeout(timer);
}, [copiedTick]);
const isSkewed = statusState === "version_skew";
const daemonVersion = snapshot.handshake?.version ?? "—";
@@ -68,142 +86,302 @@ export function App() {
setCopiedInstanceId(true);
};
const recordReady = statusState === "connected" && Boolean(snapshot.instanceId);
const recordPurpose = purposeDraft.trim();
const recordStartUrl = startUrlDraft.trim();
const recordCommand = snapshot.instanceId
? [
`bsk record start --browser ${snapshot.instanceId}`,
recordStartUrl ? `--url ${recordStartUrl}` : "",
recordPurpose ? `--purpose "${recordPurpose}"` : "",
]
.filter(Boolean)
.join(" ")
: "";
const recordPrompt = snapshot.instanceId
? t("popup.record.promptTemplate", {
command: recordCommand,
})
: "";
const copyRecordPrompt = async () => {
if (!recordReady || !navigator.clipboard?.writeText) return;
await navigator.clipboard.writeText(recordPrompt);
setCopiedTick((tick) => tick + 1);
};
const headerTitle =
view === "features"
? t("popup.launcher.title")
: view === "record"
? t("popup.record.sectionTitle")
: t("popup.brandName");
return (
<main
className="min-w-[320px] max-w-[340px] space-y-3 bg-background p-3 text-foreground"
data-slot="popup-root"
data-view={view}
data-version-skew={isSkewed ? "true" : undefined}
>
<header className="flex items-center gap-2" data-slot="popup-brand-header">
<img
src={getLogoSrc()}
alt=""
className="size-7 rounded-lg"
data-slot="popup-brand-logo"
/>
<h1 className="text-sm font-semibold tracking-tight">{t("popup.brandName")}</h1>
</header>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-connection-card"
<header
className="flex items-center gap-2"
data-slot={view === "main" ? "popup-brand-header" : "popup-subview-header"}
>
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
<div className="flex min-w-0 items-center gap-2">
<ConnectionStatusIndicator state={statusState} />
<span
className="truncate text-sm font-medium"
data-slot="popup-state-label"
>
{t(STATE_LABEL_KEYS[statusState])}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px] font-medium uppercase"
data-slot="popup-state-badge"
>
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<button
type="button"
role="switch"
aria-checked={snapshot.connectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
)}
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
</div>
{isSkewed && (
<p
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
data-slot="popup-version-skew-warning"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
})}
</p>
)}
</section>
{snapshot.lastError && (
<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"
>
{snapshot.lastError}
</div>
)}
<div className="flex flex-col gap-3" data-slot="popup-label-field">
<Label htmlFor="bh-label" className="block text-xs text-muted-foreground">
{t("popup.labelTitle")}
</Label>
<Input
id="bh-label"
type="text"
value={labelDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) => setLabelDraft(event.target.value)}
onBlur={onLabelBlur}
placeholder={t("popup.labelPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-label-input"
/>
</div>
<section
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span aria-hidden>/</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>
<code
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
data-slot="popup-instance-id"
>
{instanceId}
</code>
{view !== "main" ? (
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 rounded-md"
disabled={!snapshot.instanceId}
aria-label={t("popup.copyInstanceId")}
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
onClick={() => {
void copyInstanceId();
}}
data-slot="popup-copy-instance-id"
className="size-7 shrink-0 rounded-md"
aria-label={t("popup.back")}
onClick={() => setView(view === "record" ? "features" : "main")}
data-slot="popup-back"
>
{copiedInstanceId ? (
<RiCheckLine className="size-3" aria-hidden />
) : (
<RiFileCopyLine className="size-3" aria-hidden />
)}
<RiArrowLeftLine className="size-4" aria-hidden />
</Button>
</div>
</section>
) : (
<img
src={getLogoSrc()}
alt=""
className="size-7 rounded-lg"
data-slot="popup-brand-logo"
/>
)}
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold tracking-tight">
{headerTitle}
</h1>
{view === "main" && (
<Button
type="button"
variant="ghost"
size="icon"
className="size-7 shrink-0 rounded-md"
aria-label={t("popup.launcher.title")}
onClick={() => setView("features")}
data-slot="popup-launcher"
>
<RiApps2Line className="size-4" aria-hidden />
</Button>
)}
</header>
{view === "main" && (
<>
<section
className="rounded-xl border border-border/80 bg-card/60 px-3 py-2.5"
data-slot="popup-connection-card"
>
<div className="flex items-center justify-between gap-2" data-slot="popup-status">
<div className="flex min-w-0 items-center gap-2">
<ConnectionStatusIndicator state={statusState} />
<span className="truncate text-sm font-medium" data-slot="popup-state-label">
{t(STATE_LABEL_KEYS[statusState])}
</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<Badge
variant="outline"
className="px-1.5 py-0 text-[10px] font-medium uppercase"
data-slot="popup-state-badge"
>
{t(STATE_BADGE_KEYS[statusState])}
</Badge>
<button
type="button"
role="switch"
aria-checked={snapshot.connectionEnabled}
aria-label={t("popup.connectionToggleTitle")}
data-slot="popup-connection-toggle"
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/50",
snapshot.connectionEnabled ? "bg-primary" : "bg-muted",
)}
onClick={() => setConnectionEnabled(!snapshot.connectionEnabled)}
>
<span
className={cn(
"pointer-events-none block size-4 rounded-full bg-background shadow-sm transition-transform",
snapshot.connectionEnabled ? "translate-x-4" : "translate-x-0.5",
)}
aria-hidden
/>
</button>
</div>
</div>
{isSkewed && (
<p
className="mt-2 text-xs leading-snug text-amber-600 dark:text-amber-400"
data-slot="popup-version-skew-warning"
>
{t("popup.versionSkewWarning", {
extensionProtocol: PROTOCOL_VERSION,
daemonProtocol,
})}
</p>
)}
</section>
{snapshot.lastError && (
<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"
>
{snapshot.lastError}
</div>
)}
<section
className="flex min-w-0 items-center justify-between gap-2 border-t border-border/70 pt-2 text-[10px] leading-tight text-muted-foreground"
data-slot="popup-meta"
>
<div className="flex shrink-0 items-center gap-1">
<span title={t("popup.extensionVersionHint")}>{extensionVersion}</span>
<span aria-hidden>/</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>
<code
className="max-w-[88px] truncate font-mono text-[10px] text-foreground/80"
data-slot="popup-instance-id"
>
{instanceId}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="size-5 rounded-md"
disabled={!snapshot.instanceId}
aria-label={t("popup.copyInstanceId")}
title={copiedInstanceId ? t("popup.copied") : t("popup.copyInstanceId")}
onClick={() => {
void copyInstanceId();
}}
data-slot="popup-copy-instance-id"
>
{copiedInstanceId ? (
<RiCheckLine className="size-3" aria-hidden />
) : (
<RiFileCopyLine className="size-3" aria-hidden />
)}
</Button>
</div>
</section>
</>
)}
{view === "features" && (
<section className="space-y-2" data-slot="popup-feature-list">
{POPUP_FEATURES.map((feature) => {
const Icon = feature.icon;
return (
<button
key={feature.id}
type="button"
className="flex w-full items-center gap-3 rounded-xl border border-border/80 bg-card/60 px-3 py-2.5 text-left transition-colors hover:bg-card/80"
onClick={() => setView(feature.id)}
data-slot={`popup-feature-${feature.id}`}
>
<Icon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium text-foreground">
{t(feature.titleKey)}
</span>
<span className="mt-0.5 block text-[11px] leading-snug text-muted-foreground">
{t(feature.descKey)}
</span>
</span>
<RiArrowRightSLine className="size-4 shrink-0 text-muted-foreground" aria-hidden />
</button>
);
})}
</section>
)}
{view === "record" && (
<section className="space-y-2.5" data-slot="popup-record-body">
<p
className="text-[11px] leading-snug text-muted-foreground"
data-slot="popup-record-hint-desc"
>
{t("popup.record.sectionHint")}
</p>
<div className="flex flex-col gap-1.5" data-slot="popup-record-purpose-field">
<Label htmlFor="bh-record-purpose" className="block text-xs text-muted-foreground">
{t("popup.record.topicLabel")}
</Label>
<Input
id="bh-record-purpose"
type="text"
value={purposeDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setPurposeDraft(event.target.value)
}
placeholder={t("popup.record.topicPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-record-purpose-input"
/>
</div>
<div className="flex flex-col gap-1.5" data-slot="popup-record-start-url-field">
<Label htmlFor="bh-record-start-url" className="block text-xs text-muted-foreground">
{t("popup.record.startUrlLabel")}
</Label>
<Input
id="bh-record-start-url"
type="url"
value={startUrlDraft}
onChange={(event: ChangeEvent<HTMLInputElement>) =>
setStartUrlDraft(event.target.value)
}
placeholder={t("popup.record.startUrlPlaceholder")}
className="mt-0 h-8 text-sm"
data-slot="popup-record-start-url-input"
/>
</div>
<textarea
readOnly
value={recordPrompt}
rows={5}
className="w-full resize-none rounded-md border border-input bg-muted/40 px-2.5 py-2 font-mono text-[10px] leading-snug text-foreground/90 focus-visible:outline-none"
data-slot="popup-record-prompt"
/>
<div className="flex items-center justify-between gap-2">
{!recordReady && (
<p className="text-[10px] text-muted-foreground" data-slot="popup-record-hint">
{t("popup.record.hintDisconnected")}
</p>
)}
<div className="relative ml-auto shrink-0">
{showCopiedToast && (
<div
role="status"
className="absolute bottom-full right-0 mb-1.5 flex items-center gap-1 whitespace-nowrap rounded-md bg-foreground/65 px-2 py-1 text-[10px] font-medium text-background shadow-md backdrop-blur-sm"
data-slot="popup-record-copied-toast"
>
<RiCheckLine className="size-3" aria-hidden />
{t("popup.record.copied")}
</div>
)}
<Button
type="button"
variant="secondary"
size="sm"
className="h-7 px-2.5 text-xs"
disabled={!recordReady}
onClick={() => {
void copyRecordPrompt();
}}
data-slot="popup-record-copy"
>
<RiFileCopyLine className="size-3.5" aria-hidden />
{t("popup.record.copyButton")}
</Button>
</div>
</div>
</section>
)}
</main>
);
}
@@ -0,0 +1,22 @@
import type { RemixiconComponentType } from "@remixicon/react";
import { RiRecordCircleLine } from "@remixicon/react";
export type PopupFeatureId = "record";
export type PopupView = "main" | "features" | PopupFeatureId;
export type PopupFeature = {
id: PopupFeatureId;
icon: RemixiconComponentType;
titleKey: "popup.record.sectionTitle";
descKey: "popup.record.cardDesc";
};
export const POPUP_FEATURES: PopupFeature[] = [
{
id: "record",
icon: RiRecordCircleLine,
titleKey: "popup.record.sectionTitle",
descKey: "popup.record.cardDesc",
},
];
@@ -1,3 +1,13 @@
@import "@browser-skill/ui/styles";
@source "../../";
@source "../../../../../packages/ui/src";
/* The shared base sets html/body/#root to height:100%, which is meant for
full-page surfaces. In a popup that pins the window to the tallest content
it ever showed, so collapsing the record section leaves blank space. Size
to content instead so Chrome can grow and shrink the popup to fit. */
html,
body,
#root {
height: auto;
}
@@ -1,10 +1,8 @@
import { ConnectionController, __testing__ } from "../connection-controller";
import type { Transport } from "../../transport/transport";
import type { ConnectionState } from "../../transport/types";
import type { ConnectionStateHandler } from "../../transport/transport";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
import type { HandshakeResult } from "../../transport/types";
import type { ConnectionStateHandler, Transport } from "../../transport/transport";
import type { ConnectionState, HandshakeResult } from "../../transport/types";
import { __testing__, ConnectionController } from "../connection-controller";
vi.mock("../instance-id", () => ({
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
@@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it } from "vitest";
import { describeEventTarget, describeTarget, isMeaningfulClickTarget } from "../describe-target";
describe("describeTarget", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("prefers aria-label and button role", () => {
document.body.innerHTML = `<button aria-label="发布">go</button>`;
const el = document.querySelector("button")!;
expect(describeTarget(el)).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("uses label text and name_attr for inputs", () => {
document.body.innerHTML = `
<label for="svc">服务名称</label>
<input id="svc" name="serviceName" />
`;
const el = document.querySelector("input")!;
expect(describeTarget(el)).toEqual({
tag: "input",
role: "textbox",
name: "服务名称",
name_attr: "serviceName",
});
});
it("never invents CSS selectors or @eN", () => {
document.body.innerHTML = `<a href="/x">详情</a>`;
const desc = describeTarget(document.querySelector("a")!);
expect(JSON.stringify(desc)).not.toMatch(/@e\d+|nth-of-type|#/);
expect(desc).toEqual({ tag: "a", role: "link", name: "详情" });
});
it("prefers heading text inside SERP-style links", () => {
document.body.innerHTML = `
<a href="https://www.tencent.com">
<h3>Tencent 腾讯</h3>
<cite>https://www.tencent.com</cite>
</a>
`;
expect(describeTarget(document.querySelector("a")!).name).toBe("Tencent 腾讯");
});
});
describe("LLM textbook click targets", () => {
beforeEach(() => {
document.body.innerHTML = "";
});
it("rejects tag-only targets the LLM cannot find", () => {
expect(isMeaningfulClickTarget({ tag: "div" })).toBe(false);
});
it("treats a short visible name as the teaching signal", () => {
expect(isMeaningfulClickTarget({ tag: "div", name: "发布" })).toBe(true);
});
it("ignores anonymous layout chrome and lifts child clicks to the named button", () => {
document.body.innerHTML = `
<div id="noise">background</div>
<button type="button"><span id="icon">发布</span></button>
`;
expect(describeEventTarget(document.querySelector("#noise"))).toBeNull();
expect(describeEventTarget(document.querySelector("#icon"))).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("does not attach layout sibling text as nearby_label on buttons", () => {
document.body.innerHTML = `
<div>background</div>
<button type="button" id="pub">发布</button>
`;
expect(describeTarget(document.querySelector("#pub")!)).toEqual({
tag: "button",
role: "button",
name: "发布",
});
});
it("keeps nearby_label for form fields from a preceding label-like sibling", () => {
document.body.innerHTML = `
<div>服务名称</div>
<input id="svc" />
`;
expect(describeTarget(document.querySelector("#svc")!)).toEqual(
expect.objectContaining({
tag: "input",
nearby_label: "服务名称",
}),
);
});
it("does not treat plain text blocks as clicks", () => {
document.body.innerHTML = `<p id="copy">说明文字</p>`;
expect(describeEventTarget(document.querySelector("#copy"))).toBeNull();
});
it("records custom button-like controls by their visible label", () => {
document.body.innerHTML = `<div class="btn" id="pub" style="cursor: pointer">发布</div>`;
expect(describeEventTarget(document.querySelector("#pub"))).toEqual(
expect.objectContaining({ tag: "div", name: "发布" }),
);
});
});
@@ -51,12 +51,8 @@ describe("help-bridge", () => {
});
it("type-guards a help-cancel message", () => {
expect(
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
).toBe(true);
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
false,
);
expect(isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" })).toBe(true);
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(false);
expect(isHelpCancelMessage(null)).toBe(false);
});
});
@@ -1,10 +1,10 @@
import { describe, it, expect } from "vitest";
import { describe, expect, it } from "vitest";
import {
isOverlayAgentOverlayResetMessage,
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
describe("OVERLAY_MSG_INTERRUPT", () => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
describe("sendInterrupt", () => {
@@ -0,0 +1,80 @@
import { describe, expect, it } from "vitest";
import { appendRecordedPayload, observeRecordedNavigation } from "../recording-step-buffer";
describe("recording-step-buffer", () => {
it("stores semantic click without summary", () => {
const buffer = { steps: [], pendingNavigation: false };
appendRecordedPayload(buffer, {
op: "click",
target: { tag: "button", role: "button", name: "发布" },
expects_navigation: true,
});
expect(buffer.steps).toEqual([
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
},
]);
expect(buffer.pendingNavigation).toBe(true);
});
it("annotates navigated_to on action-caused navigation instead of wait_for_navigation", () => {
const buffer = {
steps: [
{
op: "click" as const,
target: { tag: "button", role: "button", name: "发布" },
},
],
currentUrl: "https://example.com/a",
pendingNavigation: true,
pendingNavigationDeadline: Date.now() + 5_000,
};
observeRecordedNavigation(buffer, "https://example.com/b", true);
expect(buffer.steps).toEqual([
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
navigated_to: "https://example.com/b",
},
]);
expect(JSON.stringify(buffer.steps)).not.toContain("wait_for_navigation");
});
it("annotates navigated_to onto a select that auto-submits a navigation", () => {
const buffer = {
steps: [
{
op: "select" as const,
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
},
],
currentUrl: "https://example.com/list",
pendingNavigation: true,
pendingNavigationDeadline: Date.now() + 5_000,
};
observeRecordedNavigation(buffer, "https://example.com/list?cat=tech", true);
expect(buffer.steps).toEqual([
{
op: "select",
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
navigated_to: "https://example.com/list?cat=tech",
},
]);
});
it("emits navigate for uncaused URL changes", () => {
const buffer = {
steps: [],
currentUrl: "https://example.com/a",
pendingNavigation: false,
};
observeRecordedNavigation(buffer, "https://example.com/b", false);
expect(buffer.steps[0]).toMatchObject({
op: "navigate",
url: "https://example.com/b",
});
});
});
@@ -0,0 +1,141 @@
import { describe, expect, it } from "vitest";
import type { DraftTraceStep } from "@/transport/types";
import { reduceTraceSteps, resolveTraceStartUrl, shouldRecordPress } from "../trace-reducer";
describe("shouldRecordPress", () => {
it("keeps Enter and Escape", () => {
expect(shouldRecordPress("Enter")).toBe(true);
expect(shouldRecordPress("Escape")).toBe(true);
});
it("drops modifiers, clipboard shortcuts, and bare typing", () => {
expect(shouldRecordPress("Meta")).toBe(false);
expect(shouldRecordPress("c", ["meta"])).toBe(false);
expect(shouldRecordPress("a", ["ctrl"])).toBe(false);
expect(shouldRecordPress("x")).toBe(false);
expect(shouldRecordPress("中")).toBe(false);
});
});
describe("reduceTraceSteps", () => {
it("builds steps with pages dictionary and page id references", () => {
const drafts: DraftTraceStep[] = [
{
op: "navigate",
url: "https://example.com/search?q=hello&utm_source=x",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "fill",
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
value: "browser skill",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "press",
key: "Enter",
target: { tag: "input", role: "textbox", name: "搜索", name_attr: "q" },
navigated_to: "https://example.com/results/42",
page_url: "https://example.com/search?q=hello&utm_source=x",
},
{
op: "click",
target: { tag: "button", role: "button", name: "发布" },
navigated_to: "https://example.com/p/99",
page_url: "https://example.com/results/42",
},
{
op: "press",
key: "a",
page_url: "https://example.com/p/99",
},
];
const { pages, steps } = reduceTraceSteps(
drafts,
"https://example.com/search?q=hello&utm_source=x",
);
expect(JSON.stringify(steps)).not.toContain("parameters");
expect(JSON.stringify(steps)).not.toContain("intent");
expect(JSON.stringify(steps)).not.toContain("summary");
expect(steps.map((s) => s.op)).toEqual(["navigate", "fill", "press", "click"]);
expect(pages.map((p) => p.url)).toEqual([
"https://example.com/search?q=hello&utm_source=x",
"https://example.com/results/42",
"https://example.com/p/99",
]);
expect(steps[0]).toMatchObject({
id: 1,
op: "navigate",
page: "p1",
to: "https://example.com/search?q=hello&utm_source=x",
});
expect(steps[1]).toMatchObject({
op: "fill",
page: "p1",
value: "browser skill",
});
expect(steps[2]).toMatchObject({
op: "press",
key: "Enter",
page: "p1",
effect: { navigated_to: "p2" },
});
expect(steps[3]).toMatchObject({
op: "click",
page: "p2",
effect: { navigated_to: "p3" },
});
});
it("collapses consecutive navigations", () => {
const { steps } = reduceTraceSteps([
{ op: "navigate", url: "https://a.example/redirect1" },
{ op: "navigate", url: "https://a.example/final" },
]);
expect(steps).toHaveLength(1);
expect(steps[0]).toMatchObject({
op: "navigate",
to: "https://a.example/final",
});
});
it("maps select navigated_to onto effect.navigated_to (page id)", () => {
const { pages, steps } = reduceTraceSteps(
[
{
op: "select",
target: { tag: "select", role: "combobox", name: "分类" },
values: ["tech"],
labels: ["技术"],
navigated_to: "https://example.com/list?cat=tech",
page_url: "https://example.com/list",
},
],
"https://example.com/list",
);
expect(pages.map((p) => p.url)).toEqual([
"https://example.com/list",
"https://example.com/list?cat=tech",
]);
expect(steps[0]).toMatchObject({
op: "select",
page: "p1",
selection: [{ value: "tech", label: "技术" }],
effect: { navigated_to: "p2" },
});
});
it("resolveTraceStartUrl prefers explicit start URL", () => {
expect(
resolveTraceStartUrl(
[{ op: "navigate", url: "https://example.com/other" }],
"https://example.com/start",
),
).toBe("https://example.com/start");
});
});
+345
View File
@@ -0,0 +1,345 @@
/**
* Build a semantic TargetDescriptor for an interacted element.
*
* Trace steps are an LLM *textbook*: each click must say what to look for
* on screen (usually a short visible name). Tag-only noise like
* `{ "tag": "div" }` / “点击div” is useless and must not be recorded.
*/
export interface TargetDescriptor {
role?: string;
name?: string;
tag: string;
name_attr?: string;
placeholder?: string;
nearby_label?: string;
}
/** Max length for a label that is still a useful “find this on screen” hint. */
const ACTIONABLE_LABEL_MAX = 48;
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function truncate(value: string, max = 80): string {
const trimmed = normalizeWhitespace(value);
if (trimmed.length <= max) return trimmed;
return `${trimmed.slice(0, max - 1)}…`;
}
/** Short, human label an LLM can search for in a snapshot. */
export function isActionableLabel(name: string): boolean {
const trimmed = normalizeWhitespace(name);
if (!trimmed) return false;
if (trimmed.length > ACTIONABLE_LABEL_MAX) return false;
if (/^https?:\/\//i.test(trimmed)) return false;
return true;
}
function labelledByText(el: Element): string | null {
const ids = el.getAttribute("aria-labelledby");
if (!ids) return null;
const parts = ids
.split(/\s+/)
.map((id) => document.getElementById(id)?.textContent ?? "")
.map(normalizeWhitespace)
.filter(Boolean);
return parts.length ? truncate(parts.join(" ")) : null;
}
function associatedLabelText(el: Element): string | null {
if (!(el instanceof HTMLElement) || !el.id) return null;
const label = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
if (!label) return null;
const text = normalizeWhitespace(label.textContent ?? "");
return text ? truncate(text) : null;
}
function wrappingLabelText(el: Element): string | null {
const label = el.closest("label");
if (!label) return null;
const clone = label.cloneNode(true) as HTMLElement;
for (const control of clone.querySelectorAll("input,textarea,select,button")) {
control.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
return text ? truncate(text) : null;
}
function placeholderOrTitle(el: Element): string | null {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
const ph = normalizeWhitespace(el.placeholder);
if (ph) return truncate(ph);
}
const title = normalizeWhitespace(el.getAttribute("title") ?? "");
return title ? truncate(title) : null;
}
function ownText(el: Element): string | null {
if (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement) {
return null;
}
const text = normalizeWhitespace(el.textContent ?? "");
return text ? truncate(text) : null;
}
/** Prefer a compact title inside links (e.g. SERP result `<h3>`), not the whole blob. */
function compactLinkName(el: Element): string | null {
const heading = el.querySelector("h1, h2, h3, h4");
if (heading) {
const text = normalizeWhitespace(heading.textContent ?? "");
if (text) return truncate(text);
}
const img = el.querySelector("img[alt]");
if (img instanceof HTMLImageElement) {
const alt = normalizeWhitespace(img.alt);
if (alt) return truncate(alt);
}
const clone = el.cloneNode(true) as HTMLElement;
for (const junk of clone.querySelectorAll("cite, script, style, svg, noscript")) {
junk.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
if (!text) return null;
const withoutUrl = text.replace(/https?:\/\/\S+/g, "").trim();
return truncate(withoutUrl || text);
}
/** Visible accessible name preference order for LLM textbooks. */
export function accessibleName(el: Element): string | undefined {
const ariaLabel = normalizeWhitespace(el.getAttribute("aria-label") ?? "");
if (ariaLabel) return truncate(ariaLabel);
const labelledBy = labelledByText(el);
if (labelledBy) return labelledBy;
const forLabel = associatedLabelText(el);
if (forLabel) return forLabel;
const wrapLabel = wrappingLabelText(el);
if (wrapLabel) return wrapLabel;
const alt =
el instanceof HTMLImageElement
? normalizeWhitespace(el.alt)
: normalizeWhitespace(el.getAttribute("alt") ?? "");
if (alt) return truncate(alt);
const ph = placeholderOrTitle(el);
if (ph) return ph;
const tag = el.tagName.toLowerCase();
if (tag === "a" || el.getAttribute("role") === "link") {
const linkName = compactLinkName(el);
if (linkName) return linkName;
}
const text = ownText(el);
if (text) return text;
if (el instanceof HTMLInputElement || el instanceof HTMLButtonElement) {
const value = normalizeWhitespace(el.value);
if (
value &&
(el instanceof HTMLButtonElement || el.type === "submit" || el.type === "button")
) {
return truncate(value);
}
}
return undefined;
}
const INTERACTIVE_SELECTOR = [
"a[href]",
"button",
"summary",
"select",
'input:not([type="hidden"])',
"textarea",
'[role="button"]',
'[role="link"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="menuitemcheckbox"]',
'[role="menuitemradio"]',
'[role="option"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="treeitem"]',
'[role="combobox"]',
'[contenteditable]:not([contenteditable="false"])',
].join(",");
const INTERACTIVE_ROLES = new Set([
"button",
"link",
"tab",
"menuitem",
"menuitemcheckbox",
"menuitemradio",
"option",
"checkbox",
"radio",
"switch",
"treeitem",
"combobox",
"textbox",
"searchbox",
"slider",
]);
function looksClickable(el: Element): boolean {
if (!(el instanceof HTMLElement)) return false;
if (el.matches(INTERACTIVE_SELECTOR)) return true;
const role = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
if (role && INTERACTIVE_ROLES.has(role)) return true;
if (el.tabIndex >= 0) return true;
try {
if (getComputedStyle(el).cursor === "pointer") return true;
} catch {
// happy-dom / detached nodes
}
const cls = typeof el.className === "string" ? el.className : "";
if (/\b(btn|button|link|clickable|action)\b/i.test(cls)) return true;
return false;
}
/**
* Resolve the element a click should describe for the LLM textbook.
* Never falls back to an anonymous layout `div`.
*/
export function resolveClickableElement(target: Element): Element | null {
const interactive = target.closest(INTERACTIVE_SELECTOR);
if (interactive instanceof Element) return interactive;
let node: Element | null = target;
let depth = 0;
while (node && node !== document.body && node !== document.documentElement && depth < 8) {
const name = accessibleName(node);
if (name && isActionableLabel(name) && looksClickable(node)) {
return node;
}
node = node.parentElement;
depth += 1;
}
return null;
}
/**
* Teachable clicks: the LLM must get a label it can later find via snapshot
* (visible name), or at least a form `name_attr` for checkbox/radio.
* Recording “点击div” with no name fails this bar.
*/
export function isMeaningfulClickTarget(target: TargetDescriptor): boolean {
const name = target.name?.trim();
if (name && isActionableLabel(name)) return true;
if (
(target.role === "checkbox" || target.role === "radio" || target.tag === "input") &&
target.name_attr
) {
return true;
}
return false;
}
export function inferRole(el: Element): string | undefined {
const explicit = normalizeWhitespace(el.getAttribute("role") ?? "").toLowerCase();
if (explicit) return explicit;
const tag = el.tagName.toLowerCase();
if (tag === "button") return "button";
if (tag === "a" && el.hasAttribute("href")) return "link";
if (tag === "select") return "combobox";
if (tag === "textarea") return "textbox";
if (tag === "img") return "img";
if (tag === "summary") return "button";
if (el instanceof HTMLElement && (el.isContentEditable || el.contentEditable === "true")) {
return "textbox";
}
if (el instanceof HTMLInputElement) {
const type = el.type.toLowerCase();
if (type === "checkbox") return "checkbox";
if (type === "radio") return "radio";
if (type === "submit" || type === "button" || type === "image" || type === "reset") {
return "button";
}
if (type === "range") return "slider";
return "textbox";
}
return undefined;
}
/**
* Nearby labels help the LLM name form fields. Only apply to fillable
* controls — never to buttons/links, where a previous layout sibling
* (e.g. a chrome `<div>`) would pollute the textbook target.
*/
function nearbyLabelText(el: Element): string | undefined {
if (
!(
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement
)
) {
return undefined;
}
if (el.id) {
const byFor = document.querySelector(`label[for="${CSS.escape(el.id)}"]`);
const text = byFor ? normalizeWhitespace(byFor.textContent ?? "") : "";
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
}
const wrapped = el.closest("label");
if (wrapped) {
const clone = wrapped.cloneNode(true) as HTMLLabelElement;
for (const control of clone.querySelectorAll("input, textarea, select, button")) {
control.remove();
}
const text = normalizeWhitespace(clone.textContent ?? "");
if (text) return truncate(text, ACTIONABLE_LABEL_MAX);
}
const prev = el.previousElementSibling;
if (prev && /^(LABEL|SPAN|DIV|P|DT)$/i.test(prev.tagName)) {
const text = normalizeWhitespace(prev.textContent ?? "");
if (text && text.length <= ACTIONABLE_LABEL_MAX) return text;
}
return undefined;
}
export function describeTarget(el: Element): TargetDescriptor {
const tag = el.tagName.toLowerCase();
const role = inferRole(el);
const name = accessibleName(el);
const nameAttr =
el instanceof HTMLInputElement ||
el instanceof HTMLTextAreaElement ||
el instanceof HTMLSelectElement ||
el instanceof HTMLButtonElement
? normalizeWhitespace(el.name) || undefined
: normalizeWhitespace(el.getAttribute("name") ?? "") || undefined;
const placeholder =
el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement
? normalizeWhitespace(el.placeholder) || undefined
: normalizeWhitespace(el.getAttribute("placeholder") ?? "") || undefined;
const nearby = nearbyLabelText(el);
return {
tag,
...(role ? { role } : {}),
...(name ? { name } : {}),
...(nameAttr ? { name_attr: nameAttr } : {}),
...(placeholder ? { placeholder } : {}),
...(nearby && nearby !== name ? { nearby_label: nearby } : {}),
};
}
export function describeEventTarget(target: EventTarget | null): TargetDescriptor | null {
if (!(target instanceof Element)) return null;
const clickable = resolveClickableElement(target);
if (!clickable) return null;
const desc = describeTarget(clickable);
return isMeaningfulClickTarget(desc) ? desc : null;
}
+111
View File
@@ -0,0 +1,111 @@
/**
* Wire protocol for user-action recording, sent between the background
* service worker and a tab's content script.
*/
import type { TargetDescriptor } from "./describe-target";
export const RECORD_START = "bsk-record-start";
export const RECORD_STEP = "bsk-record-step";
export const RECORD_STOP = "bsk-record-stop";
export const RECORD_CANCEL = "bsk-record-cancel";
export const RECORD_FINISH = "bsk-record-finish";
export const RECORD_QUERY = "bsk-record-query";
export interface RecordStartAck {
ok: true;
}
export type RecordStopAck = { ok: true } | { ok: false; error: string };
export interface RecordQueryMessage {
type: typeof RECORD_QUERY;
}
export interface RecordQueryResponse {
active: boolean;
requestId?: string;
}
export interface RecordStartMessage {
type: typeof RECORD_START;
requestId: string;
}
export interface RecordStepPayload {
op: "click" | "fill" | "press" | "select" | "navigate";
target?: TargetDescriptor;
value?: string;
key?: string;
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">;
values?: string[];
labels?: string[];
url?: string;
redacted?: boolean;
/** Page URL when the step was captured. */
page_url?: string;
/** Capture-only hint; never persisted unless converted to navigated_to. */
expects_navigation?: boolean;
/** Whether an observed URL change was synchronously caused by the action. */
navigation_caused_by_action?: boolean;
}
export interface RecordStepMessage {
type: typeof RECORD_STEP;
requestId: string;
step: RecordStepPayload;
}
export interface RecordStopMessage {
type: typeof RECORD_STOP;
requestId: string;
}
export interface RecordCancelMessage {
type: typeof RECORD_CANCEL;
requestId: string;
}
export interface RecordFinishMessage {
type: typeof RECORD_FINISH;
requestId: string;
}
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_START && typeof m.requestId === "string";
}
export function isRecordStopMessage(msg: unknown): msg is RecordStopMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_STOP && typeof m.requestId === "string";
}
export function isRecordCancelMessage(msg: unknown): msg is RecordCancelMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_CANCEL && typeof m.requestId === "string";
}
export function isRecordFinishMessage(msg: unknown): msg is RecordFinishMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_FINISH && typeof m.requestId === "string";
}
export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return m.type === RECORD_QUERY;
}
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
if (m.type !== RECORD_STEP || typeof m.requestId !== "string") return false;
const step = m.step;
if (typeof step !== "object" || step === null) return false;
return typeof (step as RecordStepPayload).op === "string";
}
@@ -0,0 +1,128 @@
import type { DraftTraceStep } from "@/transport/types";
import type { RecordStepPayload } from "./record-bridge";
export interface RecordingStepBuffer {
steps: DraftTraceStep[];
currentUrl?: string;
pendingNavigation: boolean;
pendingNavigationDeadline?: number;
}
const NAVIGATION_TRIGGER_WINDOW_MS = 3_000;
function toDraftStep(payload: RecordStepPayload): DraftTraceStep | null {
const pageUrl = payload.page_url;
switch (payload.op) {
case "click":
return payload.target
? {
op: "click",
target: payload.target,
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "fill":
return payload.target
? {
op: "fill",
target: payload.target,
value: payload.value ?? "",
...(payload.redacted ? { redacted: true } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "press":
return payload.key
? {
op: "press",
key: payload.key,
...(payload.target ? { target: payload.target } : {}),
...(payload.modifiers?.length ? { modifiers: payload.modifiers } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "select":
return payload.target && payload.values
? {
op: "select",
target: payload.target,
values: payload.values,
...(payload.labels?.length ? { labels: payload.labels } : {}),
...(pageUrl ? { page_url: pageUrl } : {}),
}
: null;
case "navigate":
return null;
}
}
function annotateLastStepNavigation(buffer: RecordingStepBuffer, url: string): boolean {
for (let i = buffer.steps.length - 1; i >= 0; i -= 1) {
const step = buffer.steps[i];
if (!step) continue;
if (step.op === "click" || step.op === "press" || step.op === "select") {
buffer.steps[i] = { ...step, navigated_to: url };
return true;
}
break;
}
return false;
}
export function observeRecordedNavigation(
buffer: RecordingStepBuffer,
url: string,
causedByAction?: boolean,
): void {
if (!url || url === buffer.currentUrl) return;
buffer.currentUrl = url;
const pendingIsCurrent =
buffer.pendingNavigation &&
(buffer.pendingNavigationDeadline === undefined ||
buffer.pendingNavigationDeadline >= Date.now());
if (causedByAction === true || (causedByAction === undefined && pendingIsCurrent)) {
buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined;
if (!annotateLastStepNavigation(buffer, url)) {
buffer.steps.push({
op: "navigate",
url,
page_url: url,
});
}
return;
}
buffer.pendingNavigation = false;
buffer.pendingNavigationDeadline = undefined;
buffer.steps.push({
op: "navigate",
url,
page_url: url,
});
}
export function appendRecordedPayload(
buffer: RecordingStepBuffer,
payload: RecordStepPayload,
): void {
if (payload.op === "navigate") {
if (payload.url) {
observeRecordedNavigation(buffer, payload.url, payload.navigation_caused_by_action);
}
return;
}
const step = toDraftStep({
...payload,
page_url: payload.page_url ?? buffer.currentUrl,
});
if (!step) return;
buffer.steps.push(step);
if (step.op === "click" || step.op === "press" || step.op === "select" || step.op === "fill") {
buffer.pendingNavigation = payload.expects_navigation === true;
buffer.pendingNavigationDeadline = buffer.pendingNavigation
? Date.now() + NAVIGATION_TRIGGER_WINDOW_MS
: undefined;
}
}
+218
View File
@@ -0,0 +1,218 @@
import type { DraftTraceStep, PageRef, SelectedOption, Step } from "@/transport/types";
const CLIPBOARD_KEYS = new Set(["a", "c", "v", "x", "A", "C", "V", "X"]);
const MODIFIER_ONLY_KEYS = new Set(["Meta", "Control", "Alt", "Shift", "OS", "Hyper", "Super"]);
export function shouldRecordPress(
key: string,
modifiers?: Array<"alt" | "ctrl" | "meta" | "shift">,
): boolean {
if (MODIFIER_ONLY_KEYS.has(key)) return false;
const mods = modifiers ?? [];
const hasCtrlOrMeta = mods.includes("ctrl") || mods.includes("meta");
if (hasCtrlOrMeta && CLIPBOARD_KEYS.has(key)) return false;
if (key === "Enter" || key === "Escape") return true;
// Drop bare character typing — FillSession already records the value.
if (key.length === 1 && !hasCtrlOrMeta && !mods.includes("alt")) return false;
return false;
}
function shouldIncludeDraft(step: DraftTraceStep): boolean {
if (step.op === "fill" && !(step.value ?? "").trim() && !step.redacted) return false;
if (step.op === "press" && !shouldRecordPress(step.key, step.modifiers)) return false;
return true;
}
/** Collapse consecutive navigations to the last hop. */
function collapseNavigations(steps: DraftTraceStep[]): DraftTraceStep[] {
const out: DraftTraceStep[] = [];
for (const step of steps) {
const prev = out[out.length - 1];
if (step.op === "navigate" && prev?.op === "navigate") {
out[out.length - 1] = step;
continue;
}
out.push(step);
}
return out;
}
function collectUrls(steps: DraftTraceStep[], startUrl?: string): string[] {
const urls: string[] = [];
if (startUrl) urls.push(startUrl);
for (const step of steps) {
if (step.op === "navigate") {
urls.push(step.url);
continue;
}
if ("page_url" in step && step.page_url) urls.push(step.page_url);
if ("navigated_to" in step && step.navigated_to) urls.push(step.navigated_to);
}
const seen = new Set<string>();
const unique: string[] = [];
for (const url of urls) {
if (!url || seen.has(url)) continue;
seen.add(url);
unique.push(url);
}
return unique;
}
function buildPageRegistry(
steps: DraftTraceStep[],
startUrl?: string,
): { pages: PageRef[]; urlToId: Map<string, string> } {
const urls = collectUrls(steps, startUrl);
const urlToId = new Map<string, string>();
const pages = urls.map((url, index) => {
const id = `p${index + 1}`;
urlToId.set(url, id);
return { id, url };
});
return { pages, urlToId };
}
function pageIdFor(
url: string | undefined,
urlToId: Map<string, string>,
fallbackUrl?: string,
): string {
if (url && urlToId.has(url)) return urlToId.get(url)!;
if (fallbackUrl && urlToId.has(fallbackUrl)) return urlToId.get(fallbackUrl)!;
return urlToId.values().next().value ?? "p1";
}
function pageUrlForDraft(step: DraftTraceStep, fallbackUrl?: string): string | undefined {
if (step.op === "navigate") return step.page_url ?? step.url;
if ("page_url" in step && step.page_url) return step.page_url;
return fallbackUrl;
}
function effectForNavigation(
navigatedTo: string | undefined,
urlToId: Map<string, string>,
): Step["effect"] {
if (!navigatedTo) return undefined;
const pageId = urlToId.get(navigatedTo);
if (!pageId) return undefined;
return { navigated_to: pageId };
}
function withEffect(step: Step, effect: Step["effect"]): Step {
if (!effect) return step;
return { ...step, effect };
}
function toSelection(values: string[], labels?: string[]): SelectedOption[] {
return values.map((value, index) => ({
value,
...(labels?.[index] ? { label: labels[index] } : {}),
}));
}
function toV2Step(
step: DraftTraceStep,
id: number,
urlToId: Map<string, string>,
fallbackUrl?: string,
): Step | null {
if (!shouldIncludeDraft(step)) return null;
const pageUrl = pageUrlForDraft(step, fallbackUrl);
const page = pageIdFor(pageUrl, urlToId, fallbackUrl);
switch (step.op) {
case "navigate":
return {
op: "navigate",
id,
page: pageIdFor(step.url, urlToId, fallbackUrl),
to: step.url,
};
case "click":
return withEffect(
{
op: "click",
id,
page,
target: step.target,
},
effectForNavigation(step.navigated_to, urlToId),
);
case "fill":
return {
op: "fill",
id,
page,
target: step.target,
value: step.value,
...(step.redacted ? { redacted: true } : {}),
};
case "press":
return withEffect(
{
op: "press",
id,
page,
key: step.key,
...(step.target ? { target: step.target } : {}),
...(step.modifiers?.length ? { modifiers: step.modifiers } : {}),
},
effectForNavigation(step.navigated_to, urlToId),
);
case "select":
return withEffect(
{
op: "select",
id,
page,
target: step.target,
selection: toSelection(step.values, step.labels),
},
effectForNavigation(step.navigated_to, urlToId),
);
}
}
export interface ReducedTrace {
pages: PageRef[];
steps: Step[];
}
/**
* Compile capture drafts into record-only trace v2 steps.
* Variable inputs are NOT classified here — executing agents infer that at run time.
*/
export function reduceTraceSteps(steps: DraftTraceStep[], startUrl?: string): ReducedTrace {
const collapsed = collapseNavigations(steps);
const { pages, urlToId } = buildPageRegistry(collapsed, startUrl);
const out: Step[] = [];
let id = 1;
let lastUrl = startUrl;
for (const draft of collapsed) {
if (draft.op === "navigate") lastUrl = draft.url;
else if ("navigated_to" in draft && draft.navigated_to) lastUrl = draft.navigated_to;
else if ("page_url" in draft && draft.page_url) lastUrl = draft.page_url;
const step = toV2Step(draft, id, urlToId, lastUrl);
if (!step) continue;
out.push(step);
id += 1;
}
return { pages, steps: out };
}
export function resolveTraceStartUrl(
drafts: DraftTraceStep[],
startUrl?: string,
pages?: PageRef[],
): string {
if (startUrl) return startUrl;
const navigate = drafts.find((step): step is Extract<DraftTraceStep, { op: "navigate" }> => {
return step.op === "navigate";
});
if (navigate) return navigate.url;
for (const draft of drafts) {
if ("page_url" in draft && draft.page_url) return draft.page_url;
}
return pages?.[0]?.url ?? "about:blank";
}
@@ -162,6 +162,22 @@ describe("requestBorrowConfirmation", () => {
expect(await pending).toBe(false);
});
it("denies malformed or missing confirmation responses", async () => {
tabs.get.mockResolvedValue({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValue(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
for (const response of [undefined, {}, { allowed: true }, { type: "borrow-response" }]) {
tabs.sendMessage.mockResolvedValueOnce(response);
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.runAllTimersAsync();
expect(await pending).toBe(false);
}
});
it("skips the Agent Window when it is lastFocusedWindow and falls back to a real user window", async () => {
tabs.get.mockResolvedValueOnce({
id: 42,
@@ -250,7 +266,7 @@ describe("requestBorrowConfirmation", () => {
expect(tabs.sendMessage.mock.calls[1]?.[0]).toBe(301);
});
it("does NOT fail-open immediately when every candidate's sendMessage fails — waits for notification button or timeout", async () => {
it("waits for a notification decision and denies when every UI path times out", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
@@ -282,13 +298,13 @@ describe("requestBorrowConfirmation", () => {
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
// CRITICAL: previously this would already be settled(true). Now we
// require an explicit user choice (notification button) or timeout.
// require an explicit user choice (notification button) or safe timeout.
expect(resolved).toBe(false);
// Allow the BACKGROUND_TIMEOUT_MS fail-open to fire so the test resolves.
// The background timeout must resolve the request without authorizing it.
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(true);
expect(resolvedValue).toBe(true);
expect(await pending).toBe(false);
expect(resolvedValue).toBe(false);
const exhaustedWarn = warnSpy.mock.calls.find(
(call) =>
@@ -375,7 +391,7 @@ describe("requestBorrowConfirmation", () => {
expect(await pending).toBe(false);
});
it("fail-opens when no injectable user window exists at all", async () => {
it("denies when no injectable user window exists at all", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Stranded Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 500, tabId: 5001, url: "about:blank" }),
@@ -394,7 +410,7 @@ describe("requestBorrowConfirmation", () => {
});
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(await pending).toBe(false);
expect(tabs.sendMessage).not.toHaveBeenCalled();
// No notification either — there's no actionable target window.
expect(notifications.create).not.toHaveBeenCalled();
@@ -421,6 +437,7 @@ describe("requestBorrowConfirmation", () => {
expect(notificationId.startsWith(BORROW_NOTIFICATION_PREFIX)).toBe(true);
expect(options.type).toBe("basic");
expect(options.title).toMatch(/borrow/i);
expect(options.requireInteraction).toBe(true);
expect(notifications.clear).toHaveBeenCalledWith(notificationId);
});
@@ -447,10 +464,12 @@ describe("requestBorrowConfirmation", () => {
userWindowWithActiveTab({ windowId: 77, tabId: 4242, url: "https://docs.example/" }),
);
// Keep sendMessage pending so the request stays live while we click.
// A second call may arrive if the background timeout dismisses the overlay.
let resolveSend: (v: { type: string; allowed: boolean }) => void = () => undefined;
tabs.sendMessage.mockImplementationOnce(
() => new Promise((res) => (resolveSend = res as never)),
);
tabs.sendMessage.mockResolvedValue(undefined);
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
@@ -460,7 +479,7 @@ describe("requestBorrowConfirmation", () => {
// Simulate user clicking the OS notification.
for (const l of listeners) l(notificationId);
await vi.runAllTimersAsync();
await Promise.resolve();
expect(windows.update).toHaveBeenCalledWith(77, { focused: true });
// Finish the borrow so the test resolves cleanly.
@@ -469,21 +488,29 @@ describe("requestBorrowConfirmation", () => {
await pending;
});
it("fail-opens after background timeout when content script never responds", async () => {
it("denies after background timeout when content script never responds", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Tab" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 42, url: "https://app.example/" }),
);
tabs.sendMessage.mockImplementationOnce(() => new Promise(() => undefined));
tabs.sendMessage.mockResolvedValueOnce(undefined);
const pending = requestBorrowConfirmation(42, {
deps: { tabs, windows, notifications },
});
await vi.advanceTimersByTimeAsync(BACKGROUND_TIMEOUT_MS);
expect(await pending).toBe(true);
expect(await pending).toBe(false);
// Timeout must dismiss any in-flight overlay so Allow cannot linger.
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
type: string;
requestId: string;
};
expect(cancelMessage.type).toBe("borrow-cancel");
});
it("dismisses pending overlay and fail-opens when aborted", async () => {
it("dismisses the pending overlay and denies when aborted", async () => {
tabs.get.mockResolvedValueOnce({ id: 42, title: "Borrow Target" });
windows.getLastFocused.mockResolvedValueOnce(
userWindowWithActiveTab({ windowId: 11, tabId: 7, url: "https://app.example/" }),
@@ -500,7 +527,7 @@ describe("requestBorrowConfirmation", () => {
controller.abort();
await vi.runAllTimersAsync();
expect(await pending).toBe(true);
expect(await pending).toBe(false);
expect(tabs.sendMessage).toHaveBeenCalledTimes(2);
const requestMessage = tabs.sendMessage.mock.calls[0][1] as { requestId: string };
const cancelMessage = tabs.sendMessage.mock.calls[1][1] as {
@@ -72,7 +72,7 @@ describe("handleConsole", () => {
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000, false);
});
it("reads an explicit tab and forwards bounded options", async () => {
it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
@@ -85,27 +85,15 @@ describe("handleConsole", () => {
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 500,
max_text_chars: 9999,
limit: 75,
max_text_chars: 1500,
include_stack: true,
},
deps,
);
expect(deps.ensureConsoleCapture).toHaveBeenCalledWith(9);
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 200, 4096, true);
});
it("rejects invalid bounds before touching CDP", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps();
const res = await handleConsole(sm, { session_id: "aa11", limit: 0 }, deps);
expect(res).toMatchObject({ code: "invalid_params", message: /limit/ });
expect(deps.ensureConsoleCapture).not.toHaveBeenCalled();
expect(deps.consoleEntriesSince).not.toHaveBeenCalled();
expect(deps.consoleEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500, true);
});
it("hides other sessions' Agent Window tabs", async () => {
@@ -106,6 +106,13 @@ describe("ToolDispatcher", () => {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureConsoleCapture: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
consoleEntriesSince: vi.fn(
() =>
({
@@ -149,6 +156,13 @@ describe("ToolDispatcher", () => {
const cdp = {
send: vi.fn(),
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp });
dispatcher.start();
@@ -284,6 +284,9 @@ describe("handleEvaluate", () => {
{ session_id: "aa11", expression: "1+1", tab_id: 11 },
{ cdp: fake.cdp, tabsApi: fake.tabsApi },
);
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
});
});
@@ -36,13 +36,21 @@ function baseDeps(over: Partial<RequestHelpDeps> = {}): RequestHelpDeps {
describe("handleRequestHelp", () => {
it("rejects unknown session", async () => {
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ session_id: "zzzz" }), baseDeps());
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ session_id: "zzzz" }),
baseDeps(),
);
expect("code" in res && res.code).toBe("not_found");
});
it("brings the tab to the foreground and returns the user outcome", async () => {
const deps = baseDeps();
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5 }),
deps,
);
expect(deps.windows.update).toHaveBeenCalledWith(99, { focused: true });
expect(deps.activateTab).toHaveBeenCalledWith(5);
expect(res).toMatchObject({ outcome: "continued", note: "ok", tab_id: 5 });
@@ -79,7 +87,11 @@ describe("handleRequestHelp", () => {
return unwatch;
}),
});
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5 }),
deps,
);
expect(res).toMatchObject({ outcome: "navigated", tab_id: 5 });
expect(unwatch).toHaveBeenCalled();
});
@@ -107,12 +119,19 @@ describe("handleRequestHelp", () => {
const mgr = {
get: (id: string) =>
id === "abcd"
? { sessionId: "abcd", agentWindowId: 99, refStore: { resolve: () => 42 }, borrowedTabs: new Map() }
? {
sessionId: "abcd",
agentWindowId: 99,
refStore: { resolve: () => 42 },
borrowedTabs: new Map(),
}
: null,
findByWindowId: (wid: number) => (wid === 99 ? { sessionId: "abcd" } : null),
} as unknown as SessionManager;
const deps = baseDeps({
cdp: { send: vi.fn(async () => ({ object: { objectId: "obj-1" } })) } as unknown as RequestHelpDeps["cdp"],
cdp: {
send: vi.fn(async () => ({ object: { objectId: "obj-1" } })),
} as unknown as RequestHelpDeps["cdp"],
});
const res = await handleRequestHelp(
mgr,
@@ -177,7 +196,11 @@ describe("handleRequestHelp", () => {
throw new Error("no receiver");
}),
});
const res = await handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5 }), deps);
const res = await handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5 }),
deps,
);
expect("code" in res && res.code).toBe("protocol_error");
});
@@ -254,7 +277,11 @@ describe("handleRequestHelp", () => {
it("returns cancelled when the signal aborts", async () => {
const ac = new AbortController();
const deps = baseDeps({ signal: ac.signal, sendToTab: vi.fn(() => new Promise(() => {})) });
const p = handleRequestHelp(fakeManager("abcd", 99, 5), baseParams({ tab_id: 5, timeout_ms: 60_000 }), deps);
const p = handleRequestHelp(
fakeManager("abcd", 99, 5),
baseParams({ tab_id: 5, timeout_ms: 60_000 }),
deps,
);
ac.abort();
const res = await p;
expect("code" in res ? res.code : (res as { outcome: string }).outcome).toMatch(/cancelled/);
@@ -982,4 +982,3 @@ describe("handleSelect", () => {
expect(fake.sent.some((c) => c.method === "Runtime.callFunctionOn")).toBe(false);
});
});
@@ -2,12 +2,12 @@ import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { CdpRunner } from "@/tools/shared";
import {
eventLoaderIsRelevant,
handleNavigate,
handleNavigateBack,
handleNavigateForward,
handleReload,
lifecycleAlreadyReached,
eventLoaderIsRelevant,
lifecycleMeetsOrExceeds,
shouldTrustReadyStateProbe,
} from "../navigation";
@@ -60,10 +60,7 @@ function makeFakeCdp(opts?: {
};
const events: EventListener[] = [];
const sent: Array<{ tabId: number; method: string; params?: object }> = [];
const methodHandlers: Record<
string,
(tabId: number, params?: object) => object
> = {
const methodHandlers: Record<string, (tabId: number, params?: object) => object> = {
"Page.enable": () => ({}),
"Page.setLifecycleEventsEnabled": () => ({}),
"Page.navigate": () => {
@@ -72,9 +69,7 @@ function makeFakeCdp(opts?: {
name: opts.fireLifecycleDuringNavigate,
frameId: opts.fireLifecycleDuringNavigateFrameId ?? opts.navigateFrameId ?? "frame-1",
loaderId:
opts.fireLifecycleDuringNavigateLoaderId ??
opts.navigateLoaderId ??
"loader-after",
opts.fireLifecycleDuringNavigateLoaderId ?? opts.navigateLoaderId ?? "loader-after",
};
for (const listener of [...events]) {
listener({ tabId: 4 }, "Page.lifecycleEvent", payload);
@@ -208,9 +203,7 @@ describe("lifecycleMeetsOrExceeds", () => {
describe("eventLoaderIsRelevant", () => {
it("rejects events from the pre-navigation loader", () => {
expect(
eventLoaderIsRelevant("loader-before", { beforeLoaderId: "loader-before" }),
).toBe(false);
expect(eventLoaderIsRelevant("loader-before", { beforeLoaderId: "loader-before" })).toBe(false);
expect(
eventLoaderIsRelevant("loader-after", {
beforeLoaderId: "loader-before",
@@ -0,0 +1,128 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import type { NetworkResult } from "@/transport/types";
import { handleNetwork, type NetworkCdpRunner } from "../network";
function fakeAgentWindow(ids: number[]) {
let i = 0;
return {
create: vi.fn(async () => {
const id = ids[i++];
if (id === undefined) throw new Error("ran out of fake ids");
return id;
}),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
};
}
function makeDeps(
opts: {
get?: (tabId: number) => Promise<chrome.tabs.Tab>;
query?: (query: chrome.tabs.QueryInfo) => Promise<chrome.tabs.Tab[]>;
result?: NetworkResult;
captureError?: Error;
} = {},
) {
const result =
opts.result ??
({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
} satisfies NetworkResult);
const ensureNetworkCapture = vi.fn(async () => {
if (opts.captureError) throw opts.captureError;
});
const networkEntriesSince = vi.fn(() => result);
const trackSessionTab = vi.fn();
const cdp = {
trackSessionTab,
ensureNetworkCapture,
networkEntriesSince,
} satisfies NetworkCdpRunner;
const tabsApi = {
get:
opts.get ??
vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query:
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]),
};
return { cdp, tabsApi, ensureNetworkCapture, networkEntriesSince, trackSessionTab };
}
describe("handleNetwork", () => {
it("reads the Agent Window active tab with safe defaults", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
result: {
tab_id: 7,
entries: [
{ sequence: 3, kind: "response", url: "https://x/api", status: 404, truncated: false },
],
next_since: 3,
truncated: false,
},
});
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(res.entries[0].status).toBe(404);
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(7);
expect(deps.networkEntriesSince).toHaveBeenCalledWith(7, undefined, 50, 1000);
});
it("reads an explicit tab and forwards cursor options", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 200, active: true }) as chrome.tabs.Tab),
});
await handleNetwork(
sm,
{
session_id: "aa11",
tab_id: 9,
since: 12,
limit: 75,
max_text_chars: 1500,
},
deps,
);
expect(deps.ensureNetworkCapture).toHaveBeenCalledWith(9);
expect(deps.networkEntriesSince).toHaveBeenCalledWith(9, 12, 75, 1500);
});
it("surfaces network capture failures as cdp_failed", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100]) });
await sm.start("aa11");
const deps = makeDeps({ captureError: new Error("Network.enable denied") });
const res = await handleNetwork(sm, { session_id: "aa11" }, deps);
expect(res).toEqual({ code: "cdp_failed", message: "Network.enable denied" });
expect(deps.trackSessionTab).toHaveBeenCalledWith("aa11", 7);
expect(deps.networkEntriesSince).not.toHaveBeenCalled();
});
it("hides other sessions' Agent Window tabs", async () => {
const sm = new SessionManager({ agentWindow: fakeAgentWindow([100, 101]) });
await sm.start("aa11");
await sm.start("bb22");
const deps = makeDeps({
get: vi.fn(async () => ({ id: 9, windowId: 101, active: true }) as chrome.tabs.Tab),
});
const res = await handleNetwork(sm, { session_id: "aa11", tab_id: 9 }, deps);
expect(res).toMatchObject({ code: "not_found" });
expect(deps.ensureNetworkCapture).not.toHaveBeenCalled();
});
});
@@ -42,8 +42,7 @@ function makeScreenshotDeps(
opts.get ??
vi.fn(async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab);
const query =
opts.query ??
vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]);
opts.query ?? vi.fn(async () => [{ id: 7, windowId: 100, active: true } as chrome.tabs.Tab]);
const captureVisibleTab =
opts.captureVisibleTab ?? vi.fn(async () => `data:image/png;base64,${TINY_PNG}`);
const tabsApi = { get, query };
@@ -118,7 +117,11 @@ describe("handleScreenshot", () => {
const res = await handleScreenshot(
sm,
{ session_id: "aa11" },
makeScreenshotDeps({ captureVisibleTab: vi.fn(), get: emptyGet, query: vi.fn(async () => []) }),
makeScreenshotDeps({
captureVisibleTab: vi.fn(),
get: emptyGet,
query: vi.fn(async () => []),
}),
);
expect(res).toMatchObject({ code: "not_found" });
});
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
ensureBrowserObservationListeners,
isBrowserObservationAttachedForTests,
RECORD_DEFAULT_START_URL,
releaseBrowserObservationListenersIfIdle,
resetBrowserObservationForTests,
setBrowserObservationAttachForTests,
} from "../record";
describe("record defaults", () => {
it("uses example.com as the default injectable start URL", () => {
expect(RECORD_DEFAULT_START_URL).toBe("https://example.com/");
});
});
describe("browser observation lifecycle", () => {
afterEach(() => {
resetBrowserObservationForTests();
});
it("does not attach listeners until ensure is called", () => {
const tab = vi.fn(() => () => undefined);
const nav = vi.fn(() => () => undefined);
setBrowserObservationAttachForTests(tab, nav);
expect(isBrowserObservationAttachedForTests()).toBe(false);
expect(tab).not.toHaveBeenCalled();
expect(nav).not.toHaveBeenCalled();
});
it("attaches once on ensure and detaches when idle", () => {
const detachTab = vi.fn();
const detachNav = vi.fn();
const tab = vi.fn(() => detachTab);
const nav = vi.fn(() => detachNav);
setBrowserObservationAttachForTests(tab, nav);
ensureBrowserObservationListeners({
tabsApi: { get: vi.fn(), query: vi.fn(), create: vi.fn() } as never,
sendToTab: vi.fn(),
});
expect(isBrowserObservationAttachedForTests()).toBe(true);
expect(tab).toHaveBeenCalledTimes(1);
expect(nav).toHaveBeenCalledTimes(1);
// Idempotent.
ensureBrowserObservationListeners({
tabsApi: { get: vi.fn(), query: vi.fn(), create: vi.fn() } as never,
sendToTab: vi.fn(),
});
expect(tab).toHaveBeenCalledTimes(1);
releaseBrowserObservationListenersIfIdle();
expect(isBrowserObservationAttachedForTests()).toBe(false);
expect(detachTab).toHaveBeenCalledTimes(1);
expect(detachNav).toHaveBeenCalledTimes(1);
});
});
@@ -139,9 +139,13 @@ describe("handleSessionStop with auto-return", () => {
resetAgentOverlays: vi.fn(async () => {}),
} satisfies AgentOverlayResetApi;
const res = await handleSessionStop(sm, { session_id: "aa11" }, {
tabManagement: { tabs, windows, agentOverlayReset },
});
const res = await handleSessionStop(
sm,
{ session_id: "aa11" },
{
tabManagement: { tabs, windows, agentOverlayReset },
},
);
if ("code" in res) throw new Error(`unexpected error: ${JSON.stringify(res)}`);
expect(agentOverlayReset.resetAgentOverlays).toHaveBeenCalledWith(7, "aa11");
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { lookupSession } from "../shared";
import { lookupSession, parseBufferedReadBounds } from "../shared";
function fakeAgentWindow() {
return {
@@ -20,9 +20,7 @@ describe("lookupSession", () => {
expect(lookupSession(sm, { session_id: "" }, "tool.test")).toMatchObject({
code: "invalid_params",
});
expect(
lookupSession(sm, { session_id: 42 as unknown as string }, "tool.test"),
).toMatchObject({
expect(lookupSession(sm, { session_id: 42 as unknown as string }, "tool.test")).toMatchObject({
code: "invalid_params",
});
});
@@ -42,3 +40,30 @@ describe("lookupSession", () => {
expect(result).toBe(ctx);
});
});
describe("parseBufferedReadBounds", () => {
it("applies shared defaults and caps", () => {
expect(parseBufferedReadBounds({})).toEqual({
since: undefined,
limit: 50,
maxTextChars: 1000,
});
expect(parseBufferedReadBounds({ since: 12, limit: 500, max_text_chars: 9999 })).toEqual({
since: 12,
limit: 200,
maxTextChars: 4096,
});
});
it.each([
[{ since: -1 }, "since"],
[{ since: 1.5 }, "since"],
[{ limit: 0 }, "limit"],
[{ max_text_chars: 0 }, "max_text_chars"],
])("rejects invalid bounds in %o", (params, field) => {
expect(parseBufferedReadBounds(params)).toMatchObject({
code: "invalid_params",
message: expect.stringContaining(field),
});
});
});
@@ -292,7 +292,10 @@ describe("handleTabClose", () => {
};
const { api } = makeTabMutationApi(state);
const res = await handleTabClose(sm, { session_id: "aa11", tab_id: 9 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
});
it("rejects when another session has borrowed the tab", async () => {
@@ -338,7 +341,10 @@ describe("handleTabSelect", () => {
};
const { api } = makeTabMutationApi(state);
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
});
it("rejects a borrowed tab that is no longer in the Agent Window", async () => {
@@ -352,7 +358,10 @@ describe("handleTabSelect", () => {
};
const { api } = makeTabMutationApi(state);
const res = await handleTabSelect(sm, { session_id: "aa11", tab_id: 3 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
});
});
@@ -444,7 +453,10 @@ describe("handleTabBorrow", () => {
};
const { api } = makeTabMutationApi(state);
const res = await handleTabBorrow(sm, { session_id: "aa11", tab_id: 7 }, { tabs: api });
expect(res).toMatchObject({ code: "permission_denied", data: { reason: "agent_window_scope" } });
expect(res).toMatchObject({
code: "permission_denied",
data: { reason: "agent_window_scope" },
});
});
it("returns cancelled when the approver declines", async () => {
+46 -51
View File
@@ -28,8 +28,9 @@ export const CONFIRMATION_TIMEOUT_MS = 5000;
const EXIT_ANIMATION_MS = 150;
/** Matches BorrowConfirmationOverlay progress ring/bar transition (duration-1000). */
export const PROGRESS_TRANSITION_MS = 1000;
// UI auto-allows after countdown + progress transition + exit fade. Background
// timeout must not fire before that chain completes or deny is ignored.
// UI auto-denies after countdown + progress transition + exit fade. Background
// timeout must not fire before that chain completes or the UI decision races
// with an already-settled deny.
export const BACKGROUND_TIMEOUT_MS =
CONFIRMATION_TIMEOUT_MS + PROGRESS_TRANSITION_MS + EXIT_ANIMATION_MS + 500;
@@ -114,9 +115,8 @@ const pendingBorrowNotifications = new Map<string, PendingBorrowNotification>();
*
* This is the *fallback* authorization path that fires when no candidate
* user window could host the in-page overlay (every `chrome.tabs.sendMessage`
* was rejected because the content script was not present). Without it the
* background-task fail-open at the end of `tryCandidate` would silently
* allow the borrow without any user-visible authorization step.
* was rejected because the content script was not present). It preserves an
* explicit authorization path without weakening the fail-closed timeout.
*/
const pendingBorrowDecisions = new Map<string, (allowed: boolean) => void>();
@@ -170,7 +170,7 @@ export function attachBorrowNotificationClickHandler(deps: {
* Allow / Deny buttons on the OS notification can resolve the matching
* pending `requestBorrowConfirmation`. This is the explicit-authorization
* fallback path used when every candidate user window's content script was
* missing — without it we'd be back to silently fail-opening the borrow.
* missing.
*
* Button index convention (matches `BorrowNotificationCopy`):
* - 0 → Allow → `resolve(true)`
@@ -326,8 +326,7 @@ async function listConfirmationCandidates(
if (typeof w.id !== "number") continue;
// Never bounce the overlay back into a session's Agent Window — those
// pages can't host a content script when they're on the about:blank
// bootstrap URL, so sendMessage would fail-open and silently allow the
// borrow with no UI shown.
// bootstrap URL, so they cannot surface an authorization decision.
if (isAgentWindowId(w.id)) continue;
const activeTab = w.tabs?.find((t) => t.active === true) ?? null;
if (!activeTab || typeof activeTab.id !== "number") continue;
@@ -360,16 +359,14 @@ async function listConfirmationCandidates(
* *explicit-authorization fallback* when every candidate fails sendMessage:
* we no longer silently allow the borrow in that case, the promise stays
* pending until the user clicks a notification button (resolves true/false)
* or `BACKGROUND_TIMEOUT_MS` fires (last-resort fail-open so the agent
* doesn't hang forever when even the notification path is broken).
* or `BACKGROUND_TIMEOUT_MS` fires and safely denies the request.
*
* Clicking the notification body (vs. its buttons) focuses the chosen user
* window so the overlay becomes visible — the previous behaviour.
*
* Returns `true` on user-allow (overlay or notification) OR last-resort
* fail-open (no candidate window at all; abort signal; timeout). Returns
* `false` only on explicit user-deny (overlay button or notification Deny
* button).
* Returns `true` only on an explicit user allow from the overlay or
* notification. Missing UI, aborts, malformed responses, and timeouts all
* fail closed and return `false`.
*/
export async function requestBorrowConfirmation(
tabId: number,
@@ -385,12 +382,9 @@ export async function requestBorrowConfirmation(
// The borrow pipeline already fetched this tab moments earlier
// (validateBorrowTarget in tabs.ts), so a failure here is a transient
// SW/page hiccup, not a missing tab. Do NOT fail-open: silently allowing
// a borrow with no confirmation UI contradicts the gate's purpose and is
// not one of the documented fail-open cases (no candidate window; abort;
// timeout — see this function's doc comment). Fall back to a generic
// title and still surface the overlay + notification so the user gets a
// real chance to approve or deny.
// SW/page hiccup, not a missing tab. Fall back to a generic title and still
// surface the overlay + notification so the user gets a real chance to
// approve or deny.
let tabTitle: string;
try {
const tab = await tabsApi.get(tabId);
@@ -405,10 +399,10 @@ export async function requestBorrowConfirmation(
const candidates = await listConfirmationCandidates(windowsApi, isAgentWindowId);
if (candidates.length === 0) {
console.warn("[bsk borrow] no injectable user window available — proceeding without overlay", {
console.warn("[bsk borrow] no injectable user window available — denying borrow", {
tabId,
});
return true;
return false;
}
const requestId = createBorrowRequestId(tabId);
@@ -419,7 +413,7 @@ export async function requestBorrowConfirmation(
// overlay immediately.
const notificationAnchor = candidates[0];
if (!notificationAnchor) {
return true;
return false;
}
return new Promise<boolean>((resolve) => {
@@ -448,22 +442,25 @@ export async function requestBorrowConfirmation(
resolve(allowed);
};
const onAbort = () => {
if (activeMessageTabId !== null) {
const cancelMessage: BorrowCancelMessage = {
type: "borrow-cancel",
const dismissPendingOverlay = () => {
if (activeMessageTabId === null) return;
const cancelMessage: BorrowCancelMessage = {
type: "borrow-cancel",
requestId,
};
void Promise.resolve(tabsApi.sendMessage(activeMessageTabId, cancelMessage)).catch((err) => {
console.debug("[bsk borrow] cancel message failed", {
tabId,
messageTabId: activeMessageTabId,
requestId,
};
void tabsApi.sendMessage(activeMessageTabId, cancelMessage).catch((err) => {
console.debug("[bsk borrow] cancel message failed", {
tabId,
messageTabId: activeMessageTabId,
requestId,
error: err instanceof Error ? err.message : String(err),
});
error: err instanceof Error ? err.message : String(err),
});
}
settle(true);
});
};
const onAbort = () => {
dismissPendingOverlay();
settle(false);
};
if (signal?.aborted) {
@@ -473,19 +470,18 @@ export async function requestBorrowConfirmation(
signal?.addEventListener("abort", onAbort, { once: true });
timeout = setTimeout(() => {
console.info("[bsk borrow] confirmation timed out — proceeding", {
console.info("[bsk borrow] confirmation timed out — denying borrow", {
tabId,
messageTabId: activeMessageTabId,
});
settle(true);
dismissPendingOverlay();
settle(false);
}, BACKGROUND_TIMEOUT_MS);
// Surface the OS notification *before* messaging any candidate so the
// user has a parallel signal even if every content script is missing.
// The Allow / Deny buttons let the user authorize explicitly when the
// in-page overlay never reached them — without those buttons we'd have
// to fail-open after `tryCandidate` exhausts its candidates, which is
// exactly the silent-allow bug we're fixing here.
// in-page overlay never reached them.
if (notificationsApi) {
pendingBorrowNotifications.set(notificationId, {
windowId: notificationAnchor.windowId,
@@ -499,7 +495,7 @@ export async function requestBorrowConfirmation(
title: copy.title,
message: copy.body(tabTitle),
priority: 2,
requireInteraction: false,
requireInteraction: true,
silent: false,
buttons: [{ title: copy.allowButton }, { title: copy.denyButton }],
})
@@ -523,15 +519,12 @@ export async function requestBorrowConfirmation(
): void => {
if (settled) return;
if (index >= candidates.length) {
// Every candidate rejected sendMessage. We deliberately do NOT
// fail-open here anymore: silently allowing a borrow with zero
// user-visible authorization UI is the bug. Instead we keep the
// promise pending and rely on:
// Every candidate rejected sendMessage. Keep the promise pending and
// rely on:
// • the OS notification's Allow / Deny buttons for an explicit
// user choice (preferred), or
// • the BACKGROUND_TIMEOUT_MS fail-open as the last-resort
// soft-fail so the agent doesn't hang forever when even the
// notification API is unavailable.
// • the BACKGROUND_TIMEOUT_MS fail-closed result so the agent does
// not hang forever when even the notification API is unavailable.
// Keep the diagnostics so real-world frequency is still observable.
console.warn(
"[bsk borrow] every candidate user window failed sendMessage — awaiting notification button or timeout",
@@ -560,7 +553,9 @@ export async function requestBorrowConfirmation(
.sendMessage(candidate.tabId, message)
.then((response) => {
if (settled) return;
const allowed = (response as BorrowResponseMessage | undefined)?.allowed !== false;
const candidateResponse = response as BorrowResponseMessage | undefined;
const allowed =
candidateResponse?.type === "borrow-response" && candidateResponse.allowed === true;
console.info("[bsk borrow] confirmation response", { tabId, allowed });
settle(allowed);
})
+3 -54
View File
@@ -7,14 +7,10 @@ import {
chromeTabsApi,
isRpcError,
lookupSession,
parseBufferedReadBounds,
resolveTargetTab,
} from "./shared";
const DEFAULT_CONSOLE_LIMIT = 50;
const MAX_CONSOLE_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
export interface ConsoleDeps {
cdp: CdpRunner;
tabsApi: ChromeTabsApi;
@@ -34,7 +30,7 @@ export async function handleConsole(
): Promise<ConsoleResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "console");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseConsoleBounds(params);
const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
@@ -50,7 +46,7 @@ export async function handleConsole(
bounds.since,
bounds.limit,
bounds.maxTextChars,
bounds.includeStack,
params.include_stack === true,
);
} catch (err) {
return {
@@ -59,50 +55,3 @@ export async function handleConsole(
};
}
}
function parseConsoleBounds(params: ConsoleParams):
| {
since: number | undefined;
limit: number;
maxTextChars: number;
includeStack: boolean;
}
| RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_CONSOLE_LIMIT,
MAX_CONSOLE_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return {
since,
limit,
maxTextChars,
includeStack: params.include_stack === true,
};
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
+58 -6
View File
@@ -10,8 +10,12 @@ import type {
NavigateBackParams,
NavigateForwardParams,
NavigateParams,
NetworkParams,
PressParams,
ProtocolFrame,
RecordAwaitParams,
RecordStartParams,
RecordStopParams,
ReloadParams,
RequestFrame,
RequestHelpParams,
@@ -33,6 +37,7 @@ import {
handleNavigateForward,
handleReload,
} from "./navigation";
import { handleNetwork, type NetworkCdpRunner } from "./network";
import {
type CdpRunner,
chromeTabsCaptureApi,
@@ -40,6 +45,7 @@ import {
handleScreenshot,
handleSnapshot,
} from "./observation";
import { handleRecordAwait, handleRecordStart, handleRecordStop } from "./record";
import {
handleSessionStart,
handleSessionStop,
@@ -64,12 +70,15 @@ import {
} from "./tabs";
import { handleWaitForNavigation } from "./waits";
type DispatcherCdpRunner = CdpRunner &
NetworkCdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
export interface DispatcherDeps {
transport: Transport;
sessions: SessionManager;
cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
cdp?: DispatcherCdpRunner;
/**
* Invoked whenever a dispatched RPC may have changed the live
* session set (currently `tool.session_start` and
@@ -103,9 +112,7 @@ export interface DispatcherDeps {
export class ToolDispatcher {
private readonly transport: Transport;
private readonly sessions: SessionManager;
private readonly cdp?: CdpRunner & {
detachSession(sessionId: string): Promise<void>;
};
private readonly cdp?: DispatcherCdpRunner;
private readonly onSessionsChanged?: () => void;
private readonly approveBorrow?: BorrowConfirmationApprover;
private readonly helpNotificationCopy?: () => { title: string; body: string };
@@ -285,6 +292,12 @@ export class ToolDispatcher {
req.params as ConsoleParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.network":
return handleNetwork(
this.sessions,
req.params as NetworkParams,
this.cdp ? { cdp: this.cdp, tabsApi: chromeTabsApi } : undefined,
);
case "tool.snapshot":
return handleSnapshot(
this.sessions,
@@ -387,6 +400,45 @@ export class ToolDispatcher {
notificationCopy: this.helpNotificationCopy?.(),
signal,
});
case "tool.record_start":
return handleRecordStart(this.sessions, req.params as RecordStartParams, {
tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
bypassOverlay: async (tabId, enabled) => {
try {
await chrome.tabs.sendMessage(tabId, {
type: OVERLAY_AUTOMATION_BYPASS,
enabled,
});
} catch {
// Content script may be unavailable on restricted pages.
}
},
...(this.cdp ? { cdp: this.cdp } : {}),
signal,
});
case "tool.record_stop":
return handleRecordStop(this.sessions, req.params as RecordStopParams, {
tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
bypassOverlay: async (tabId, enabled) => {
try {
await chrome.tabs.sendMessage(tabId, {
type: OVERLAY_AUTOMATION_BYPASS,
enabled,
});
} catch {
// Content script may be unavailable on restricted pages.
}
},
signal,
});
case "tool.record_await":
return handleRecordAwait(this.sessions, req.params as RecordAwaitParams, {
tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
signal,
});
default:
return {
code: "unknown_method",
+1 -1
View File
@@ -18,6 +18,7 @@
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { EvaluateError, EvaluateParams, EvaluateResult, RpcError } from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -27,7 +28,6 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface EvaluateDeps {
cdp: CdpRunner;
+11 -9
View File
@@ -6,7 +6,6 @@
// until the user clicks Continue / Cancel, the wait times out, or the
// daemon cancels the RPC.
import type { SessionManager } from "@/session-manager/manager";
import {
HELP_CANCEL,
HELP_REQUEST,
@@ -15,6 +14,7 @@ import {
type HelpRequestMessage,
type HelpResponseMessage,
} from "@/lib/help-bridge";
import type { SessionManager } from "@/session-manager/manager";
import type {
HelpTarget,
RequestHelpParams,
@@ -22,7 +22,6 @@ import type {
ResolvedTarget,
RpcError,
} from "@/transport/types";
import { lookupSnapshotRef } from "./snapshot-ref";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -32,6 +31,7 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { lookupSnapshotRef } from "./snapshot-ref";
const DEFAULT_HELP_TIMEOUT_MS = 300_000;
/** Attribute the overlay highlights for resolved `ref` targets. */
@@ -46,7 +46,9 @@ export type TabNavigationUnsubscribe = () => void;
export interface RequestHelpDeps {
tabsApi: ChromeTabsApi;
windows: { update(windowId: number, info: { focused?: boolean }): Promise<chrome.windows.Window> };
windows: {
update(windowId: number, info: { focused?: boolean }): Promise<chrome.windows.Window>;
};
/** Activate a tab inside its window. */
activateTab(tabId: number): Promise<void>;
/** Send the help-request to the tab's content script and await reply. */
@@ -68,10 +70,7 @@ export function defaultWatchTabNavigation(
tabId: number,
onNavigated: () => void,
): TabNavigationUnsubscribe {
const listener = (
updatedTabId: number,
changeInfo: chrome.tabs.TabChangeInfo,
) => {
const listener = (updatedTabId: number, changeInfo: chrome.tabs.TabChangeInfo) => {
if (updatedTabId !== tabId) return;
if (changeInfo.url !== undefined || changeInfo.status === "loading") {
chrome.tabs.onUpdated.removeListener(listener);
@@ -102,7 +101,8 @@ function getDefaultDeps(): RequestHelpDeps {
else resolve(rid ?? id);
}),
),
clear: (id) => new Promise((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
clear: (id) =>
new Promise((resolve) => chrome.notifications.clear(id, (c) => resolve(c ?? false))),
},
};
}
@@ -167,7 +167,9 @@ async function selectorExists(
async function clearRefTags(cdp: CdpRunner | undefined, tabId: number): Promise<void> {
if (!cdp) return;
try {
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", { depth: 0 });
const doc = await cdp.send<{ root?: { nodeId?: number } }>(tabId, "DOM.getDocument", {
depth: 0,
});
const rootId = doc.root?.nodeId;
if (rootId === undefined) return;
const { nodeIds } = await cdp.send<{ nodeIds: number[] }>(tabId, "DOM.querySelectorAll", {
+6 -4
View File
@@ -21,10 +21,11 @@ import type {
MouseButton,
PressParams,
PressResult,
RpcError,
SelectParams,
SelectResult,
RpcError,
} from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs";
import {
backendNodeToObject,
boxCentre,
@@ -33,7 +34,6 @@ import {
scrollNodeIntoView,
} from "./element-geometry";
import { rpcError } from "./errors";
import { resolveSnapshotRef } from "./snapshot-ref";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -43,7 +43,7 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { resolveSnapshotRef } from "./snapshot-ref";
export interface InteractionDeps {
cdp: CdpRunner;
@@ -858,7 +858,9 @@ export async function handleSelect(
);
}
const attrs = described.node.attributes ?? [];
const isMultiple = attrs.some((attr, idx) => idx % 2 === 0 && attr.toLowerCase() === "multiple");
const isMultiple = attrs.some(
(attr, idx) => idx % 2 === 0 && attr.toLowerCase() === "multiple",
);
if (!isMultiple && params.values.length !== 1) {
return rpcError(
"invalid_params",
+9 -5
View File
@@ -28,6 +28,7 @@ import type {
RpcError,
WaitUntil,
} from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs";
import {
type CdpRunner,
type ChromeTabsApi,
@@ -37,7 +38,6 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface NavigationDeps {
cdp: CdpRunner;
@@ -120,9 +120,7 @@ export function lifecycleAlreadyReached(readyState: string, targetName: string):
case "DOMContentLoaded":
return readyState === "interactive" || readyState === "complete";
case "commit":
return (
readyState === "interactive" || readyState === "complete" || readyState === "loading"
);
return readyState === "interactive" || readyState === "complete" || readyState === "loading";
case "networkIdle":
// Must come from `Page.lifecycleEvent`; readyState does not imply network idle.
return false;
@@ -285,7 +283,13 @@ function startLifecycleWait(
const maybeFinishPending = () => {
if (settled || !pendingLifecycle) return;
if (!acceptLifecycleEvent(pendingLifecycle.name, pendingLifecycle.frameId, pendingLifecycle.loaderId)) {
if (
!acceptLifecycleEvent(
pendingLifecycle.name,
pendingLifecycle.frameId,
pendingLifecycle.loaderId,
)
) {
return;
}
if (lifecycleMeetsOrExceeds(pendingLifecycle.name, targetName)) {
+63
View File
@@ -0,0 +1,63 @@
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import type { SessionManager } from "@/session-manager/manager";
import type { NetworkParams, NetworkResult, RpcError } from "@/transport/types";
import {
type ChromeTabsApi,
chromeTabsApi,
isRpcError,
lookupSession,
parseBufferedReadBounds,
resolveTargetTab,
} from "./shared";
export interface NetworkCdpRunner {
trackSessionTab?(sessionId: string, tabId: number): void;
ensureNetworkCapture(tabId: number): Promise<void>;
networkEntriesSince(
tabId: number,
since: number | undefined,
limit: number,
maxTextChars: number,
): NetworkResult;
}
export interface NetworkDeps {
cdp: NetworkCdpRunner;
tabsApi: ChromeTabsApi;
}
function defaultNetworkDeps(): NetworkDeps {
return {
cdp: new ChromiumCdp(),
tabsApi: chromeTabsApi,
};
}
export async function handleNetwork(
manager: SessionManager,
params: NetworkParams,
deps: NetworkDeps = defaultNetworkDeps(),
): Promise<NetworkResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "network");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const bounds = parseBufferedReadBounds(params);
if (isRpcError(bounds)) return bounds;
const target = await resolveTargetTab(manager, ctxOrErr, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
try {
deps.cdp.trackSessionTab?.(ctxOrErr.sessionId, target.tabId);
await deps.cdp.ensureNetworkCapture(target.tabId);
return deps.cdp.networkEntriesSince(
target.tabId,
bounds.since,
bounds.limit,
bounds.maxTextChars,
);
} catch (err) {
return {
code: "cdp_failed",
message: err instanceof Error ? err.message : String(err),
};
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ import type {
SnapshotParams,
SnapshotResult,
} from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { nodeBoundingRect, scrollNodeIntoView } from "./element-geometry";
import { resolveSnapshotRef } from "./snapshot-ref";
import { rpcError } from "./errors";
import {
type ChromeTabsApi,
@@ -25,7 +25,7 @@ import {
type CdpRunner as SharedCdpRunner,
normaliseRef as sharedNormaliseRef,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { resolveSnapshotRef } from "./snapshot-ref";
// ---------------------------------------------------------------------------
// Shared helpers (legacy aliases — observation.ts kept exporting these
+828
View File
@@ -0,0 +1,828 @@
// `tool.record_start` / `tool.record_stop` / `tool.record_await` — capture
// user actions in the Agent Window via the content script and return a
// semantic (LLM textbook) trace.
import {
isRecordFinishMessage,
isRecordQueryMessage,
isRecordStepMessage,
RECORD_CANCEL,
RECORD_START,
RECORD_STEP,
RECORD_STOP,
type RecordCancelMessage,
type RecordFinishMessage,
type RecordQueryResponse,
type RecordStartAck,
type RecordStartMessage,
type RecordStopMessage,
} from "@/lib/record-bridge";
import { appendRecordedPayload, observeRecordedNavigation } from "@/lib/recording-step-buffer";
import { reduceTraceSteps, resolveTraceStartUrl } from "@/lib/trace-reducer";
import type { SessionManager } from "@/session-manager/manager";
import type {
DraftTraceStep,
RecordAwaitParams,
RecordAwaitResult,
RecordStartParams,
RecordStartResult,
RecordStopParams,
RecordStopResult,
RpcError,
Trace,
} from "@/transport/types";
import { handleNavigate } from "./navigation";
import {
type CdpRunner,
type ChromeTabsApi,
chromeTabsApi,
isRpcError,
lookupSession,
resolveTargetTab,
} from "./shared";
interface ActiveRecording {
requestId: string;
tabId: number;
agentWindowId: number;
startUrl?: string;
purpose?: string;
steps: DraftTraceStep[];
startedAt: string;
finishPromise: Promise<Trace>;
resolveFinish: (trace: Trace) => void;
rejectFinish: (err: Error) => void;
settled: boolean;
finishing: boolean;
currentUrl?: string;
pendingNavigation: boolean;
pendingNavigationDeadline?: number;
}
const recordings = new Map<string, ActiveRecording>();
const RECORD_START_RETRIES = 3;
const RECORD_START_RETRY_DELAY_MS = 500;
const RECORD_REARM_DEBOUNCE_MS = 150;
const RECORD_REARM_MAX_ATTEMPTS = 12;
const RECORD_REARM_RETRY_DELAY_MS = 400;
const rearmTimers = new Map<number, ReturnType<typeof setTimeout>>();
function makeRequestId(tabId: number): string {
return `rec-${tabId}-${Date.now().toString(36)}`;
}
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** Injectable http(s) landing page when `tool.record_start` omits `url`. */
export const RECORD_DEFAULT_START_URL = "https://example.com/";
/** Pages where MV3 content scripts cannot attach (Agent Window boots here). */
function isContentScriptRestrictedUrl(url: string | undefined): boolean {
if (!url) return true;
const lower = url.toLowerCase();
return (
lower === "about:blank" ||
lower.startsWith("about:") ||
lower.startsWith("chrome://") ||
lower.startsWith("chrome-extension://") ||
lower.startsWith("edge://") ||
lower.startsWith("devtools://") ||
lower.startsWith("devtools:") ||
lower.startsWith("https://chrome.google.com/webstore")
);
}
async function waitForTabReady(
tabId: number,
tabsApi: ChromeTabsApi,
timeoutMs = 10_000,
): Promise<void> {
try {
const tab = await tabsApi.get(tabId);
if (tab.status === "complete") return;
} catch {
return;
}
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => {
chrome.tabs.onUpdated.removeListener(listener);
reject(new Error("tab load timeout"));
}, timeoutMs);
const listener = (updatedTabId: number, info: chrome.tabs.TabChangeInfo) => {
if (updatedTabId !== tabId || info.status !== "complete") return;
clearTimeout(timer);
chrome.tabs.onUpdated.removeListener(listener);
resolve();
};
chrome.tabs.onUpdated.addListener(listener);
});
}
function isRecordStartAck(response: unknown): response is RecordStartAck {
return (
typeof response === "object" &&
response !== null &&
"ok" in response &&
(response as RecordStartAck).ok === true
);
}
async function sendRecordStartWithAck(
tabId: number,
msg: RecordStartMessage,
sendToTab: RecordDeps["sendToTab"],
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
try {
const response = await sendToTab(tabId, msg);
if (isRecordStartAck(response)) return;
lastError = new Error("content script did not ack RECORD_START");
} catch (err) {
lastError = err;
}
if (attempt + 1 < RECORD_START_RETRIES) {
await sleep(RECORD_START_RETRY_DELAY_MS);
}
}
throw lastError ?? new Error("failed to start recording in content script");
}
function buildTrace(recording: ActiveRecording): Trace {
const { pages, steps } = reduceTraceSteps(recording.steps, recording.startUrl);
const startUrl = resolveTraceStartUrl(recording.steps, recording.startUrl, pages);
return {
...(recording.purpose ? { purpose: recording.purpose } : {}),
recorded_at: new Date().toISOString(),
started_at: recording.startedAt,
entry: { start_url: startUrl },
pages,
steps,
};
}
export interface RecordDeps {
tabsApi: ChromeTabsApi;
sendToTab(
tabId: number,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
cdp?: CdpRunner;
signal?: AbortSignal;
}
let defaultDeps: RecordDeps | null = null;
function getDefaultDeps(): RecordDeps {
if (!defaultDeps) {
defaultDeps = {
tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
};
}
return defaultDeps;
}
/** Disposer for lazily attached tab / webNavigation observers. */
let detachBrowserObservation: (() => void) | null = null;
type AttachObservation = (deps: RecordDeps) => () => void;
// Deferred wrappers so we do not capture attach* before their declarations.
let attachTabObservation: AttachObservation = (deps) => attachRecordTabListener(deps);
let attachNavObservation: AttachObservation = (deps) => attachRecordNavigationListener(deps);
/** Test seam: swap real chrome listeners for fakes. */
export function setBrowserObservationAttachForTests(
tab: AttachObservation | null,
nav: AttachObservation | null,
): void {
attachTabObservation = tab ?? ((deps) => attachRecordTabListener(deps));
attachNavObservation = nav ?? ((deps) => attachRecordNavigationListener(deps));
}
export function isBrowserObservationAttachedForTests(): boolean {
return detachBrowserObservation !== null;
}
export function resetBrowserObservationForTests(): void {
detachBrowserObservation?.();
detachBrowserObservation = null;
recordings.clear();
attachTabObservation = (deps) => attachRecordTabListener(deps);
attachNavObservation = (deps) => attachRecordNavigationListener(deps);
}
/**
* Attach tab/webNavigation listeners while any recording is active.
* Must run before navigate-on-start so rearm observes the destination load.
*/
export function ensureBrowserObservationListeners(deps: RecordDeps = getDefaultDeps()): void {
if (detachBrowserObservation) return;
const detachTab = attachTabObservation(deps);
const detachNav = attachNavObservation(deps);
detachBrowserObservation = () => {
detachTab();
detachNav();
};
}
/** Detach when the recordings map is empty. */
export function releaseBrowserObservationListenersIfIdle(): void {
if (recordings.size > 0) return;
if (!detachBrowserObservation) return;
detachBrowserObservation();
detachBrowserObservation = null;
}
export function attachRecordStepListener(): () => void {
const listener = (
message: unknown,
_sender: chrome.runtime.MessageSender,
_sendResponse: () => void,
) => {
if (!isRecordStepMessage(message)) return false;
for (const recording of recordings.values()) {
if (recording.requestId !== message.requestId) continue;
appendRecordedPayload(recording, message.step);
return false;
}
return false;
};
chrome.runtime.onMessage.addListener(listener);
return () => chrome.runtime.onMessage.removeListener(listener);
}
export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()): () => void {
const listener = (
message: unknown,
sender: chrome.runtime.MessageSender,
_sendResponse: () => void,
) => {
if (!isRecordFinishMessage(message)) return false;
const tabId = sender.tab?.id;
if (tabId === undefined) return false;
void finishRecordingByRequest(message.requestId, tabId, deps);
return false;
};
chrome.runtime.onMessage.addListener(listener);
return () => chrome.runtime.onMessage.removeListener(listener);
}
function findRecordingByTabId(tabId: number): ActiveRecording | null {
for (const recording of recordings.values()) {
if (recording.tabId === tabId && !recording.settled) return recording;
}
return null;
}
async function findRecordingForTab(
tabId: number,
deps: RecordDeps,
): Promise<ActiveRecording | null> {
const direct = findRecordingByTabId(tabId);
if (direct) return direct;
try {
const tab = await deps.tabsApi.get(tabId);
const windowId = tab.windowId;
if (typeof windowId !== "number") return null;
for (const recording of recordings.values()) {
if (!recording.settled && recording.agentWindowId === windowId) return recording;
}
} catch {
return null;
}
return null;
}
function clearRearmTimer(tabId: number): void {
const timer = rearmTimers.get(tabId);
if (timer) {
clearTimeout(timer);
rearmTimers.delete(tabId);
}
}
async function clearRearmTimersForRecording(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
clearRearmTimer(recording.tabId);
try {
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
for (const tab of tabs) {
if (typeof tab.id === "number") clearRearmTimer(tab.id);
}
} catch {
// Best-effort cleanup.
}
}
async function stopRecordingOnAllAgentTabs(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
let tabIds = [recording.tabId];
try {
const tabs = await deps.tabsApi.query({ windowId: recording.agentWindowId });
tabIds = [
...new Set([
recording.tabId,
...tabs.flatMap((tab) => (typeof tab.id === "number" ? [tab.id] : [])),
]),
];
} catch {
// Fall back to the current recording tab.
}
for (const tabId of tabIds) {
try {
const response = await deps.sendToTab(tabId, stopMsg);
if (tabId === recording.tabId && !isRecordStartAck(response)) {
throw new Error("content script did not confirm recorded steps");
}
} catch {
if (tabId === recording.tabId) {
throw new Error("failed to flush recorded steps");
}
}
if (deps.bypassOverlay) {
try {
await deps.bypassOverlay(tabId, false);
} catch {
// Best-effort cleanup.
}
}
}
}
async function rearmRecording(
recording: ActiveRecording,
targetTabId: number,
deps: RecordDeps,
): Promise<boolean> {
// Do NOT toggle automation-bypass here: each retry used to increment the
// content-script counter, and a single stop decrement left the ControlOverlay
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
// already hides the control chrome while activeRecord is set.
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
const startMsg: RecordStartMessage = { type: RECORD_START, requestId: recording.requestId };
try {
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
recording.tabId = targetTabId;
return true;
} catch {
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
await sleep(RECORD_REARM_RETRY_DELAY_MS);
}
}
}
return false;
}
function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
const existing = rearmTimers.get(tabId);
if (existing) clearTimeout(existing);
rearmTimers.set(
tabId,
setTimeout(() => {
rearmTimers.delete(tabId);
void (async () => {
const current = await findRecordingForTab(tabId, deps);
if (current) await rearmRecording(current, tabId, deps);
})();
}, RECORD_REARM_DEBOUNCE_MS),
);
}
export function attachRecordTabListener(deps: RecordDeps = getDefaultDeps()): () => void {
const onCreated = (tab: chrome.tabs.Tab) => {
const tabId = tab.id;
const windowId = tab.windowId;
if (tabId === undefined || windowId === undefined) return;
for (const recording of recordings.values()) {
if (recording.settled || recording.agentWindowId !== windowId) continue;
scheduleRearmForTab(tabId, deps);
return;
}
};
const onActivated = (activeInfo: chrome.tabs.TabActiveInfo) => {
scheduleRearmForTab(activeInfo.tabId, deps);
};
chrome.tabs.onCreated.addListener(onCreated);
chrome.tabs.onActivated.addListener(onActivated);
return () => {
chrome.tabs.onCreated.removeListener(onCreated);
chrome.tabs.onActivated.removeListener(onActivated);
};
}
export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps()): () => void {
const observeMainFrame = (tabId: number, url?: string, causedByAction?: boolean) => {
void (async () => {
const recording = await findRecordingForTab(tabId, deps);
if (recording && url) {
observeRecordedNavigation(recording, url, causedByAction);
}
})();
};
const onMainFrameComplete = (tabId: number, url?: string) => {
void (async () => {
observeMainFrame(tabId, url);
scheduleRearmForTab(tabId, deps);
})();
};
if (chrome.webNavigation?.onCompleted) {
const completedListener = (
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) => {
if (details.frameId !== 0) return;
onMainFrameComplete(details.tabId, details.url);
};
const committedListener = (
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
) => {
if (details.frameId !== 0) return;
const causedByAction =
details.transitionType === "link" || details.transitionType === "form_submit"
? true
: details.transitionType === "typed" ||
details.transitionType === "auto_bookmark" ||
details.transitionType === "generated" ||
details.transitionType === "keyword" ||
details.transitionType === "keyword_generated" ||
details.transitionType === "reload"
? false
: undefined;
observeMainFrame(details.tabId, details.url, causedByAction);
};
chrome.webNavigation.onCompleted.addListener(completedListener);
chrome.webNavigation.onCommitted?.addListener(committedListener);
return () => {
chrome.webNavigation.onCompleted.removeListener(completedListener);
chrome.webNavigation.onCommitted?.removeListener(committedListener);
};
}
const listener = (tabId: number, info: chrome.tabs.TabChangeInfo) => {
if (info.status !== "complete") return;
onMainFrameComplete(tabId, info.url);
};
chrome.tabs.onUpdated.addListener(listener);
return () => chrome.tabs.onUpdated.removeListener(listener);
}
export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()): () => void {
const listener = (
message: unknown,
sender: chrome.runtime.MessageSender,
sendResponse: (response: RecordQueryResponse) => void,
) => {
if (!isRecordQueryMessage(message)) return false;
const tabId = sender.tab?.id;
if (tabId === undefined) {
sendResponse({ active: false });
return false;
}
void (async () => {
const recording = await findRecordingForTab(tabId, deps);
if (!recording) {
sendResponse({ active: false });
return;
}
await rearmRecording(recording, tabId, deps);
sendResponse({ active: true, requestId: recording.requestId });
})();
return true;
};
chrome.runtime.onMessage.addListener(listener);
return () => chrome.runtime.onMessage.removeListener(listener);
}
async function finishRecordingByRequest(
requestId: string,
tabId: number,
deps: RecordDeps,
): Promise<void> {
for (const [sessionId, recording] of recordings) {
if (recording.requestId !== requestId || recording.settled) continue;
const match = await findRecordingForTab(tabId, deps);
if (match !== recording) continue;
await finishRecording(sessionId, deps);
return;
}
}
async function finishRecording(sessionId: string, deps: RecordDeps): Promise<Trace | null> {
const recording = recordings.get(sessionId);
if (!recording || recording.settled || recording.finishing) return null;
recording.finishing = true;
await clearRearmTimersForRecording(recording, deps);
try {
await stopRecordingOnAllAgentTabs(recording, deps);
} catch {
recording.finishing = false;
return null;
}
recording.settled = true;
recordings.delete(sessionId);
releaseBrowserObservationListenersIfIdle();
const trace = buildTrace(recording);
recording.resolveFinish(trace);
return trace;
}
export async function handleRecordStart(
manager: SessionManager,
params: RecordStartParams,
deps: RecordDeps = getDefaultDeps(),
): Promise<RecordStartResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "record_start");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const ctx = ctxOrErr;
if (recordings.has(params.session_id)) {
return {
code: "protocol_error",
message: `session ${params.session_id} is already recording`,
};
}
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
// Register the recording *before* navigate so content-script syncAgentOverlay
// on the destination page can RECORD_QUERY → rearm → show RecordOverlay
// instead of flashing ControlOverlay ("Agent 正在控制").
const requestId = makeRequestId(target.tabId);
let resolveFinish!: (trace: Trace) => void;
let rejectFinish!: (err: Error) => void;
const finishPromise = new Promise<Trace>((resolve, reject) => {
resolveFinish = resolve;
rejectFinish = reject;
});
const navigateUrl = params.url ?? RECORD_DEFAULT_START_URL;
recordings.set(params.session_id, {
requestId,
tabId: target.tabId,
agentWindowId: ctx.agentWindowId,
startUrl: navigateUrl,
...(params.purpose ? { purpose: params.purpose } : {}),
steps: [],
startedAt: new Date().toISOString(),
finishPromise,
resolveFinish,
rejectFinish,
settled: false,
finishing: false,
currentUrl: navigateUrl,
pendingNavigation: false,
pendingNavigationDeadline: undefined,
});
// Observe navigations for the whole recording lifetime; attach before
// optional navigate so the destination load can rearm capture.
ensureBrowserObservationListeners(deps);
const abortPending = async (notifyContent: boolean) => {
recordings.delete(params.session_id);
releaseBrowserObservationListenersIfIdle();
if (notifyContent) {
try {
await deps.sendToTab(target.tabId, {
type: RECORD_CANCEL,
requestId,
});
} catch {
// Content may never have received RECORD_START.
}
}
if (deps.bypassOverlay) {
try {
await deps.bypassOverlay(target.tabId, false);
} catch {
// Ignore cleanup errors.
}
}
};
const cancelledError = (): RpcError => ({
code: "cancelled",
message: "record_start aborted",
});
/** Dispatcher may race-cancel while we still hold a provisional recording. */
const abortIfCancelled = async (notifyContent: boolean): Promise<RpcError | null> => {
if (!deps.signal?.aborted) return null;
await abortPending(notifyContent);
return cancelledError();
};
{
const cancelled = await abortIfCancelled(false);
if (cancelled) return cancelled;
}
if (deps.cdp) {
const nav = await handleNavigate(
manager,
{
session_id: params.session_id,
url: navigateUrl,
tab_id: target.tabId,
},
{ cdp: deps.cdp, tabsApi: deps.tabsApi, signal: deps.signal },
);
if (isRpcError(nav)) {
await abortPending(false);
return nav;
}
{
const cancelled = await abortIfCancelled(false);
if (cancelled) return cancelled;
}
try {
await waitForTabReady(target.tabId, deps.tabsApi);
} catch {
// Proceed with retries even if the tab never reports complete.
}
{
const cancelled = await abortIfCancelled(false);
if (cancelled) return cancelled;
}
}
let startUrl: string | undefined;
try {
const tab = await deps.tabsApi.get(target.tabId);
startUrl = tab.url;
} catch {
startUrl = navigateUrl;
}
const active = recordings.get(params.session_id);
if (!active) {
// Cleared by a concurrent abort / session teardown.
return cancelledError();
}
active.startUrl = startUrl;
active.currentUrl = startUrl;
if (isContentScriptRestrictedUrl(startUrl)) {
await abortPending(false);
return {
code: "invalid_params",
message: params.url
? `cannot record on restricted URL (${startUrl}); use an http(s) page`
: `cannot record on restricted URL (${startUrl}); default start page must be injectable http(s)`,
};
}
{
const cancelled = await abortIfCancelled(false);
if (cancelled) return cancelled;
}
if (deps.bypassOverlay) {
try {
// Single ref for the initial race before RecordOverlay mounts; rearm must
// not stack additional refs (see rearmRecording). Cleared on stop.
await deps.bypassOverlay(target.tabId, true);
} catch {
// Best-effort; activeRecord also hides the control overlay.
}
}
{
const cancelled = await abortIfCancelled(true);
if (cancelled) return cancelled;
}
const startMsg: RecordStartMessage = { type: RECORD_START, requestId };
try {
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
} catch {
await abortPending(true);
return {
code: "protocol_error",
message:
"failed to start recording in content script — reload the BrowserSkill extension, then retry",
};
}
{
const cancelled = await abortIfCancelled(true);
if (cancelled) return cancelled;
}
return { tab_id: target.tabId, recording: true };
}
export async function handleRecordStop(
manager: SessionManager,
params: RecordStopParams,
deps: RecordDeps = getDefaultDeps(),
): Promise<RecordStopResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "record_stop");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const recording = recordings.get(params.session_id);
if (!recording) {
return {
code: "not_found",
message: `no active recording for session ${params.session_id}`,
};
}
const trace = await finishRecording(params.session_id, deps);
if (!trace) {
return {
code: "protocol_error",
message: `failed to flush recorded steps for session ${params.session_id}; the recording is still active — retry \`bsk record stop\``,
};
}
return { trace };
}
export async function handleRecordAwait(
manager: SessionManager,
params: RecordAwaitParams,
deps: RecordDeps = getDefaultDeps(),
): Promise<RecordAwaitResult | RpcError> {
const ctxOrErr = lookupSession(manager, params, "record_await");
if (isRpcError(ctxOrErr)) return ctxOrErr;
const recording = recordings.get(params.session_id);
if (!recording) {
return {
code: "not_found",
message: `no active recording for session ${params.session_id}`,
};
}
if (deps.signal?.aborted) {
return { code: "cancelled", message: "record_await aborted" };
}
const outcome = await new Promise<{ trace: Trace } | { error: RpcError }>((resolve) => {
let settled = false;
const finish = (result: { trace: Trace } | { error: RpcError }) => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
deps.signal?.removeEventListener("abort", onAbort);
resolve(result);
};
const onAbort = () => finish({ error: { code: "cancelled", message: "record_await aborted" } });
const timer =
params.timeout_ms === undefined
? undefined
: setTimeout(
() =>
finish({
error: {
code: "timeout",
message: `record_await timed out after ${params.timeout_ms}ms`,
},
}),
params.timeout_ms,
);
deps.signal?.addEventListener("abort", onAbort, { once: true });
void recording.finishPromise.then(
(trace) => finish({ trace }),
() =>
finish({
error: { code: "cancelled", message: "recording was cleared" },
}),
);
});
return "trace" in outcome ? { trace: outcome.trace } : outcome.error;
}
export function clearRecordingForSession(sessionId: string): void {
const recording = recordings.get(sessionId);
if (!recording) {
recordings.delete(sessionId);
releaseBrowserObservationListenersIfIdle();
return;
}
void clearRearmTimersForRecording(recording, getDefaultDeps());
if (!recording.settled) {
recording.settled = true;
recording.rejectFinish(new Error("recording cleared"));
}
recordings.delete(sessionId);
releaseBrowserObservationListenersIfIdle();
}
export type { RecordFinishMessage };
+3
View File
@@ -1,5 +1,6 @@
import type { SessionManager } from "@/session-manager/manager";
import type { RpcError } from "@/transport/types";
import { clearRecordingForSession } from "./record";
import { returnBorrowedTab, type TabManagementDeps } from "./tabs";
export interface SessionStartParams {
@@ -156,6 +157,8 @@ export async function handleSessionStop(
// Step 2: clear the per-session RefStore (review M6/M7 parity).
ctx.refStore.clear();
clearRecordingForSession(params.session_id);
// Step 3: detach CDP sessions this session opened (no-op if none).
await deps.cdp?.detachSession(params.session_id);
+51
View File
@@ -12,6 +12,11 @@ import { normaliseRef } from "@/session-manager/ref-store";
import type { ConsoleResult, JavaScriptDialogInfo, RpcError } from "@/transport/types";
import { rpcError } from "./errors";
const DEFAULT_BUFFERED_READ_LIMIT = 50;
const MAX_BUFFERED_READ_LIMIT = 200;
const DEFAULT_MAX_TEXT_CHARS = 1000;
const MAX_TEXT_CHARS = 4096;
/**
* Subset of `chrome.tabs` we depend on across tool handlers. Kept on
* a thin interface so vitest can inject a fake without monkey-patching
@@ -55,6 +60,52 @@ export interface CdpRunner {
): ConsoleResult;
}
export interface BufferedReadBounds {
since: number | undefined;
limit: number;
maxTextChars: number;
}
/** Parse the common cursor and output bounds used by buffered read tools. */
export function parseBufferedReadBounds(params: {
since?: number;
limit?: number;
max_text_chars?: number;
}): BufferedReadBounds | RpcError {
const since = params.since;
if (since !== undefined && (!Number.isSafeInteger(since) || since < 0)) {
return { code: "invalid_params", message: "since must be a non-negative integer" };
}
const limit = boundedOptionalInteger(
params.limit,
DEFAULT_BUFFERED_READ_LIMIT,
MAX_BUFFERED_READ_LIMIT,
"limit",
);
if (isRpcError(limit)) return limit;
const maxTextChars = boundedOptionalInteger(
params.max_text_chars,
DEFAULT_MAX_TEXT_CHARS,
MAX_TEXT_CHARS,
"max_text_chars",
);
if (isRpcError(maxTextChars)) return maxTextChars;
return { since, limit, maxTextChars };
}
function boundedOptionalInteger(
value: number | undefined,
defaultValue: number,
maxValue: number,
field: string,
): number | RpcError {
if (value === undefined) return defaultValue;
if (!Number.isSafeInteger(value) || value <= 0) {
return { code: "invalid_params", message: `${field} must be a positive integer` };
}
return Math.min(value, maxValue);
}
/**
* Look up an active session by its `session_id` param, returning the
* matching `SessionContext` or a structured `RpcError`. Tool handlers
+1 -1
View File
@@ -20,6 +20,7 @@ import type {
WaitForNavigationResult,
WaitUntil,
} from "@/transport/types";
import { attachDialogs, markDialogCursor } from "./dialogs";
import { cdpLifecycleName, ensureCdpReady, waitForLifecyclePassive } from "./navigation";
import {
type CdpRunner,
@@ -30,7 +31,6 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { attachDialogs, markDialogCursor } from "./dialogs";
export interface WaitForNavigationDeps {
cdp: CdpRunner;
+7
View File
@@ -5,3 +5,10 @@
// at runtime (review M3 fix to round-1).
declare const __BSK_EXT_VERSION__: string;
/**
* WebSocket URL the extension uses to connect to the local bsk daemon.
* Defaults to {@code ws://127.0.0.1:52800}. Override at build time by
* setting the {@code BSK_DAEMON_WS_URL} environment variable.
*/
declare const __BSK_DAEMON_WS_URL__: string;
+163
View File
@@ -172,6 +172,37 @@ export interface ConsoleResult {
truncated: boolean;
}
export type NetworkEntryKind = "response" | "failure";
export interface NetworkEntry {
sequence: number;
kind: NetworkEntryKind;
method?: string;
url?: string;
status?: number;
status_text?: string;
mime_type?: string;
resource_type?: string;
error_text?: string;
timestamp?: number;
truncated: boolean;
}
export interface NetworkParams {
session_id: string;
tab_id?: number;
since?: number;
limit?: number;
max_text_chars?: number;
}
export interface NetworkResult {
tab_id: number;
entries: NetworkEntry[];
next_since: number;
truncated: boolean;
}
export type TabScopeFilter = "user" | "agent" | "all";
export interface TabInfo {
@@ -506,3 +537,135 @@ export interface RequestHelpResult {
tab_id: number;
resolved_targets?: ResolvedTarget[];
}
// --------------------------------------------------------------------------
// Semantic record payloads — mirror bsk-protocol record.rs (Trace v2)
// --------------------------------------------------------------------------
export interface TargetDescriptor {
role?: string;
name?: string;
tag: string;
name_attr?: string;
placeholder?: string;
nearby_label?: string;
}
export interface TraceEntry {
start_url: string;
}
export interface PageRef {
id: string;
url: string;
title?: string;
}
export interface SelectedOption {
value: string;
label?: string;
}
export interface StepEffect {
navigated_to: string;
}
export interface StepCommon {
id: number;
page: string;
effect?: StepEffect;
}
/** Capture/buffer draft before v2 reduction. */
export type DraftTraceStep =
| {
op: "click";
target: TargetDescriptor;
navigated_to?: string;
page_url?: string;
}
| {
op: "fill";
target: TargetDescriptor;
value: string;
redacted?: boolean;
page_url?: string;
}
| {
op: "press";
key: string;
target?: TargetDescriptor;
modifiers?: KeyModifier[];
navigated_to?: string;
page_url?: string;
}
| {
op: "select";
target: TargetDescriptor;
values: string[];
labels?: string[];
navigated_to?: string;
page_url?: string;
}
| {
op: "navigate";
url: string;
page_url?: string;
};
/** Exported record-only step (trace v2). */
export type Step =
| ({ op: "navigate" } & StepCommon & { to: string })
| ({ op: "click" } & StepCommon & { target: TargetDescriptor })
| ({ op: "fill" } & StepCommon & {
target: TargetDescriptor;
value: string;
redacted?: boolean;
})
| ({ op: "select" } & StepCommon & {
target: TargetDescriptor;
selection: SelectedOption[];
})
| ({ op: "press" } & StepCommon & {
key: string;
modifiers?: KeyModifier[];
target?: TargetDescriptor;
});
export interface Trace {
recorded_at: string;
started_at?: string;
purpose?: string;
entry: TraceEntry;
pages: PageRef[];
steps: Step[];
}
export interface RecordStartParams {
session_id: string;
tab_id?: number;
url?: string;
purpose?: string;
}
export interface RecordStartResult {
tab_id: number;
recording: boolean;
}
export interface RecordStopParams {
session_id: string;
}
export interface RecordStopResult {
trace: Trace;
}
export interface RecordAwaitParams {
session_id: string;
timeout_ms?: number;
}
export interface RecordAwaitResult {
trace: Trace;
}
+1
View File
@@ -21,6 +21,7 @@ export default defineConfig({
},
define: {
__BSK_EXT_VERSION__: JSON.stringify(pkg.version),
__BSK_DAEMON_WS_URL__: JSON.stringify(process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800"),
},
resolve: {
alias: {
+12 -1
View File
@@ -25,7 +25,15 @@ export default defineConfig({
name: "BrowserSkill",
description:
"Let AI agents use your logged-in browser in a separate Agent Window—without interrupting your work. Powered by the bsk CLI.",
permissions: ["alarms", "debugger", "notifications", "tabs", "storage", "windows"],
permissions: [
"alarms",
"debugger",
"notifications",
"tabs",
"storage",
"webNavigation",
"windows",
],
host_permissions: ["<all_urls>"],
icons: {
16: "icon/logo.png",
@@ -59,6 +67,9 @@ export default defineConfig({
],
define: {
__BSK_EXT_VERSION__: JSON.stringify(EXTENSION_VERSION),
__BSK_DAEMON_WS_URL__: JSON.stringify(
process.env.BSK_DAEMON_WS_URL ?? "ws://127.0.0.1:52800",
),
},
resolve: {
alias: {
+5
View File
@@ -48,6 +48,11 @@ fs2 = { workspace = true }
rand = { workspace = true }
uuid = { workspace = true }
bsk-protocol = { workspace = true }
reqwest = { workspace = true }
flate2 = { workspace = true }
tar = { workspace = true }
zip = { workspace = true }
sha2 = { workspace = true }
[target.'cfg(unix)'.dependencies]
nix = { workspace = true }
+35 -2
View File
@@ -46,6 +46,21 @@ Optional: `bsk session start --browser <instance-id-or-label>` when multiple bro
Emergency cleanup: `bsk session stop --all` or the Agent Window overlay **Stop all**.
## Stop when the goal is met
Every task is a **bounded goal**, not open-ended browsing. The goal may come from the user's request, a recorded `trace.json`, or both.
1. **Define success first** — one concrete, observable condition derived from the user's words, `purpose`, or the last meaningful step in a trace (e.g. "form submitted", "item added to cart", "playback started").
2. **Take the shortest path** — snapshot → act → at most one check. Do not wander, re-try unrelated actions, or stack exploratory steps.
3. **Stop as soon as success is reached** — run `bsk session stop <id>` immediately unless the user explicitly asked to keep the session open (e.g. "don't close yet", "keep browsing").
4. **No post-success work** — once the goal is met, do not click, refresh, navigate, re-search, switch tabs, or "double-check" that it worked. Further verification is a new task.
5. **When blocked, pause — do not brute-force** — if the page requires human input (login, captcha, OTP, payment confirmation) or an action fails twice with no progress, call `bsk request-help` instead of retrying blindly. See **Ask the human for help** below.
6. **When unsure** — at most one extra `bsk snapshot`. If success looks met, stop. If not, ask the user; do not keep clicking.
**With a trace:** replay steps in order using `target` role/name/tag and raw `value`/`selection` fields. After the last step (or when its `effect.navigated_to` / success hint is satisfied), apply rules 3–4 immediately. The trace guides execution; it does not extend control beyond the goal.
**Without a trace:** the user's request *is* the success condition. Satisfying it ends the task — same stop rules apply.
## Core interaction loop
Write operations only affect tabs in the **Agent Window** (or tabs you **borrowed** into it).
@@ -191,6 +206,23 @@ acts. The result `outcome` is one of:
`note` carries any text the user typed back. `resolved_targets` reports
which refs/selectors matched a live element.
### Recording — `bsk record`
Capture the user's own actions in the Agent Window to a `trace.json`, for later LLM-driven automation:
```bash
bsk record start --browser <instance-id-or-label> [--url https://…] [--purpose "publish a wiki doc"] [--output trace.json]
# `--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.json and closes the window.
bsk record stop [--output trace.json] # terminal fallback if the browser panel is unavailable
```
- The trace is a **record-only action log** (a `pages[]` dictionary + `navigate`/`click`/`fill`/`select`/`press` steps with `target` descriptors). It records *what the user did*; deciding which inputs are variable is left to the executing agent.
- `--purpose` is optional context metadata; it does **not** change what gets captured.
- There is **no** `bsk replay` — to redo a flow, read the trace and reuse the existing `session` / `snapshot` / `@eN` / `click` / `fill` tools. Follow **Stop when the goal is met**.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
## Error handling
### Exit codes (`echo $?` after `bsk …`)
@@ -221,8 +253,9 @@ Always **`bsk session stop <id>`** in a `finally`-style path so the Agent Window
1. **No token theft** — do not `bsk evaluate` on sensitive sites to read `localStorage`, cookies, or auth headers for exfiltration.
2. **No long borrow** — do not leave a user's personal tab in the Agent Window across unrelated tasks.
3. **No skip stop** — always `bsk session stop <id>`; never assume idle timeout will clean up.
4. **No observe escalation before snapshot** — use `bsk snapshot` first; only use `bsk get-html` or `bsk screenshot` when the snapshot is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot ref — never skip snapshot just to grab a visual.
5. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
4. **No post-success control** — once the user’s goal (or last trace step) is met, do not keep operating the page; stop the session unless they asked to keep it open.
5. **No observe escalation before snapshot** — use `bsk snapshot` first; only use `bsk get-html` or `bsk screenshot` when the snapshot is insufficient. Element screenshots (`--ref @eN`) still require a fresh snapshot ref — never skip snapshot just to grab a visual.
6. **`evaluate` is powerful and risky** — use only when snapshot + click/fill/select cannot suffice; never on credential surfaces.
---
+1 -1
View File
@@ -25,7 +25,7 @@ pub enum DaemonCmd {
Restart(StartArgs),
}
#[derive(Debug, Clone, clap::Args)]
#[derive(Debug, Clone, Default, clap::Args)]
pub struct StartArgs {
/// Override the WebSocket port (default 52800).
#[arg(long, value_name = "PORT")]
+21
View File
@@ -104,6 +104,12 @@ pub fn run(output: Output) -> Result<Vec<CheckResult>> {
Ok(checks)
}
/// Whether the rendered doctor report contains an active failure.
/// `NotApplicable` remains informational and must not change the exit code.
pub fn has_failures(checks: &[CheckResult]) -> bool {
checks.iter().any(|check| check.status == CheckStatus::Fail)
}
/// Ensure the daemon is reachable and give the browser extension time to
/// connect before checks run. Returns a single [`DaemonState`] snapshot
/// for check evaluation.
@@ -500,6 +506,21 @@ mod m2_tests {
);
}
#[test]
fn only_active_failures_make_doctor_unsuccessful() {
let healthy = vec![
CheckResult::ok("ok", "ready"),
CheckResult::na("optional", "not connected"),
];
assert!(!has_failures(&healthy));
let unhealthy = vec![
CheckResult::ok("ok", "ready"),
CheckResult::fail("broken", "not ready", "repair it"),
];
assert!(has_failures(&unhealthy));
}
#[test]
fn browsers_check_reports_na_when_no_browser_connected() {
// Review M2: 0 browsers means there is nothing to compare
+29 -3
View File
@@ -43,6 +43,12 @@ pub enum CliError {
#[error("{message}")]
Rendered { code: ErrorCode, message: String },
/// Command already rendered its result and only needs to communicate
/// a command-specific non-zero status. Unlike `Rendered`, this is not
/// pretending that a daemon protocol error occurred.
#[error("command reported an unsuccessful status")]
RenderedExit { exit_code: u8 },
/// Local transport / setup failure (e.g. couldn't reach the
/// daemon, JSON encode failed). Maps to exit code 2.
#[error(transparent)]
@@ -63,6 +69,7 @@ impl CliError {
match self {
CliError::Rpc { code, .. } => Some(*code),
CliError::Rendered { code, .. } => Some(*code),
CliError::RenderedExit { .. } => None,
CliError::Local(_) => None,
}
}
@@ -76,6 +83,9 @@ impl CliError {
/// Map this error to the CLI exit code (§3.1).
pub fn exit_code(&self) -> u8 {
if let CliError::RenderedExit { exit_code } = self {
return *exit_code;
}
match self.code() {
Some(code) => exit_code_for(code),
None => 2, // local / protocol-level transport failure
@@ -162,7 +172,10 @@ fn render_info_for(err: &CliError) -> Option<render_error::RenderInfo> {
/// Hint line for human / JSON rendering, including the local-failure
/// fallback when there is no daemon [`ErrorCode`].
fn hint_for(err: &CliError, render_info: Option<&render_error::RenderInfo>) -> Option<&'static str> {
fn hint_for(
err: &CliError,
render_info: Option<&render_error::RenderInfo>,
) -> Option<&'static str> {
render_info
.and_then(|info| info.hint)
.or(if matches!(err, CliError::Local(_)) {
@@ -204,7 +217,10 @@ pub fn render_with_extras(
extras: Option<&dyn RenderExtras>,
) -> ExitCode {
let exit = err.exit_code();
if matches!(err, CliError::Rendered { .. }) {
if matches!(
err,
CliError::Rendered { .. } | CliError::RenderedExit { .. }
) {
return ExitCode::from(exit);
}
let render_info = render_info_for(err);
@@ -256,7 +272,10 @@ pub fn render_with_extras(
#[cfg(test)]
pub(crate) fn render_human_to_string(err: &CliError, extras: Option<&dyn RenderExtras>) -> String {
let mut buf: Vec<u8> = Vec::new();
if matches!(err, CliError::Rendered { .. }) {
if matches!(
err,
CliError::Rendered { .. } | CliError::RenderedExit { .. }
) {
return String::new();
}
let render_info = render_info_for(err);
@@ -337,6 +356,13 @@ mod tests {
assert_eq!(err.exit_code(), 3);
}
#[test]
fn rendered_exit_uses_command_specific_status_without_protocol_code() {
let err = CliError::RenderedExit { exit_code: 1 };
assert_eq!(err.code(), None);
assert_eq!(err.exit_code(), 1);
}
/// Review I2: the `--json` payload must be flat at the top level.
/// The previous shape nested everything under `"error"` which was
/// awkward for shell scripts (`jq -r '.error.code'`) and contradicted
+16
View File
@@ -18,12 +18,16 @@ pub mod install_skill;
pub mod interaction;
pub mod logs;
pub mod navigate;
pub mod network;
pub mod record;
pub mod record_state;
pub mod render_error;
pub mod screenshot;
pub mod session;
pub mod snapshot;
pub mod status;
pub mod tab;
pub mod update;
pub mod waits;
use clap::{Args, Parser, Subcommand};
@@ -36,10 +40,13 @@ use crate::cli::human_loop::RequestHelpArgs;
use crate::cli::install_skill::InstallSkillArgs;
use crate::cli::interaction::{ClickArgs, FillArgs, PressArgs, SelectArgs};
use crate::cli::navigate::{NavigateCommand, NavigateHistoryArgs, ReloadArgs};
use crate::cli::network::NetworkArgs;
use crate::cli::record::RecordCmd;
use crate::cli::screenshot::ScreenshotArgs;
use crate::cli::session::SessionCmd;
use crate::cli::snapshot::SnapshotArgs;
use crate::cli::tab::TabCmd;
use crate::cli::update::UpdateArgs;
use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs};
/// Tool calls wait slightly longer than the daemon's 30s tool timeout so
@@ -94,6 +101,9 @@ pub enum Command {
#[command(name = "install-skill")]
InstallSkill(InstallSkillArgs),
/// Check for and install bsk CLI updates.
Update(UpdateArgs),
/// Print (and optionally follow) the daemon log file.
Logs(LogsCmd),
@@ -115,6 +125,9 @@ pub enum Command {
/// Read buffered console/log/exception messages.
Console(ConsoleArgs),
/// Read buffered network responses / failures.
Network(NetworkArgs),
/// Dump raw HTML for a tab or a snapshot ref.
#[command(name = "get-html")]
GetHtml(GetHtmlArgs),
@@ -159,6 +172,9 @@ pub enum Command {
/// Ask the human to complete an in-page step (captcha / login / confirm).
#[command(name = "request-help")]
RequestHelp(RequestHelpArgs),
/// Record user actions into a semantic `trace.json` textbook for LLMs.
Record(RecordCmd),
}
#[derive(Debug, Clone, Args, Default)]
+134
View File
@@ -0,0 +1,134 @@
//! `bsk network` — read buffered page network responses / failures.
use std::path::PathBuf;
use anyhow::Context;
use bsk_protocol::Method;
use bsk_protocol::tools::{NetworkEntry, NetworkParams, NetworkResult};
use clap::Args;
use crate::cli::TOOL_IPC_TIMEOUT;
use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format};
#[derive(Debug, Clone, Args)]
pub struct NetworkArgs {
/// Session id (must be active).
#[arg(long)]
pub session: String,
/// Target tab. Defaults to the Agent Window's active tab.
#[arg(long = "tab-id")]
pub tab_id: Option<i64>,
/// Return entries with sequence greater than this cursor.
#[arg(long)]
pub since: Option<u64>,
/// Maximum number of entries to return. Defaults to 50; extension caps at 200.
#[arg(long, value_parser = clap::value_parser!(u32).range(1..))]
pub limit: Option<u32>,
/// Maximum characters per URL / error text. Defaults to 1000; extension caps at 4096.
#[arg(long = "max-text-chars", value_parser = clap::value_parser!(u32).range(1..))]
pub max_text_chars: Option<u32>,
}
pub fn dispatch(args: NetworkArgs, format: Format) -> Result<(), CliError> {
let info = ensure_daemon().context("ensure daemon is running")?;
run(info.sock_path, args, format)
}
fn run(sock: PathBuf, args: NetworkArgs, format: Format) -> Result<(), CliError> {
let params = NetworkParams {
session_id: args.session,
tab_id: args.tab_id,
since: args.since,
limit: args.limit,
max_text_chars: args.max_text_chars,
};
let reply: NetworkResult = call(sock, params)?;
render(&reply, format)
}
fn call(sock: PathBuf, params: NetworkParams) -> Result<NetworkResult, CliError> {
crate::cli::business_rpc::call::<NetworkParams, NetworkResult>(
sock,
"network",
Method::ToolNetwork,
Some(params),
TOOL_IPC_TIMEOUT,
)
}
fn render(reply: &NetworkResult, format: Format) -> Result<(), CliError> {
match format {
Format::Json => {
let json = serde_json::to_string_pretty(reply)
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?;
println!("{json}");
}
Format::Human => {
if reply.entries.is_empty() {
println!("(no network activity captured)");
} else {
for entry in &reply.entries {
println!("{}", render_entry(entry));
}
}
if reply.truncated {
eprintln!(
"warning: network output truncated (next_since={}). Use --since / --limit / --max-text-chars to request a different slice.",
reply.next_since
);
}
}
}
Ok(())
}
fn render_entry(entry: &NetworkEntry) -> String {
let method = entry.method.as_deref().unwrap_or("?");
let url = entry.url.as_deref().unwrap_or("(unknown)");
match entry.kind {
bsk_protocol::tools::NetworkEntryKind::Failure => {
let err = entry.error_text.as_deref().unwrap_or("failed");
format!("#{} FAILED {method} {url} - {err}", entry.sequence)
}
bsk_protocol::tools::NetworkEntryKind::Response => {
let status = entry
.status
.map(|s| s.to_string())
.unwrap_or_else(|| "?".to_string());
format!("#{} {status} {method} {url}", entry.sequence)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use bsk_protocol::tools::NetworkEntryKind;
#[test]
fn human_failure_output_uses_ascii_and_displays_unknown_url() {
let entry = NetworkEntry {
sequence: 7,
kind: NetworkEntryKind::Failure,
method: Some("GET".into()),
url: None,
status: None,
status_text: None,
mime_type: None,
resource_type: Some("Fetch".into()),
error_text: Some("net::ERR_FAILED".into()),
timestamp: None,
truncated: false,
};
assert_eq!(
render_entry(&entry),
"#7 FAILED GET (unknown) - net::ERR_FAILED"
);
}
}
+261
View File
@@ -0,0 +1,261 @@
//! `bsk record start|stop` — capture user actions in the Agent Window.
use std::fs;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Context;
use bsk_protocol::Method;
use bsk_protocol::tools::{
RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams,
RecordStopResult, Trace,
};
use clap::{Args, Subcommand};
use crate::cli::TOOL_IPC_TIMEOUT;
use crate::cli::business_rpc;
use crate::cli::ensure_daemon::ensure_daemon;
use crate::cli::error::{CliError, Format};
use crate::cli::record_state;
use crate::cli::session::{start_session, stop_session};
/// Max wait for the user to click 结束 in the browser (24 hours).
const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000;
/// `bsk record …` subcommand tree.
#[derive(Debug, Clone, Args)]
pub struct RecordCmd {
#[command(subcommand)]
pub sub: RecordSub,
}
#[derive(Debug, Clone, Subcommand)]
pub enum RecordSub {
/// Open the Agent Window, record user actions, and block until finished in the browser.
Start(RecordStartArgs),
/// Stop recording from the terminal (fallback), write trace JSON, and close the window.
/// Works even while `record start` is blocked in `record_await` (daemon forwards
/// `tool.record_stop` without the per-session busy gate).
Stop(RecordStopArgs),
}
#[derive(Debug, Clone, Args)]
pub struct RecordStartArgs {
/// Target browser instance id or label. Required when multiple browsers
/// are connected; omit when only one is online.
#[arg(long)]
pub browser: Option<String>,
#[arg(long = "tab-id")]
pub tab_id: Option<i64>,
/// Navigate to this http(s) URL before recording. When omitted, defaults
/// to `https://example.com/`.
#[arg(long)]
pub url: Option<String>,
/// Optional goal text stored on the exported trace for LLM context.
#[arg(long)]
pub purpose: Option<String>,
/// Output path for the trace JSON (default `./trace.json`).
#[arg(long, default_value = "trace.json")]
pub output: PathBuf,
}
#[derive(Debug, Clone, Args)]
pub struct RecordStopArgs {
/// Output path for the trace JSON (default `./trace.json`).
#[arg(long, default_value = "trace.json")]
pub output: PathBuf,
}
pub fn dispatch(cmd: RecordCmd, format: Format) -> Result<(), CliError> {
match cmd.sub {
RecordSub::Start(args) => dispatch_start(args, format),
RecordSub::Stop(args) => dispatch_stop(args, format),
}
}
fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> {
if record_state::read().is_ok() {
return Err(CliError::Local(anyhow::anyhow!(
"a recording is already in progress; run `bsk record stop` first"
)));
}
let info = ensure_daemon().context("ensure daemon is running")?;
let session = start_session(info.sock_path.clone(), args.browser)?;
let start_params = RecordStartParams {
session_id: session.session_id.clone(),
tab_id: args.tab_id,
url: args.url,
purpose: args.purpose.clone(),
};
let start_result = business_rpc::call::<RecordStartParams, RecordStartResult>(
info.sock_path.clone(),
"record-start",
Method::ToolRecordStart,
Some(start_params),
TOOL_IPC_TIMEOUT,
);
let start_result = match start_result {
Ok(result) => result,
Err(err) => {
let _ = stop_session(info.sock_path, &session.session_id);
return Err(err);
}
};
if let Err(err) = record_state::write(&session.session_id) {
let _ = stop_session(info.sock_path.clone(), &session.session_id);
return Err(CliError::Local(err));
}
if format == Format::Human {
println!(
"recording on tab={} — click 结束 in the browser when done",
start_result.tab_id
);
}
let await_params = RecordAwaitParams {
session_id: session.session_id.clone(),
timeout_ms: Some(RECORD_AWAIT_TIMEOUT_MS),
};
let await_result = business_rpc::call::<RecordAwaitParams, RecordAwaitResult>(
info.sock_path.clone(),
"record-await",
Method::ToolRecordAwait,
Some(await_params),
record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS),
);
// Keep write/render inside a Result so `?` cannot skip session teardown.
let run_result: Result<(), CliError> = match await_result {
Ok(result) => (|| {
write_trace_file(&args.output, &result.trace)?;
render_finish(&result.trace, &args.output, format)
})(),
Err(err) => Err(err),
};
let session_stop_result = stop_session(info.sock_path, &session.session_id);
record_state::clear();
run_result?;
session_stop_result?;
Ok(())
}
fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> {
let state = record_state::read().map_err(CliError::Local)?;
let info = ensure_daemon().context("ensure daemon is running")?;
let session_id = state.session_id.clone();
let run_result: Result<(), CliError> = (|| {
let params = RecordStopParams {
session_id: session_id.clone(),
};
let result = business_rpc::call::<RecordStopParams, RecordStopResult>(
info.sock_path.clone(),
"record-stop",
Method::ToolRecordStop,
Some(params),
TOOL_IPC_TIMEOUT,
)?;
write_trace_file(&args.output, &result.trace)?;
render_stop(&result, &args.output, format)
})();
let session_stop_result = stop_session(info.sock_path, &session_id);
record_state::clear();
run_result?;
session_stop_result?;
Ok(())
}
fn record_await_ipc_timeout(timeout_ms: u32) -> Duration {
Duration::from_millis(u64::from(timeout_ms))
.checked_add(Duration::from_secs(15))
.unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15))
}
fn write_trace_file(output: &PathBuf, trace: &Trace) -> Result<(), CliError> {
let json = serde_json::to_string_pretty(trace)
.context("serialize trace JSON")
.map_err(CliError::Local)?;
if let Some(parent) = output.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)
.with_context(|| format!("create output directory {}", parent.display()))
.map_err(CliError::Local)?;
}
}
fs::write(output, format!("{json}\n"))
.with_context(|| format!("write trace to {}", output.display()))
.map_err(CliError::Local)?;
Ok(())
}
fn render_finish(trace: &Trace, output: &PathBuf, format: Format) -> Result<(), CliError> {
match format {
Format::Json => {
println!(
"{}",
serde_json::to_string(&serde_json::json!({
"output": output,
"trace": trace,
"window_closed": true,
}))
.map_err(|e| CliError::Local(anyhow::anyhow!(e)))?
);
}
Format::Human => {
println!("saved {} steps to {}", trace.steps.len(), output.display());
}
}
Ok(())
}
fn render_stop(
result: &RecordStopResult,
output: &PathBuf,
format: Format,
) -> Result<(), CliError> {
render_finish(&result.trace, output, format)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_output_is_trace_json() {
let args = RecordStopArgs {
output: PathBuf::from("trace.json"),
};
assert_eq!(args.output, PathBuf::from("trace.json"));
}
#[test]
fn start_args_default_output_is_trace_json() {
let args = RecordStartArgs {
browser: None,
tab_id: None,
url: None,
purpose: None,
output: PathBuf::from("trace.json"),
};
assert_eq!(args.output, PathBuf::from("trace.json"));
}
#[test]
fn record_await_ipc_timeout_covers_long_wait() {
let got = record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS);
assert!(got >= Duration::from_secs(86_400));
}
}
+117
View File
@@ -0,0 +1,117 @@
//! Persist the session id between `bsk record start` and `bsk record stop`.
use std::fs;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::daemon::paths;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RecordSessionState {
pub session_id: String,
pub started_at: String,
}
pub fn write(session_id: &str) -> Result<()> {
let path = paths::record_session_path()?;
paths::ensure_bsk_home()?;
if path.exists() {
return Err(anyhow::anyhow!(
"a recording is already in progress; run `bsk record stop` first"
));
}
let state = RecordSessionState {
session_id: session_id.to_string(),
started_at: started_at_unix_ms(),
};
let json = serde_json::to_string_pretty(&state).context("serialize record session state")?;
fs::write(&path, format!("{json}\n")).with_context(|| format!("write {}", path.display()))?;
Ok(())
}
pub fn read() -> Result<RecordSessionState> {
let path = paths::record_session_path()?;
if !path.exists() {
return Err(anyhow::anyhow!(
"no recording in progress; run `bsk record start` first"
));
}
let raw = fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
serde_json::from_str(&raw).with_context(|| format!("parse {}", path.display()))
}
pub fn clear() {
if let Ok(path) = paths::record_session_path() {
let _ = fs::remove_file(path);
}
}
fn started_at_unix_ms() -> String {
let ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis();
format!("{ms}")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
fn env_guard() -> &'static Mutex<()> {
static GUARD: Mutex<()> = Mutex::new(());
&GUARD
}
fn with_temp_home<F: FnOnce()>(f: F) {
let _lock = env_guard().lock().unwrap_or_else(|e| e.into_inner());
let tmp = tempfile::tempdir().unwrap();
unsafe {
std::env::set_var(crate::daemon::paths::BSK_HOME_ENV, tmp.path());
}
f();
clear();
unsafe {
std::env::remove_var(crate::daemon::paths::BSK_HOME_ENV);
}
}
#[test]
fn write_read_round_trips() {
with_temp_home(|| {
write("abcd").unwrap();
let state = read().unwrap();
assert_eq!(state.session_id, "abcd");
assert!(!state.started_at.is_empty());
});
}
#[test]
fn write_rejects_duplicate() {
with_temp_home(|| {
write("abcd").unwrap();
let err = write("efgh").unwrap_err();
assert!(err.to_string().contains("already in progress"));
});
}
#[test]
fn read_errors_when_missing() {
with_temp_home(|| {
let err = read().unwrap_err();
assert!(err.to_string().contains("no recording in progress"));
});
}
#[test]
fn clear_removes_state_file() {
with_temp_home(|| {
write("abcd").unwrap();
clear();
assert!(read().is_err());
});
}
}
+1 -1
View File
@@ -93,7 +93,7 @@ pub fn info_for(code: ErrorCode) -> RenderInfo {
ErrorCode::NotFound => RenderInfo {
summary: "requested resource does not exist",
hint: Some(
"the session, tab, or browser may have stopped; run `bsk sessions` / `bsk browsers` to see current state",
"the session, tab, or browser may have stopped; run `bsk session list` / `bsk browsers` to see current state",
),
exit_code: 1,
},
+39 -21
View File
@@ -63,11 +63,11 @@ struct StartParams {
}
#[derive(Debug, Deserialize)]
struct StartReply {
session_id: String,
browser_instance_id: String,
pub struct StartReply {
pub session_id: String,
pub browser_instance_id: String,
#[serde(default)]
agent_window_id: Option<i64>,
pub agent_window_id: Option<i64>,
}
#[derive(Debug, Serialize)]
@@ -78,21 +78,21 @@ struct StopParams {
}
#[derive(Debug, Deserialize)]
struct StopReply {
stopped: Vec<String>,
pub struct StopReply {
pub stopped: Vec<String>,
#[serde(default)]
failed: Vec<StopFailure>,
pub failed: Vec<StopFailure>,
#[serde(default)]
returned_tab_ids: Vec<i64>,
pub returned_tab_ids: Vec<i64>,
#[serde(default)]
return_failures: Vec<ReturnFailure>,
pub return_failures: Vec<ReturnFailure>,
}
#[derive(Debug, Deserialize, Serialize)]
struct StopFailure {
session_id: String,
code: ErrorCode,
message: String,
pub struct StopFailure {
pub session_id: String,
pub code: ErrorCode,
pub message: String,
}
#[derive(Debug, Deserialize)]
@@ -113,14 +113,7 @@ pub fn dispatch(cmd: SessionCmd, format: Format) -> Result<(), CliError> {
}
fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<(), CliError> {
let result: Result<StartReply, CliError> = call(
sock,
Method::SessionStart,
Some(StartParams {
browser_instance_id: args.browser,
}),
Duration::from_secs(30),
);
let result = start_session(sock, args.browser);
match result {
Ok(reply) => match format {
Format::Json => {
@@ -143,6 +136,31 @@ fn run_start(sock: PathBuf, args: SessionStartArgs, format: Format) -> Result<()
Ok(())
}
/// Start a session and open the Agent Window. Used by `session start` and `record start`.
pub fn start_session(sock: PathBuf, browser: Option<String>) -> Result<StartReply, CliError> {
call(
sock,
Method::SessionStart,
Some(StartParams {
browser_instance_id: browser,
}),
Duration::from_secs(30),
)
}
/// Stop a single session by id.
pub fn stop_session(sock: PathBuf, session_id: &str) -> Result<StopReply, CliError> {
call(
sock,
Method::SessionStop,
Some(StopParams {
session_id: Some(session_id.to_string()),
all: false,
}),
SESSION_STOP_IPC_TIMEOUT,
)
}
/// Render a `session.start` failure (review I3).
///
/// The previous implementation hand-rolled `eprintln!("error: ...")`
+883
View File
@@ -0,0 +1,883 @@
//! `bsk update` — check for and install CLI updates.
use std::collections::BTreeMap;
use std::io::{Cursor, Read, Write};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result, bail};
use clap::Args;
use flate2::read::GzDecoder;
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cli::daemon::StartArgs;
use crate::cli::error::{CliError, Format};
pub const DEFAULT_MANIFEST_URL: &str =
"https://github.com/Tencent/BrowserSkill/releases/latest/download/version.json";
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
const ARCHIVE_FETCH_TIMEOUT: Duration = Duration::from_secs(60);
const UPDATE_CHECK_INTERVAL: Duration = Duration::from_secs(24 * 60 * 60);
#[derive(Debug, Clone)]
pub struct UpdateManifest {
pub version: Version,
pub tag: String,
pub release_url: Option<String>,
pub assets: BTreeMap<String, ManifestAsset>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ManifestAsset {
pub url: String,
pub sha256: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UpdateCandidate {
pub current: Version,
pub latest: Version,
pub tag: String,
pub release_url: Option<String>,
pub asset: ManifestAsset,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum InstallAction {
Replaced,
Staged,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StagedReplacementPaths {
pub binary_path: PathBuf,
pub script_path: PathBuf,
}
#[derive(Debug, Clone, Serialize)]
struct UpdateReport {
status: &'static str,
current_version: String,
latest_version: Option<String>,
release_url: Option<String>,
asset_url: Option<String>,
install_action: Option<&'static str>,
message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct UpdateCheckCache {
pub checked_at_epoch_secs: u64,
pub latest_version: String,
}
impl UpdateCheckCache {
pub fn is_fresh(&self, now_epoch_secs: u64, interval: Duration) -> bool {
now_epoch_secs.saturating_sub(self.checked_at_epoch_secs) <= interval.as_secs()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ArchiveKind {
TarGz,
Zip,
}
#[derive(Debug, Deserialize)]
struct RawManifest {
version: String,
tag: Option<String>,
release_url: Option<String>,
assets: BTreeMap<String, RawManifestAsset>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum RawManifestAsset {
Url(String),
Rich {
url: String,
#[serde(default)]
sha256: Option<String>,
},
}
impl UpdateManifest {
pub fn from_slice(bytes: &[u8]) -> Result<Self> {
let raw: RawManifest = serde_json::from_slice(bytes).context("parse update manifest")?;
let version = Version::parse(raw.version.trim_start_matches('v'))
.context("parse manifest version")?;
let tag = raw.tag.unwrap_or_else(|| format!("cli-v{version}"));
let assets = raw
.assets
.into_iter()
.map(|(platform, asset)| {
let asset = match asset {
RawManifestAsset::Url(url) => ManifestAsset { url, sha256: None },
RawManifestAsset::Rich { url, sha256 } => ManifestAsset { url, sha256 },
};
(platform, asset)
})
.collect();
Ok(Self {
version,
tag,
release_url: raw.release_url,
assets,
})
}
pub fn update_candidate(
&self,
current_version: &str,
platform_key: &str,
) -> Result<Option<UpdateCandidate>> {
let current = Version::parse(current_version.trim_start_matches('v'))
.context("parse current bsk version")?;
if self.version <= current {
return Ok(None);
}
let asset = self
.assets
.get(platform_key)
.with_context(|| format!("no bsk release asset for platform `{platform_key}`"))?
.clone();
Ok(Some(UpdateCandidate {
current,
latest: self.version.clone(),
tag: self.tag.clone(),
release_url: self.release_url.clone(),
asset,
}))
}
}
impl ArchiveKind {
pub fn from_url(url: &str) -> Result<Self> {
if url.ends_with(".tar.gz") || url.ends_with(".tgz") {
Ok(Self::TarGz)
} else if url.ends_with(".zip") {
Ok(Self::Zip)
} else {
bail!("unsupported bsk archive type: {url}");
}
}
}
#[derive(Debug, Clone, Args, Default)]
pub struct UpdateArgs {
/// Only check whether a newer version is available; do not install it.
#[arg(long)]
pub check: bool,
/// Skip interactive confirmation prompts.
#[arg(short = 'y', long)]
pub yes: bool,
/// Do not restart the daemon after replacing the CLI binary.
#[arg(long = "no-restart-daemon", default_value_t = true, action = clap::ArgAction::SetFalse)]
pub restart_daemon: bool,
}
pub fn dispatch(args: UpdateArgs, format: Format) -> Result<(), CliError> {
run(args, format).map_err(CliError::Local)
}
fn run(args: UpdateArgs, format: Format) -> Result<()> {
let client = update_http_client(ARCHIVE_FETCH_TIMEOUT)?;
let manifest = fetch_manifest_with_client(&client, &manifest_url())?;
let platform = current_platform_key()?;
let current_version = env!("CARGO_PKG_VERSION");
let Some(candidate) = manifest.update_candidate(current_version, platform)? else {
return render_report(
format,
&UpdateReport {
status: "up_to_date",
current_version: current_version.to_string(),
latest_version: Some(manifest.version.to_string()),
release_url: manifest.release_url,
asset_url: None,
install_action: None,
message: format!("bsk {current_version} is already up to date"),
},
);
};
if args.check {
return render_report(
format,
&UpdateReport {
status: "update_available",
current_version: candidate.current.to_string(),
latest_version: Some(candidate.latest.to_string()),
release_url: candidate.release_url.clone(),
asset_url: Some(candidate.asset.url.clone()),
install_action: None,
message: format!(
"bsk {} is available (current {})",
candidate.latest, candidate.current
),
},
);
}
if !args.yes && !confirm_update(&candidate)? {
return render_report(
format,
&UpdateReport {
status: "cancelled",
current_version: candidate.current.to_string(),
latest_version: Some(candidate.latest.to_string()),
release_url: candidate.release_url,
asset_url: Some(candidate.asset.url),
install_action: None,
message: "update cancelled".to_string(),
},
);
}
let action = install_candidate_with_client(&candidate, args.restart_daemon, &client)?;
let action_label = match action {
InstallAction::Replaced => "replaced",
InstallAction::Staged => "staged",
};
render_report(
format,
&UpdateReport {
status: "updated",
current_version: candidate.current.to_string(),
latest_version: Some(candidate.latest.to_string()),
release_url: candidate.release_url,
asset_url: Some(candidate.asset.url),
install_action: Some(action_label),
message: format!(
"updated bsk from {} to {}",
candidate.current, candidate.latest
),
},
)
}
pub fn current_platform_key() -> Result<&'static str> {
match (std::env::consts::OS, std::env::consts::ARCH) {
("macos", "aarch64") => Ok("darwin-arm64"),
("macos", "x86_64") => Ok("darwin-x64"),
("linux", "aarch64") => Ok("linux-arm64"),
("linux", "x86_64") => Ok("linux-x64"),
("windows", "x86_64") => Ok("windows-x64"),
(os, arch) => bail!("unsupported platform for bsk auto-update: {os}-{arch}"),
}
}
pub fn fetch_bytes(url: &str, timeout: Duration) -> Result<Vec<u8>> {
let client = update_http_client(timeout)?;
fetch_bytes_with_client(&client, url)
}
fn update_http_client(timeout: Duration) -> Result<reqwest::blocking::Client> {
reqwest::blocking::Client::builder()
.timeout(timeout)
.build()
.context("build update HTTP client")
}
fn fetch_bytes_with_client(client: &reqwest::blocking::Client, url: &str) -> Result<Vec<u8>> {
let response = client
.get(url)
.send()
.with_context(|| format!("download {url}"))?
.error_for_status()
.with_context(|| format!("download {url}"))?;
Ok(response
.bytes()
.context("read update response body")?
.to_vec())
}
pub fn fetch_manifest(url: &str) -> Result<UpdateManifest> {
let client = update_http_client(FETCH_TIMEOUT)?;
fetch_manifest_with_client(&client, url)
}
fn fetch_manifest_with_client(
client: &reqwest::blocking::Client,
url: &str,
) -> Result<UpdateManifest> {
let bytes = fetch_bytes_with_client(client, url)?;
UpdateManifest::from_slice(&bytes)
}
fn install_candidate_with_client(
candidate: &UpdateCandidate,
restart_daemon: bool,
client: &reqwest::blocking::Client,
) -> Result<InstallAction> {
let expected_sha = candidate.asset.sha256.as_deref().with_context(|| {
format!(
"release {} does not include a sha256 checksum; cannot safely auto-update",
candidate.tag
)
})?;
let archive = fetch_bytes_with_client(client, &candidate.asset.url)?;
verify_sha256(&archive, expected_sha)?;
let kind = ArchiveKind::from_url(&candidate.asset.url)?;
let binary = extract_bsk_binary(&archive, kind)?;
let target = std::env::current_exe().context("locate current bsk executable")?;
let daemon_was_running = restart_daemon && crate::daemon::info::read_valid()?.is_some();
if daemon_was_running {
crate::daemon::start::run_stop().context("stop bsk daemon before update")?;
}
let action = replace_binary_at_path(&target, &binary)?;
if daemon_was_running && matches!(action, InstallAction::Replaced) {
crate::daemon::start::run_start(StartArgs::default())
.context("restart bsk daemon after update")?;
}
Ok(action)
}
pub fn verify_sha256(bytes: &[u8], expected_hex: &str) -> Result<()> {
let actual = hex_sha256(bytes);
let expected = expected_hex.trim().to_ascii_lowercase();
if actual != expected {
bail!("downloaded bsk archive checksum mismatch: expected {expected}, got {actual}");
}
Ok(())
}
fn manifest_url() -> String {
std::env::var("BSK_UPDATE_MANIFEST_URL").unwrap_or_else(|_| DEFAULT_MANIFEST_URL.to_string())
}
pub fn update_hint_for_manifest(
manifest: &UpdateManifest,
current_version: &str,
platform_key: &str,
) -> Result<Option<String>> {
Ok(manifest
.update_candidate(current_version, platform_key)?
.map(|candidate| {
format!(
"A new bsk version is available: {} -> {}. Run `bsk update`.",
candidate.current, candidate.latest
)
}))
}
fn update_hint_for_cache(cache: &UpdateCheckCache, current_version: &str) -> Option<String> {
let latest = cache.latest_version.as_str();
let current = Version::parse(current_version.trim_start_matches('v')).ok()?;
let latest_version = Version::parse(latest.trim_start_matches('v')).ok()?;
(latest_version > current).then(|| {
format!("A new bsk version is available: {current} -> {latest_version}. Run `bsk update`.")
})
}
pub fn read_update_cache(path: &Path) -> Result<Option<UpdateCheckCache>> {
match std::fs::read(path) {
Ok(bytes) => {
let cache = serde_json::from_slice(&bytes)
.with_context(|| format!("parse {}", path.display()))?;
Ok(Some(cache))
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(anyhow::Error::from(err).context(format!("read {}", path.display()))),
}
}
pub fn write_update_cache(path: &Path, cache: &UpdateCheckCache) -> Result<()> {
let dir = path
.parent()
.with_context(|| format!("cache path has no parent: {}", path.display()))?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let tmp = path.with_file_name(format!(
"{}.tmp.{}",
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("update-check.json"),
std::process::id()
));
let payload = serde_json::to_vec_pretty(cache).context("encode update cache")?;
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
file.write_all(&payload)
.with_context(|| format!("write {}", tmp.display()))?;
file.flush()
.with_context(|| format!("flush {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("sync {}", tmp.display()))?;
}
std::fs::rename(&tmp, path)
.with_context(|| format!("rename {} to {}", tmp.display(), path.display()))?;
sync_dir(dir);
Ok(())
}
fn now_epoch_secs() -> u64 {
SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.map(|duration| duration.as_secs())
.unwrap_or(0)
}
fn refresh_update_cache(cache_path: &Path) -> Result<Option<String>> {
let manifest = fetch_manifest(&manifest_url())?;
let platform = current_platform_key()?;
let hint = update_hint_for_manifest(&manifest, env!("CARGO_PKG_VERSION"), platform)?;
write_update_cache(
cache_path,
&UpdateCheckCache {
checked_at_epoch_secs: now_epoch_secs(),
latest_version: manifest.version.to_string(),
},
)?;
Ok(hint)
}
pub fn maybe_spawn_background_check(flags: &super::GlobalFlags, command: &super::Command) {
if flags.quiet
|| flags.json
|| matches!(
command,
super::Command::Daemon(_) | super::Command::Update(_)
)
{
return;
}
let Ok(cache_path) = crate::daemon::paths::update_check_path() else {
return;
};
let now = now_epoch_secs();
match read_update_cache(&cache_path) {
Ok(Some(cache)) => {
if let Some(hint) = update_hint_for_cache(&cache, env!("CARGO_PKG_VERSION")) {
eprintln!("{hint}");
}
if cache.is_fresh(now, UPDATE_CHECK_INTERVAL) {
return;
}
}
Ok(None) => {}
Err(err) => {
tracing::debug!(error = %err, "update cache read failed");
}
}
let _ = std::thread::Builder::new()
.name("bsk-update-check".to_string())
.spawn(move || match refresh_update_cache(&cache_path) {
Ok(Some(hint)) => eprintln!("{hint}"),
Ok(None) => {}
Err(err) => tracing::debug!(error = %err, "background update check failed"),
});
}
fn confirm_update(candidate: &UpdateCandidate) -> Result<bool> {
dialoguer::Confirm::new()
.with_prompt(format!(
"Update bsk from {} to {}?",
candidate.current, candidate.latest
))
.default(true)
.interact()
.context("read update confirmation")
}
fn render_report(format: Format, report: &UpdateReport) -> Result<()> {
match format {
Format::Human => {
println!("{}", report.message);
if let Some(release_url) = &report.release_url {
println!("release: {release_url}");
}
if matches!(report.install_action, Some("staged")) {
println!(
"restart your terminal and run `bsk --version` to verify the staged update"
);
}
}
Format::Json => {
println!(
"{}",
serde_json::to_string_pretty(report).context("encode update report as JSON")?
);
}
}
Ok(())
}
pub fn extract_bsk_binary(archive_bytes: &[u8], kind: ArchiveKind) -> Result<Vec<u8>> {
match kind {
ArchiveKind::TarGz => extract_bsk_from_tar_gz(archive_bytes),
ArchiveKind::Zip => extract_bsk_from_zip(archive_bytes),
}
}
pub fn replace_binary_at_path(target: &Path, binary: &[u8]) -> Result<InstallAction> {
#[cfg(windows)]
{
stage_windows_replacement(target, binary)
}
#[cfg(not(windows))]
{
replace_binary_atomically(target, binary)
}
}
#[cfg(not(windows))]
fn replace_binary_atomically(target: &Path, binary: &[u8]) -> Result<InstallAction> {
let dir = target
.parent()
.with_context(|| format!("target path has no parent: {}", target.display()))?;
std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.context("target path must have a UTF-8 file name")?;
let tmp = target.with_file_name(format!(".{file_name}.update-{}.tmp", std::process::id()));
{
let mut file =
std::fs::File::create(&tmp).with_context(|| format!("create {}", tmp.display()))?;
file.write_all(binary)
.with_context(|| format!("write {}", tmp.display()))?;
file.flush()
.with_context(|| format!("flush {}", tmp.display()))?;
file.sync_all()
.with_context(|| format!("sync {}", tmp.display()))?;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o755))
.with_context(|| format!("chmod 0755 {}", tmp.display()))?;
}
std::fs::rename(&tmp, target)
.with_context(|| format!("rename {} to {}", tmp.display(), target.display()))?;
sync_dir(dir);
Ok(InstallAction::Replaced)
}
#[cfg(windows)]
fn stage_windows_replacement(target: &Path, binary: &[u8]) -> Result<InstallAction> {
let paths = staged_replacement_paths(target, std::process::id())?;
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),
)
.with_context(|| format!("write {}", paths.script_path.display()))?;
Ok(InstallAction::Staged)
}
pub fn staged_replacement_paths(target: &Path, pid: u32) -> Result<StagedReplacementPaths> {
let file_name = target
.file_name()
.and_then(|name| name.to_str())
.context("target path must have a UTF-8 file name")?;
let binary_path = target.with_file_name(format!("{file_name}.update-{pid}"));
let script_path = target.with_file_name(format!("{file_name}.update-{pid}.cmd"));
Ok(StagedReplacementPaths {
binary_path,
script_path,
})
}
#[cfg(windows)]
fn windows_replacement_script(target: &Path, staged_binary: &Path) -> String {
format!(
"@echo off\r\n\
setlocal\r\n\
:retry\r\n\
move /Y \"{}\" \"{}\" >nul 2>nul\r\n\
if errorlevel 1 (\r\n\
timeout /t 1 /nobreak >nul\r\n\
goto retry\r\n\
)\r\n",
staged_binary.display(),
target.display()
)
}
fn sync_dir(dir: &Path) {
if let Ok(dir_file) = std::fs::File::open(dir) {
let _ = dir_file.sync_all();
}
}
fn extract_bsk_from_tar_gz(archive_bytes: &[u8]) -> Result<Vec<u8>> {
let decoder = GzDecoder::new(Cursor::new(archive_bytes));
let mut archive = tar::Archive::new(decoder);
for entry in archive.entries().context("read bsk tar.gz entries")? {
let mut entry = entry.context("read bsk tar.gz entry")?;
let path = entry.path().context("read bsk tar.gz entry path")?;
if path.file_name().is_some_and(|name| name == "bsk") {
let mut bytes = Vec::new();
entry
.read_to_end(&mut bytes)
.context("read bsk binary from tar.gz")?;
return Ok(bytes);
}
}
bail!("bsk binary not found in tar.gz archive");
}
fn extract_bsk_from_zip(archive_bytes: &[u8]) -> Result<Vec<u8>> {
let cursor = Cursor::new(archive_bytes);
let mut archive = zip::ZipArchive::new(cursor).context("read bsk zip archive")?;
for index in 0..archive.len() {
let mut file = archive
.by_index(index)
.with_context(|| format!("read zip entry {index}"))?;
let name = std::path::Path::new(file.name())
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("");
if name == "bsk.exe" || name == "bsk" {
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)
.context("read bsk binary from zip")?;
return Ok(bytes);
}
}
bail!("bsk binary not found in zip archive");
}
fn hex_sha256(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(digest.len() * 2);
for byte in digest {
use std::fmt::Write as _;
let _ = write!(out, "{byte:02x}");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use flate2::Compression;
use flate2::write::GzEncoder;
use std::io::Write;
#[test]
fn parses_new_and_legacy_manifest_asset_shapes() {
let manifest = UpdateManifest::from_slice(
br#"{
"name": "bsk",
"version": "0.2.0",
"tag": "cli-v0.2.0",
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
"assets": {
"darwin-arm64": {
"url": "https://example.test/bsk.tar.gz",
"sha256": "abc123"
},
"linux-x64": "https://example.test/legacy.tar.gz"
}
}"#,
)
.unwrap();
assert_eq!(manifest.version.to_string(), "0.2.0");
assert_eq!(
manifest.assets["darwin-arm64"].url,
"https://example.test/bsk.tar.gz"
);
assert_eq!(
manifest.assets["darwin-arm64"].sha256.as_deref(),
Some("abc123")
);
assert_eq!(
manifest.assets["linux-x64"].url,
"https://example.test/legacy.tar.gz"
);
assert!(manifest.assets["linux-x64"].sha256.is_none());
}
#[test]
fn classifies_newer_manifest_as_update_candidate() {
let manifest = UpdateManifest::from_slice(
br#"{
"name": "bsk",
"version": "0.2.0",
"tag": "cli-v0.2.0",
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
"assets": {
"linux-x64": {
"url": "https://example.test/bsk.tar.gz",
"sha256": "abc123"
}
}
}"#,
)
.unwrap();
let candidate = manifest
.update_candidate("0.1.7", "linux-x64")
.unwrap()
.unwrap();
assert_eq!(candidate.latest.to_string(), "0.2.0");
assert_eq!(candidate.asset.sha256.as_deref(), Some("abc123"));
assert!(
manifest
.update_candidate("0.2.0", "linux-x64")
.unwrap()
.is_none()
);
}
#[test]
fn verifies_sha256_before_installing_archive() {
let bytes = b"archive bytes";
let expected = hex_sha256(bytes);
verify_sha256(bytes, &expected).unwrap();
assert!(verify_sha256(bytes, "0000").is_err());
}
#[test]
fn extracts_bsk_binary_from_tar_gz_archive() {
let archive = tar_gz_with_bsk(b"new binary");
let extracted = extract_bsk_binary(&archive, ArchiveKind::TarGz).unwrap();
assert_eq!(extracted, b"new binary");
}
#[test]
fn extracts_bsk_binary_from_zip_archive() {
let archive = zip_with_bsk_exe(b"windows binary");
let extracted = extract_bsk_binary(&archive, ArchiveKind::Zip).unwrap();
assert_eq!(extracted, b"windows binary");
}
#[test]
fn replaces_binary_at_path() {
let tmp = tempfile::TempDir::new().unwrap();
let target = tmp.path().join("bsk");
std::fs::write(&target, b"old binary").unwrap();
let action = replace_binary_at_path(&target, b"new binary").unwrap();
assert_eq!(std::fs::read(&target).unwrap(), b"new binary");
assert!(matches!(action, InstallAction::Replaced));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&target).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o755);
}
}
#[test]
fn staged_replacement_paths_are_next_to_target() {
let target = std::path::Path::new("/tmp/bsk.exe");
let paths = staged_replacement_paths(target, 42).unwrap();
assert_eq!(
paths.binary_path,
std::path::Path::new("/tmp/bsk.exe.update-42")
);
assert_eq!(
paths.script_path,
std::path::Path::new("/tmp/bsk.exe.update-42.cmd")
);
}
#[test]
fn cache_freshness_uses_epoch_seconds() {
let cache = UpdateCheckCache {
checked_at_epoch_secs: 100,
latest_version: "0.2.0".to_string(),
};
assert!(cache.is_fresh(120, Duration::from_secs(30)));
assert!(!cache.is_fresh(131, Duration::from_secs(30)));
}
#[test]
fn update_hint_is_generated_only_for_newer_versions() {
let manifest = UpdateManifest::from_slice(
br#"{
"name": "bsk",
"version": "0.2.0",
"tag": "cli-v0.2.0",
"release_url": "https://github.com/Tencent/BrowserSkill/releases/tag/cli-v0.2.0",
"assets": {
"linux-x64": {
"url": "https://example.test/bsk.tar.gz",
"sha256": "abc123"
}
}
}"#,
)
.unwrap();
let hint = update_hint_for_manifest(&manifest, "0.1.7", "linux-x64").unwrap();
assert_eq!(
hint.as_deref(),
Some("A new bsk version is available: 0.1.7 -> 0.2.0. Run `bsk update`.")
);
assert!(
update_hint_for_manifest(&manifest, "0.2.0", "linux-x64")
.unwrap()
.is_none()
);
}
#[test]
fn update_check_cache_round_trips_to_disk() {
let tmp = tempfile::TempDir::new().unwrap();
let path = tmp.path().join("update-check.json");
let cache = UpdateCheckCache {
checked_at_epoch_secs: 123,
latest_version: "0.2.0".to_string(),
};
write_update_cache(&path, &cache).unwrap();
assert_eq!(read_update_cache(&path).unwrap(), Some(cache));
}
fn tar_gz_with_bsk(binary: &[u8]) -> Vec<u8> {
let mut tar_bytes = Vec::new();
{
let mut builder = tar::Builder::new(&mut tar_bytes);
let mut header = tar::Header::new_gnu();
header.set_size(binary.len() as u64);
header.set_mode(0o755);
header.set_cksum();
builder.append_data(&mut header, "bsk", binary).unwrap();
builder.finish().unwrap();
}
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&tar_bytes).unwrap();
encoder.finish().unwrap()
}
fn zip_with_bsk_exe(binary: &[u8]) -> Vec<u8> {
let mut cursor = Cursor::new(Vec::new());
{
let mut writer = zip::ZipWriter::new(&mut cursor);
writer
.start_file("bsk.exe", zip::write::SimpleFileOptions::default())
.unwrap();
writer.write_all(binary).unwrap();
writer.finish().unwrap();
}
cursor.into_inner()
}
}
+18 -5
View File
@@ -229,6 +229,7 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolTabReturn
| Method::ToolScreenshot
| Method::ToolConsole
| Method::ToolNetwork
| Method::ToolSnapshot
| Method::ToolGetHtml
| Method::ToolNavigate
@@ -241,7 +242,10 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
| Method::ToolSelect
| Method::ToolEvaluate
| Method::ToolWaitForNavigation
| Method::ToolRequestHelp => {
| Method::ToolRequestHelp
| Method::ToolRecordStart
| Method::ToolRecordStop
| Method::ToolRecordAwait => {
handle_tool_dispatch(&state, rpc_id, method, params).await
}
Method::ToolWaitMs => handle_wait_ms(&state.abort_registry, rpc_id, params).await,
@@ -318,10 +322,19 @@ async fn handle_tool_dispatch(
}
};
let entry = inflight_guard.entry();
let outcome = state
.tool_queues
.dispatch(&session_id, method, params, timeout, Some(entry))
.await;
// `record_stop` must reach the extension while `record_await` holds the
// serial busy lock — finishing the recording unblocks await.
let outcome = if method == Method::ToolRecordStop {
state
.tool_queues
.dispatch_unlocked(&session_id, method, params, timeout, Some(entry))
.await
} else {
state
.tool_queues
.dispatch(&session_id, method, params, timeout, Some(entry))
.await
};
drop(inflight_guard);
match outcome {
Ok(v) => ResponseBody::Ok(v),
+7 -1
View File
@@ -44,10 +44,16 @@ pub async fn run(
Some(path) => Some(ipc::IpcServer::new(Arc::clone(&state)).bind(path).await?),
None => None,
};
let session_idle_task = start::spawn_session_idle_reaper(Arc::clone(&state));
// Ensure WS/IPC accept loops have polled `shutdown.notified()` before any
// caller can invoke `DaemonHandle::shutdown()` — `Notify::notify_waiters()`
// drops wakeups when nothing is registered yet, which otherwise hangs
// shutdown forever (hit by tests that connect/shutdown immediately).
tokio::task::yield_now().await;
Ok(DaemonHandle::new(state, ws_handle, ipc_handle))
Ok(DaemonHandle::new(
state,
ws_handle,
ipc_handle,
session_idle_task,
))
}
+14
View File
@@ -81,6 +81,10 @@ pub fn log_path() -> Result<PathBuf> {
Ok(bsk_home()?.join("daemon.log"))
}
pub fn update_check_path() -> Result<PathBuf> {
Ok(bsk_home()?.join("update-check.json"))
}
pub fn log_dir() -> Result<PathBuf> {
bsk_home()
}
@@ -91,6 +95,11 @@ pub fn sock_path() -> Result<PathBuf> {
Ok(bsk_home()?.join("run").join("daemon.sock"))
}
/// Path to the in-progress recording session state (`record-session.json`).
pub fn record_session_path() -> Result<PathBuf> {
Ok(bsk_home()?.join("record-session.json"))
}
/// Windows named-pipe name. Include the resolved `BSK_HOME` path in the
/// token so test homes and custom installs do not share a predictable
/// per-username pipe.
@@ -157,7 +166,12 @@ mod tests {
assert_eq!(lock_path().unwrap(), home.join("daemon.lock"));
assert_eq!(info_path().unwrap(), home.join("daemon.json"));
assert_eq!(log_path().unwrap(), home.join("daemon.log"));
assert_eq!(update_check_path().unwrap(), home.join("update-check.json"));
assert_eq!(sock_path().unwrap(), home.join("run").join("daemon.sock"));
assert_eq!(
record_session_path().unwrap(),
home.join("record-session.json")
);
});
}
}
+112 -6
View File
@@ -218,9 +218,14 @@ impl ToolQueueRegistry {
/// Whether `sid`'s queue is still accepting new tool dispatches.
pub fn is_accepting(&self, sid: &SessionId) -> bool {
let guard = self.queues.lock().expect("tool queue registry poisoned");
guard
.get(sid)
.is_some_and(|entry| entry.accepting)
guard.get(sid).is_some_and(|entry| entry.accepting)
}
#[cfg(test)]
fn force_busy_for_tests(&self, sid: &SessionId, busy: bool) {
let guard = self.queues.lock().expect("tool queue registry poisoned");
let entry = guard.get(sid).expect("queue must exist");
entry.state.lock().expect("queue state poisoned").busy = busy;
}
/// Spawn the worker task for a session id. Idempotent: spawning the
@@ -302,7 +307,8 @@ impl ToolQueueRegistry {
queue_state.busy = true;
(entry.sender.clone(), Arc::clone(&entry.state))
};
dispatch_with_sender(
self.sessions.touch(sid);
let outcome = dispatch_with_sender(
sender,
state,
sid.clone(),
@@ -312,7 +318,49 @@ impl ToolQueueRegistry {
false,
inflight,
)
.await
.await;
// A long-running request may outlive the idle threshold. Touching
// after completion prevents the reaper from immediately closing it;
// while it is running, session.stop observes SessionBusy and retries
// on a later sweep rather than interrupting the tool.
self.sessions.touch(sid);
outcome
}
/// Forward a tool RPC to the extension without taking the
/// per-session busy lock. Used by `tool.record_stop` so a second
/// CLI can finish an in-flight `tool.record_await` (extension
/// dispatch already runs concurrent `void dispatch(msg)` handlers).
pub async fn dispatch_unlocked(
&self,
sid: &SessionId,
method: Method,
params: Value,
timeout: Duration,
inflight: Option<Arc<ToolInflightEntry>>,
) -> Result<Value, DispatchError> {
{
let guard = self.queues.lock().expect("tool queue registry poisoned");
let Some(entry) = guard.get(sid) else {
return Err(DispatchError::SessionNotFound);
};
if !entry.accepting {
return Err(DispatchError::SessionStopping);
}
}
let (respond_tx, _respond_rx) = oneshot::channel();
let job = ToolJob {
method,
params,
timeout,
respond: respond_tx,
inflight,
session_id: sid.clone(),
};
match forward_one(sid, &self.browsers, &self.sessions, &job).await {
Ok(v) => Ok(v),
Err(err) => Err(DispatchError::Rpc(err)),
}
}
/// Stop accepting new jobs for `sid`, enqueue one final control RPC,
@@ -342,10 +390,21 @@ impl ToolQueueRegistry {
entry.accepting = false;
(entry.sender.clone(), Arc::clone(&entry.state))
};
dispatch_with_sender(sender, state, sid.clone(), method, params, timeout, true, None).await
dispatch_with_sender(
sender,
state,
sid.clone(),
method,
params,
timeout,
true,
None,
)
.await
}
}
#[allow(clippy::too_many_arguments)]
async fn dispatch_with_sender(
sender: mpsc::Sender<ToolJob>,
state: Arc<Mutex<QueueState>>,
@@ -773,3 +832,50 @@ mod tool_job_session_id_tests {
assert_eq!(job.session_id.0, "sess-A");
}
}
#[cfg(test)]
mod dispatch_unlocked_tests {
use super::*;
use crate::daemon::browsers::BrowserRegistry;
use crate::daemon::sessions::{SessionId, SessionRegistry};
use bsk_protocol::Method;
use serde_json::json;
use std::sync::Arc;
#[tokio::test]
async fn unlocked_dispatch_skips_session_busy_gate() {
let browsers = Arc::new(BrowserRegistry::new());
let sessions = Arc::new(SessionRegistry::new());
let queues = ToolQueueRegistry::new(browsers, sessions);
let sid = SessionId("s-record".into());
queues.spawn(sid.clone());
queues.force_busy_for_tests(&sid, true);
let busy = queues
.dispatch(
&sid,
Method::ToolTabList,
json!({ "session_id": "s-record" }),
Duration::from_secs(1),
None,
)
.await;
assert!(matches!(busy, Err(DispatchError::SessionBusy)));
let unlocked = queues
.dispatch_unlocked(
&sid,
Method::ToolRecordStop,
json!({ "session_id": "s-record" }),
Duration::from_secs(1),
None,
)
.await;
assert!(
!matches!(unlocked, Err(DispatchError::SessionBusy)),
"record_stop must not be rejected as SessionBusy; got {unlocked:?}"
);
// No session row → forward_one returns NotFound as Rpc.
assert!(matches!(unlocked, Err(DispatchError::Rpc(_))));
}
}
+69 -6
View File
@@ -6,7 +6,7 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Instant, SystemTime, UNIX_EPOCH};
use bsk_protocol::system::{BrowserStatusEntry, SessionStatusEntry};
use bsk_protocol::tools::{
@@ -66,6 +66,9 @@ impl Session {
#[derive(Debug, Default)]
pub struct SessionRegistry {
inner: Mutex<HashMap<SessionId, Session>>,
/// Operational metadata kept outside the public `Session` wire/domain
/// shape so idle enforcement does not break external struct users.
last_activity: Mutex<HashMap<SessionId, Instant>>,
}
impl SessionRegistry {
@@ -100,10 +103,13 @@ impl SessionRegistry {
}
pub fn insert(&self, session: Session) {
self.inner
let session_id = session.id.clone();
let mut sessions = self.inner.lock().expect("session registry poisoned");
sessions.insert(session_id.clone(), session);
self.last_activity
.lock()
.expect("session registry poisoned")
.insert(session.id.clone(), session);
.expect("session activity registry poisoned")
.insert(session_id, Instant::now());
}
/// Reserve a fresh, collision-free [`SessionId`] under the registry
@@ -141,6 +147,10 @@ impl SessionRegistry {
created_at_ms: now_ms_fn(),
},
);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.insert(candidate.clone(), Instant::now());
return Some(candidate);
}
None
@@ -168,13 +178,23 @@ impl SessionRegistry {
.lock()
.expect("session registry poisoned")
.remove(session_id);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.remove(session_id);
}
pub fn remove(&self, id: &SessionId) -> Option<Session> {
self.inner
let removed = self
.inner
.lock()
.expect("session registry poisoned")
.remove(id)
.remove(id);
self.last_activity
.lock()
.expect("session activity registry poisoned")
.remove(id);
removed
}
pub fn get(&self, id: &SessionId) -> Option<Session> {
@@ -185,6 +205,42 @@ impl SessionRegistry {
.cloned()
}
/// Record accepted or completed tool activity for a live session.
pub fn touch(&self, id: &SessionId) -> bool {
if !self
.inner
.lock()
.expect("session registry poisoned")
.contains_key(id)
{
return false;
}
self.last_activity
.lock()
.expect("session activity registry poisoned")
.insert(id.clone(), Instant::now());
true
}
/// Return sessions whose last tool activity is at least `idle_for`
/// old. The caller supplies `now` to keep boundary tests deterministic.
pub fn idle_ids_at(&self, idle_for: Duration, now: Instant) -> Vec<SessionId> {
let sessions = self.inner.lock().expect("session registry poisoned");
let activity = self
.last_activity
.lock()
.expect("session activity registry poisoned");
sessions
.values()
.filter(|session| {
activity
.get(&session.id)
.is_some_and(|last| now.saturating_duration_since(*last) >= idle_for)
})
.map(|session| session.id.clone())
.collect()
}
/// Drop all sessions owned by `browser_id` (e.g. on disconnect).
pub fn purge_browser(&self, browser_id: &BrowserId) -> Vec<Session> {
let mut guard = self.inner.lock().expect("session registry poisoned");
@@ -196,6 +252,13 @@ impl SessionRegistry {
for s in &drained {
guard.remove(&s.id);
}
let mut activity = self
.last_activity
.lock()
.expect("session activity registry poisoned");
for session in &drained {
activity.remove(&session.id);
}
drained
}
+57 -2
View File
@@ -23,8 +23,11 @@ use tracing::{debug, info, warn};
use crate::cli::daemon::StartArgs;
use crate::daemon::{
browsers::EXTENSION_CONNECT_WAIT, info as daemon_info, ipc, lockfile, paths,
state::DaemonState, ws,
browsers::EXTENSION_CONNECT_WAIT,
info as daemon_info, ipc, lockfile, paths,
sessions::{StopSessionError, forget_session, stop_session},
state::DaemonState,
ws,
};
/// Internal env-var contract: the parent sets this on the spawned child
@@ -196,6 +199,7 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
.with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?;
let state = Arc::new(DaemonState::new(cfg.clone()));
let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
let ws_addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), cfg.ws_port);
let ws_handle = ws::WsServer::new(Arc::clone(&state))
.bind(ws_addr)
@@ -355,6 +359,8 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
let _ = ipc_shutdown_tx.send(());
let _ = ipc_task.await;
session_idle_task.abort();
let _ = session_idle_task.await;
ws_handle.shutdown.notify_waiters();
let _ = ws_handle.task.await;
@@ -367,6 +373,55 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
Ok(())
}
/// Spawn the cooperative session-idle reaper shared by the production
/// foreground daemon and the test/embed daemon entry point.
pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
let session_idle = state.config.session_idle;
let tick = (session_idle / 4)
.max(Duration::from_millis(100))
.min(Duration::from_secs(30));
let mut ticker = tokio::time::interval(tick);
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
// `interval`'s first tick is immediate. Consume it so a zero/very
// short test setting still gets one real inactivity window.
ticker.tick().await;
loop {
ticker.tick().await;
let idle_ids = state.sessions.idle_ids_at(session_idle, Instant::now());
for session_id in idle_ids {
match stop_session(
&state.browsers,
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&session_id,
Duration::from_secs(10),
)
.await
{
Ok(_) => info!(session = %session_id, "idle session stopped"),
Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => {
debug!(session = %session_id, "idle session still active; retrying later");
}
Err(StopSessionError::NotFound | StopSessionError::BrowserGone) => {
forget_session(
&state.sessions,
&state.tool_queues,
&state.session_interrupts,
&session_id,
);
}
Err(err) => {
warn!(session = %session_id, error = %err, "failed to stop idle session");
}
}
}
}
})
}
fn record_activity(activity: &Arc<Mutex<Instant>>) {
if let Ok(mut a) = activity.lock() {
*a = Instant::now();
+15 -2
View File
@@ -81,11 +81,22 @@ pub struct DaemonHandle {
state: Arc<DaemonState>,
ws: WsHandle,
ipc: Option<IpcHandle>,
session_idle_task: JoinHandle<()>,
}
impl DaemonHandle {
pub(crate) fn new(state: Arc<DaemonState>, ws: WsHandle, ipc: Option<IpcHandle>) -> Self {
Self { state, ws, ipc }
pub(crate) fn new(
state: Arc<DaemonState>,
ws: WsHandle,
ipc: Option<IpcHandle>,
session_idle_task: JoinHandle<()>,
) -> Self {
Self {
state,
ws,
ipc,
session_idle_task,
}
}
pub fn state(&self) -> Arc<DaemonState> {
@@ -103,6 +114,8 @@ impl DaemonHandle {
/// Stop the WS server (and IPC if running). Returns once both join
/// handles complete.
pub async fn shutdown(self) {
self.session_idle_task.abort();
let _ = await_join(self.session_idle_task).await;
self.ws.shutdown.notify_waiters();
let _ = await_join(self.ws.task).await;
if let Some(ipc) = self.ipc {
+2 -1
View File
@@ -124,7 +124,8 @@ async fn handle_connection(
let captured_origin: std::sync::Arc<std::sync::Mutex<Option<String>>> =
std::sync::Arc::new(std::sync::Mutex::new(None));
let captured_origin_for_cb = std::sync::Arc::clone(&captured_origin);
let callback = move |req: &Request, response: Response| -> Result<Response, ErrorResponse> {
#[allow(clippy::result_large_err)]
let callback = move |req: &Request, response: Response| {
let origin = req
.headers()
.get("Origin")
+10 -3
View File
@@ -25,6 +25,7 @@ fn main() -> ExitCode {
if !matches!(cli.command, Command::Daemon(_)) {
init_cli_tracing(&cli.flags);
}
cli::update::maybe_spawn_background_check(&cli.flags, &cli.command);
let format = if cli.flags.json {
Format::Json
@@ -55,9 +56,12 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
} else {
Output::Human
};
cli::doctor::run(output)
.map(|_| ())
.map_err(CliError::Local)
let checks = cli::doctor::run(output).map_err(CliError::Local)?;
if cli::doctor::has_failures(&checks) {
Err(CliError::RenderedExit { exit_code: 1 })
} else {
Ok(())
}
}
Command::InstallSkill(args) => {
let output = if cli.flags.json {
@@ -67,6 +71,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
};
cli::install_skill::dispatch(args, output)
}
Command::Update(args) => cli::update::dispatch(args, format),
Command::Logs(cmd) => cli::logs::run(cli::logs::LogsArgs {
follow: cmd.follow,
lines: cmd.lines,
@@ -78,6 +83,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
Command::Screenshot(args) => cli::screenshot::dispatch(args, format),
Command::Snapshot(args) => cli::snapshot::dispatch(args, format),
Command::Console(args) => cli::console::dispatch(args, format),
Command::Network(args) => cli::network::dispatch(args, format),
Command::GetHtml(args) => cli::get_html::dispatch(args, format),
Command::Navigate(args) => cli::navigate::dispatch_navigate_command(args, format),
Command::NavigateBack(args) => cli::navigate::dispatch_navigate_back(args, format),
@@ -91,6 +97,7 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format),
Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format),
Command::RequestHelp(args) => cli::human_loop::dispatch(args, format),
Command::Record(cmd) => cli::record::dispatch(cmd, format),
}
}
@@ -16,7 +16,6 @@ use bsk_protocol::system::BrowserStatusEntry;
use bsk_protocol::{ErrorCode, Method};
use rand::Rng;
use serde::Deserialize;
use tokio::net::TcpListener;
use tokio::sync::mpsc;
fn tempfile_path(prefix: &str) -> PathBuf {
@@ -30,9 +29,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-list");
+1 -4
View File
@@ -15,7 +15,6 @@ use bsk_protocol::system::{BrowserListParams, HandshakeParams, HandshakeResult,
use bsk_protocol::{BrowserPeerInfo, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
@@ -77,9 +76,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-browser-wait");
+3 -10
View File
@@ -20,14 +20,11 @@ use bsk_protocol::{
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
use support::{
wait_for_abort_registered, wait_for_inflight_forwarded, wait_until,
};
use support::{wait_for_abort_registered, wait_for_inflight_forwarded, wait_until};
const TEST_EXT_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
@@ -42,9 +39,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-cancel");
@@ -551,9 +546,7 @@ async fn cancel_arriving_during_promote_critical_section_keeps_request_cancel_in
wait_until(
"extension snapshot/cancel counters to match",
Duration::from_secs(2),
|| {
snapshots_clone.load(AOrdering::SeqCst) == cancels_clone.load(AOrdering::SeqCst)
},
|| snapshots_clone.load(AOrdering::SeqCst) == cancels_clone.load(AOrdering::SeqCst),
)
.await;
+81
View File
@@ -4,6 +4,7 @@ use std::time::Duration;
use bsk::cli::daemon::{DaemonCmd, parse_duration};
use bsk::cli::navigate::NavigateCmd;
use bsk::cli::record::{RecordCmd, RecordSub};
use bsk::{Cli, Command};
use clap::Parser;
@@ -104,12 +105,58 @@ fn rejects_zero_console_bounds() {
);
}
#[test]
fn parses_network_command_with_context_safety_flags() {
let cli = parse(&[
"bsk",
"network",
"--session",
"s1",
"--tab-id",
"9",
"--since",
"12",
"--limit",
"75",
"--max-text-chars",
"2048",
]);
let Command::Network(args) = cli.command else {
panic!("expected network command");
};
assert_eq!(args.session, "s1");
assert_eq!(args.tab_id, Some(9));
assert_eq!(args.since, Some(12));
assert_eq!(args.limit, Some(75));
assert_eq!(args.max_text_chars, Some(2048));
}
#[test]
fn rejects_zero_network_bounds() {
assert!(Cli::try_parse_from(["bsk", "network", "--session", "s1", "--limit", "0"]).is_err());
assert!(
Cli::try_parse_from(["bsk", "network", "--session", "s1", "--max-text-chars", "0"])
.is_err()
);
}
#[test]
fn parses_install_skill_subcommand() {
let cli = parse(&["bsk", "install-skill", "--list"]);
assert!(matches!(cli.command, Command::InstallSkill(_)));
}
#[test]
fn parses_update_subcommand_with_flags() {
let cli = parse(&["bsk", "update", "--check", "--yes", "--no-restart-daemon"]);
let Command::Update(args) = cli.command else {
panic!("expected update subcommand");
};
assert!(args.check);
assert!(args.yes);
assert!(!args.restart_daemon);
}
#[test]
fn duration_parser_accepts_units() {
assert_eq!(parse_duration("750ms").unwrap(), Duration::from_millis(750));
@@ -146,3 +193,37 @@ fn rejects_zero_click_count() {
Cli::try_parse_from(["bsk", "click", "@e1", "--session", "s1", "--count", "0"]).is_err()
);
}
#[test]
fn parses_record_start_with_browser_and_url() {
let cli = parse(&[
"bsk",
"record",
"start",
"--browser",
"022ca8ac",
"--url",
"https://x",
]);
let Command::Record(RecordCmd {
sub: RecordSub::Start(args),
}) = cli.command
else {
panic!("expected record start subcommand");
};
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
assert_eq!(args.url.as_deref(), Some("https://x"));
}
#[test]
fn parses_record_start_without_url() {
let cli = parse(&["bsk", "record", "start", "--browser", "022ca8ac"]);
let Command::Record(RecordCmd {
sub: RecordSub::Start(args),
}) = cli.command
else {
panic!("expected record start subcommand");
};
assert_eq!(args.browser.as_deref(), Some("022ca8ac"));
assert!(args.url.is_none());
}
+1 -4
View File
@@ -10,7 +10,6 @@ use bsk_protocol::system::{HandshakeParams, HandshakeResult, StatusResult};
use bsk_protocol::{BrowserPeerInfo, ErrorCode, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
@@ -28,9 +27,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-handshake");
+14 -10
View File
@@ -27,7 +27,6 @@ use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde::Deserialize;
use serde_json::json;
use tokio::net::TcpListener;
use tokio::sync::{Mutex, mpsc};
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
@@ -46,9 +45,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let sock = tempfile_path("bsk-test-queue");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
@@ -272,9 +269,7 @@ async fn same_session_dispatches_run_after_previous_completes() {
.expect("request did not reach extension")
.expect("request channel closed");
assert_eq!(tag, format!("job-{i}"));
reply_tx
.send((rpc_id, json!({"acked": true})))
.unwrap();
reply_tx.send((rpc_id, json!({"acked": true}))).unwrap();
let r = dispatch_task.await.unwrap();
assert!(r.is_ok(), "dispatch should succeed, got {r:?}");
}
@@ -333,7 +328,10 @@ async fn session_stop_fast_fails_while_tool_is_in_flight() {
)
.await;
assert!(
matches!(stop_result, Err(bsk::daemon::sessions::StopSessionError::SessionBusy)),
matches!(
stop_result,
Err(bsk::daemon::sessions::StopSessionError::SessionBusy)
),
"session.stop should fast-fail while a tool is active, got {stop_result:?}"
);
assert!(
@@ -437,7 +435,10 @@ async fn concurrent_same_session_dispatch_returns_session_busy_immediately() {
let rpc = DispatchError::SessionBusy.into_rpc();
assert_eq!(rpc.code, ErrorCode::Timeout);
assert_eq!(
rpc.data.as_ref().and_then(|d| d.get("reason")).and_then(|v| v.as_str()),
rpc.data
.as_ref()
.and_then(|d| d.get("reason"))
.and_then(|v| v.as_str()),
Some(bsk::rpc_reason::SESSION_BUSY)
);
@@ -471,7 +472,10 @@ async fn concurrent_same_session_dispatch_returns_session_busy_immediately() {
.send((after_rpc_id, json!({"acked": true})))
.unwrap();
let after = after_task.await.unwrap();
assert!(after.is_ok(), "dispatch should succeed after first completes, got {after:?}");
assert!(
after.is_ok(),
"dispatch should succeed after first completes, got {after:?}"
);
handle.shutdown().await;
}
@@ -26,7 +26,6 @@ use bsk_protocol::{
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::json;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
@@ -46,9 +45,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port);
let sock = tempfile_path("bsk-test-user-interrupt");
+52 -4
View File
@@ -15,7 +15,6 @@ use bsk_protocol::tools::{SessionStartParams, SessionStartResult, SessionStopPar
use bsk_protocol::{BrowserPeerInfo, Frame, Method, RequestFrame, ResponseBody, ResponseFrame};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use tokio::net::TcpListener;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
use tokio_tungstenite::tungstenite::protocol::Message;
@@ -39,9 +38,7 @@ async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
}
async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let config = DaemonConfig::new(port).with_extension_connect_wait(connect_wait);
let sock = tempfile_path("bsk-test-ipc");
@@ -49,6 +46,16 @@ async fn spawn_daemon_with_connect_wait(connect_wait: Duration) -> (daemon::Daem
(handle, sock)
}
async fn spawn_daemon_with_session_idle(session_idle: Duration) -> (daemon::DaemonHandle, PathBuf) {
let port = 0;
let mut config = DaemonConfig::new(port);
config.session_idle = session_idle;
let sock = tempfile_path("bsk-test-ipc");
let handle = daemon::run(config, Some(sock.clone())).await.unwrap();
(handle, sock)
}
async fn connect_ext(
addr: std::net::SocketAddr,
) -> tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>> {
@@ -240,6 +247,47 @@ async fn session_start_stop_round_trip_via_ipc() {
handle.shutdown().await;
}
#[tokio::test]
async fn session_idle_timeout_stops_and_unregisters_session() {
let (handle, _sock) = spawn_daemon_with_session_idle(Duration::from_millis(50)).await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = handshake_as_ext(&mut ws).await;
let state = handle.state();
let session_id = bsk::daemon::sessions::SessionId("idle".into());
state.sessions.insert(bsk::daemon::sessions::Session {
id: session_id.clone(),
browser_id: bsk::daemon::browsers::BrowserId(TEST_EXT_ID.into()),
agent_window_id: Some(7),
created_at_ms: 0,
});
state.tool_queues.spawn(session_id);
let request = tokio::time::timeout(Duration::from_secs(1), ws.next())
.await
.expect("idle reaper did not contact extension")
.expect("extension socket closed")
.expect("extension socket failed");
let Message::Text(text) = request else {
panic!("expected text request");
};
let request: RequestFrame = serde_json::from_str(&text).unwrap();
assert_eq!(request.method, Method::ToolSessionStop);
ws.send(Message::Text(
serde_json::to_string(&ResponseFrame {
id: request.id,
body: ResponseBody::Ok(
serde_json::to_value(bsk_protocol::tools::SessionStopResult::default()).unwrap(),
),
})
.unwrap(),
))
.await
.unwrap();
wait_for_no_sessions(&state).await;
handle.shutdown().await;
}
#[tokio::test]
async fn session_start_errors_without_browser() {
let (handle, sock) = spawn_daemon_with_connect_wait(Duration::ZERO).await;
+4 -4
View File
@@ -92,7 +92,7 @@ fn bsk_doctor_runs_without_running_daemon() {
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
.output()
.expect("run bsk doctor");
assert!(out.status.success());
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("bsk home writable"),
@@ -137,7 +137,7 @@ fn bsk_doctor_json_returns_structured_checks() {
.env("BSK_DOCTOR_BROWSER_WAIT_MS", "200")
.output()
.expect("run bsk doctor --json");
assert!(out.status.success());
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8(out.stdout).unwrap();
let parsed: serde_json::Value =
serde_json::from_str(stdout.trim()).expect("doctor --json should be valid JSON");
@@ -203,7 +203,7 @@ fn bsk_doctor_does_not_treat_live_non_daemon_pid_as_running() {
.env("RUST_LOG", "warn")
.output()
.expect("run bsk doctor --json");
assert!(out.status.success());
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8(out.stdout).unwrap();
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
let daemon = checks
@@ -262,7 +262,7 @@ fn bsk_doctor_flags_pid_mismatch_against_running_daemon() {
.env("RUST_LOG", "warn")
.output()
.expect("bsk doctor --json");
assert!(out.status.success());
assert_eq!(out.status.code(), Some(1));
let stdout = String::from_utf8(out.stdout).unwrap();
let checks: Vec<serde_json::Value> = serde_json::from_str(stdout.trim()).unwrap();
+4 -6
View File
@@ -8,9 +8,9 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use bsk::daemon::queue::ToolQueueRegistry;
use bsk::daemon::sessions::SessionId;
use bsk::daemon::state::DaemonState;
use bsk::daemon::queue::ToolQueueRegistry;
use bsk_protocol::RpcId;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(2);
@@ -81,11 +81,9 @@ pub async fn wait_for_inflight_forwarded(state: &Arc<DaemonState>, rpc_id: &RpcI
pub async fn wait_for_abort_registered(state: &Arc<DaemonState>, rpc_id: &RpcId) {
let id = rpc_id.clone();
wait_until(
&format!("abort token for {id}"),
DEFAULT_TIMEOUT,
|| state.abort_registry.contains(&id),
)
wait_until(&format!("abort token for {id}"), DEFAULT_TIMEOUT, || {
state.abort_registry.contains(&id)
})
.await;
}
+60 -6
View File
@@ -11,8 +11,9 @@ use bsk::ipc_client::IpcClient;
use bsk_protocol::system::{HandshakeParams, HandshakeResult};
use bsk_protocol::tools::{
ConsoleEntry, ConsoleEntryKind, ConsoleParams, ConsoleResult, GetHtmlParams, GetHtmlResult,
ScreenshotParams, ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams,
SnapshotResult, TabInfo, TabListParams, TabListResult, TabScope,
NetworkEntry, NetworkEntryKind, NetworkParams, NetworkResult, ScreenshotParams,
ScreenshotResult, SessionStartParams, SessionStartResult, SnapshotParams, SnapshotResult,
TabInfo, TabListParams, TabListResult, TabScope,
};
use bsk_protocol::{
BrowserPeerInfo, ErrorCode, Frame, Method, RequestFrame, ResponseBody, ResponseFrame, RpcError,
@@ -21,7 +22,6 @@ use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
@@ -40,9 +40,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let sock = tempfile_path("bsk-test-tools");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await
@@ -368,6 +366,62 @@ async fn console_returns_buffered_entries() {
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn network_returns_buffered_entries() {
let (handle, sock) = spawn_daemon().await;
let mut ws = connect_ext(handle.ws_addr()).await;
let _ = do_handshake(&mut ws).await;
run_extension(ws, |req| {
assert_eq!(req.method, Method::ToolNetwork);
let params: NetworkParams = serde_json::from_value(req.params.clone().unwrap()).unwrap();
assert_eq!(params.tab_id, Some(7));
assert_eq!(params.since, Some(3));
assert_eq!(params.limit, Some(50));
assert_eq!(params.max_text_chars, Some(1000));
ResponseBody::Ok(
serde_json::to_value(NetworkResult {
tab_id: 7,
entries: vec![NetworkEntry {
sequence: 4,
kind: NetworkEntryKind::Response,
method: Some("GET".into()),
url: Some("https://example.test/api".into()),
status: Some(404),
status_text: Some("Not Found".into()),
mime_type: Some("application/json".into()),
resource_type: Some("Fetch".into()),
error_text: None,
timestamp: None,
truncated: false,
}],
next_since: 4,
truncated: false,
})
.unwrap(),
)
});
let session_id = ipc_session_start(&sock).await;
let result: NetworkResult = ipc_tool_call(
&sock,
Method::ToolNetwork,
NetworkParams {
session_id,
tab_id: Some(7),
since: Some(3),
limit: Some(50),
max_text_chars: Some(1000),
},
)
.await
.expect("network ok");
assert_eq!(result.tab_id, 7);
assert_eq!(result.next_since, 4);
assert_eq!(result.entries[0].status, Some(404));
handle.shutdown().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn snapshot_returns_text_and_ref_count() {
let (handle, sock) = spawn_daemon().await;
+1 -4
View File
@@ -25,7 +25,6 @@ use futures_util::stream::{SplitSink, SplitStream};
use futures_util::{SinkExt, StreamExt};
use rand::Rng;
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tokio::sync::Mutex;
use tokio_tungstenite::tungstenite::handshake::client::generate_key;
use tokio_tungstenite::tungstenite::http::Request;
@@ -44,9 +43,7 @@ fn tempfile_path(prefix: &str) -> PathBuf {
}
async fn spawn_daemon() -> (daemon::DaemonHandle, PathBuf) {
let probe = TcpListener::bind("127.0.0.1:0").await.unwrap();
let port = probe.local_addr().unwrap().port();
drop(probe);
let port = 0;
let sock = tempfile_path("bsk-test-tools-m7");
let handle = daemon::run(DaemonConfig::new(port), Some(sock.clone()))
.await

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