feat(bsk): support upload and download

This commit is contained in:
Ljy-0827
2026-08-24 14:59:11 +08:00
parent 4b6cdde168
commit 6800f8fdfe
33 changed files with 2038 additions and 9 deletions
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { type DownloadsApi, handleDownload } from "../download";
import type { CdpRunner } from "../shared";
import { handleUpload } from "../upload";
function sessions() {
return new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
}
function tabsApi() {
return {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
};
}
function fakeEvent<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
hasListener: (listener: T) => listeners.has(listener),
hasListeners: () => listeners.size > 0,
emit: (...args: Parameters<T>) => {
for (const listener of listeners) listener(...args);
},
};
}
describe("file transfer tools", () => {
it("arms chooser interception before clicking and injects only staged paths", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const calls: Array<{ method: string; params?: object }> = [];
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
eventHandler?.({ tabId: 4 }, "Page.fileChooserOpened", {
backendNodeId: 456,
mode: "selectMultiple",
});
}
return {};
});
const cdp: CdpRunner = {
onEvent: (handler) => {
eventHandler = handler;
return { dispose: vi.fn() };
},
send: send as unknown as CdpRunner["send"],
};
const result = await handleUpload(
manager,
{
session_id: "s1",
ref: "@e3",
files: [
{ transfer_id: "tr_1", name: "one.png", staged_path: "/private/stage/one" },
{ transfer_id: "tr_2", name: "two.png", staged_path: "/private/stage/two" },
],
},
{ cdp, tabsApi: tabsApi() },
);
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
expect(calls[0]).toMatchObject({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: true },
});
expect(calls).toContainEqual({
method: "DOM.setFileInputFiles",
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
});
});
it("captures the one download created under daemon staging", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const item = {
id: 9,
filename: "/private/stage/tr_1/result.zip",
state: "complete",
fileSize: 12,
totalBytes: 12,
mime: "application/zip",
danger: "safe",
} as chrome.downloads.DownloadItem;
const downloads: DownloadsApi = {
onCreated: onCreated as unknown as DownloadsApi["onCreated"],
onChanged: onChanged as unknown as DownloadsApi["onChanged"],
search: vi.fn(async () => [item]),
cancel: vi.fn(async () => {}),
};
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
onCreated.emit(item);
}
return {};
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
};
const result = await handleDownload(
manager,
{ session_id: "s1", ref: "@e3", staging_path: "/private/stage/tr_1" },
{ cdp, tabsApi: tabsApi(), downloads },
);
expect(result).toMatchObject({
tab_id: 4,
suggested_filename: "result.zip",
byte_size: 12,
staged_path: item.filename,
});
});
});
+38
View File
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
import type {
ClickParams,
ConsoleParams,
DownloadParams,
EmulateParams,
EvaluateParams,
FillParams,
@@ -28,10 +29,12 @@ import type {
ScreenshotParams,
SelectParams,
SnapshotParams,
UploadParams,
WaitForNavigationParams,
} from "@/transport/types";
import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop";
@@ -79,6 +82,7 @@ import {
type TabReturnParams,
type TabSelectParams,
} from "./tabs";
import { handleUpload } from "./upload";
import { handleWaitForNavigation } from "./waits";
import { handleWindowResize, type WindowResizeParams } from "./window";
@@ -513,6 +517,38 @@ export class ToolDispatcher {
),
signal,
);
case "tool.upload":
return this.withHoverReleaseForRequest(
req.params as UploadParams,
() =>
this.cdp
? handleUpload(this.sessions, req.params as UploadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
})
: Promise.resolve({
code: "unsupported",
message: "upload requires CDP",
} satisfies RpcError),
signal,
);
case "tool.download":
return this.withHoverReleaseForRequest(
req.params as DownloadParams,
() =>
this.cdp
? handleDownload(this.sessions, req.params as DownloadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
})
: Promise.resolve({
code: "unsupported",
message: "download requires CDP",
} satisfies RpcError),
signal,
);
case "tool.evaluate":
return handleEvaluate(
this.sessions,
@@ -719,6 +755,8 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
case "tool.fill":
case "tool.press":
case "tool.select":
case "tool.upload":
case "tool.download":
case "tool.evaluate":
case "tool.observe":
case "tool.request_help":
+138
View File
@@ -0,0 +1,138 @@
// One-click/one-file browser download capture. chrome.downloads events are
// browser-global, so a coordinator prevents two bsk sessions from racing.
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, DownloadParams, DownloadResult, RpcError } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import { enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab } from "./shared";
export interface DownloadsApi {
onCreated: chrome.events.Event<(item: chrome.downloads.DownloadItem) => void>;
onChanged: chrome.events.Event<(delta: chrome.downloads.DownloadDelta) => void>;
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
cancel(downloadId: number): Promise<void>;
}
const chromeDownloadsApi: DownloadsApi = {
get onCreated() {
return chrome.downloads.onCreated;
},
get onChanged() {
return chrome.downloads.onChanged;
},
search: (query) => chrome.downloads.search(query),
cancel: (id) => chrome.downloads.cancel(id),
};
let downloadActive = false;
export interface DownloadDeps extends InteractionDeps {
downloads?: DownloadsApi;
}
export async function handleDownload(
manager: SessionManager,
params: DownloadParams,
deps: DownloadDeps,
): Promise<DownloadResult | RpcError> {
if (downloadActive) return { code: "invalid_params", message: "another bsk download is active" };
downloadActive = true;
const downloads = deps.downloads ?? chromeDownloadsApi;
let capturedId: number | undefined;
let createdListener: ((item: chrome.downloads.DownloadItem) => void) | undefined;
let changedListener: ((delta: chrome.downloads.DownloadDelta) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const ctx = lookupSession(manager, params, "download");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "download");
if (denied) return denied;
if (!params.staging_path)
return { code: "invalid_params", message: "download requires daemon staging" };
const normalisePath = (path: string) => path.replaceAll("\\", "/");
const normalisedStaging = normalisePath(params.staging_path);
const stagingPrefix = normalisedStaging.endsWith("/")
? normalisedStaging
: `${normalisedStaging}/`;
let rejectCompleted!: (error: Error) => void;
const completed = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
rejectCompleted = reject;
createdListener = (item) => {
if (!normalisePath(item.filename).startsWith(stagingPrefix)) return;
if (capturedId !== undefined && capturedId !== item.id) {
reject(new Error("download trigger produced more than one file"));
return;
}
capturedId = item.id;
if (item.state === "complete") resolve(item);
};
changedListener = async (delta) => {
if (capturedId === undefined || delta.id !== capturedId) return;
if (delta.state?.current === "interrupted" || delta.error?.current) {
reject(new Error(delta.error?.current ?? "download interrupted"));
return;
}
if (delta.state?.current === "complete") {
const [item] = await downloads.search({ id: delta.id });
if (item) resolve(item);
else reject(new Error("completed download disappeared"));
}
};
downloads.onCreated.addListener(createdListener);
downloads.onChanged.addListener(changedListener);
timer = setTimeout(
() => reject(new Error("download did not complete before timeout")),
params.timeout_ms ?? 120_000,
);
});
const onAbort = () => {
if (capturedId !== undefined) void downloads.cancel(capturedId).catch(() => undefined);
rejectCompleted(new DOMException("aborted", "AbortError"));
};
deps.signal?.addEventListener("abort", onAbort, { once: true });
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", {
behavior: "allow",
downloadPath: params.staging_path,
});
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
const clicked = await handleClick(manager, clickParams, deps);
if (isRpcError(clicked)) {
void completed.catch(() => undefined);
return clicked;
}
const item = await completed;
return {
tab_id: target.tabId,
used_ref: clicked.used_ref,
used_selector: clicked.used_selector,
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
mime: item.mime || undefined,
danger: item.danger,
staged_path: item.filename,
};
} finally {
deps.signal?.removeEventListener("abort", onAbort);
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", { behavior: "default" });
} catch {}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
} finally {
if (timer) clearTimeout(timer);
if (createdListener) downloads.onCreated.removeListener(createdListener);
if (changedListener) downloads.onChanged.removeListener(changedListener);
downloadActive = false;
}
}
+148
View File
@@ -0,0 +1,148 @@
// Atomic file-chooser upload: arm interception before clicking, then attach
// daemon-staged files to the chooser node. No local path is accepted from an
// agent-facing call; staged_path is injected by the daemon.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, RpcError, UploadParams, UploadResult } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import {
type CdpRunner,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
sendToCdpTarget,
} from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref";
const DEFAULT_TIMEOUT_MS = 120_000;
export interface UploadDeps extends InteractionDeps {
cdp: CdpRunner;
}
function chooserTarget(
params: UploadParams,
tabId: number,
manager: SessionManager,
): CdpTarget | RpcError {
const ctx = manager.get(params.session_id);
if (!ctx) return { code: "not_found", message: `session ${params.session_id} unknown` };
if (params.ref) {
const ref = resolveSnapshotRef(ctx, params.ref, tabId);
if (isRpcError(ref)) return ref;
return { tabId, ...(ref.cdpSessionId ? { sessionId: ref.cdpSessionId } : {}) };
}
return { tabId };
}
export async function handleUpload(
manager: SessionManager,
params: UploadParams,
deps: UploadDeps,
): Promise<UploadResult | RpcError> {
const ctx = lookupSession(manager, params, "upload");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "upload");
if (denied) return denied;
if (!deps.cdp?.onEvent)
return { code: "unsupported", message: "CDP event subscription unavailable" };
if (
params.files.length === 0 ||
params.files.length > 20 ||
params.files.some((file) => !file.staged_path)
) {
return { code: "invalid_params", message: "upload requires daemon-staged files" };
}
const cdpTarget = chooserTarget(params, target.tabId, manager);
if (isRpcError(cdpTarget)) return cdpTarget;
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
let subscription: { dispose(): void } | undefined;
let resolveChooser!: (value: {
source: { tabId?: number; sessionId?: string };
backendNodeId: number;
mode?: string;
}) => void;
let rejectChooser!: (error: Error) => void;
const chooser = new Promise<{
source: { tabId?: number; sessionId?: string };
backendNodeId: number;
mode?: string;
}>((resolve, reject) => {
resolveChooser = resolve;
rejectChooser = reject;
});
subscription = deps.cdp.onEvent((source, method, raw) => {
if (method !== "Page.fileChooserOpened" || source.tabId !== target.tabId) return;
if (cdpTarget.sessionId && source.sessionId !== cdpTarget.sessionId) return;
const event = raw as { backendNodeId?: unknown; mode?: unknown };
if (typeof event.backendNodeId !== "number") {
rejectChooser(new Error("file chooser did not expose an input backendNodeId"));
return;
}
resolveChooser({
source,
backendNodeId: event.backendNodeId,
...(typeof event.mode === "string" ? { mode: event.mode } : {}),
});
});
timer = setTimeout(
() => rejectChooser(new Error("file chooser did not open before timeout")),
timeoutMs,
);
const onAbort = () => rejectChooser(new DOMException("aborted", "AbortError"));
deps.signal?.addEventListener("abort", onAbort, { once: true });
try {
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
enabled: true,
});
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
const clicked = await handleClick(manager, clickParams, deps);
if (isRpcError(clicked)) {
void chooser.catch(() => undefined);
return clicked;
}
const opened = await chooser;
if (opened.mode === "selectSingle" && params.files.length !== 1) {
return { code: "invalid_params", message: "file chooser accepts exactly one file" };
}
const sourceTarget: CdpTarget = {
tabId: target.tabId,
...(opened.source.sessionId ? { sessionId: opened.source.sessionId } : {}),
};
await sendToCdpTarget(deps.cdp, sourceTarget, "DOM.setFileInputFiles", {
files: params.files.map((file) => file.staged_path as string),
backendNodeId: opened.backendNodeId,
});
return {
tab_id: target.tabId,
used_ref: clicked.used_ref,
used_selector: clicked.used_selector,
file_names: params.files.map((file) => file.name),
};
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
} finally {
if (timer) clearTimeout(timer);
subscription?.dispose();
deps.signal?.removeEventListener("abort", onAbort);
try {
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
enabled: false,
});
} catch {
// Best-effort compensation; the target may have navigated/closed.
}
}
}
+43
View File
@@ -500,6 +500,49 @@ export interface SelectResult {
dialogs?: JavaScriptDialogInfo[];
}
export interface UploadFile {
transfer_id: string;
name: string;
staged_path?: string;
}
export interface UploadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
files: UploadFile[];
timeout_ms?: number;
}
export interface UploadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
file_names: string[];
}
export interface DownloadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
timeout_ms?: number;
staging_path?: string;
}
export interface DownloadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
suggested_filename: string;
byte_size: number;
mime?: string;
danger?: string;
staged_path?: string;
transfer_id?: string;
}
// --------------------------------------------------------------------------
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
// --------------------------------------------------------------------------