feat(bsk): fix upload function that cannot bypass layout

This commit is contained in:
Ljy-0827
2026-08-31 17:58:33 +08:00
parent 995061514c
commit d5de9b0154
20 changed files with 1033 additions and 630 deletions
@@ -232,6 +232,94 @@ describe("ToolDispatcher", () => {
});
});
it("bypasses and restores the control overlay for an upload trigger click", async () => {
const sendMessage = vi.fn(async () => undefined);
vi.stubGlobal("chrome", {
tabs: {
get: vi.fn(async () => ({ id: 7, windowId: 4242, active: true })),
query: vi.fn(async () => [{ id: 7, windowId: 4242, active: true }]),
sendMessage,
},
});
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
agentWindow: {
create: vi.fn(async () => 4242),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
const ctx = await sessions.start("aa11");
ctx.refStore.set("e1", 123, { tabId: 7 });
const send = vi.fn(async <T>(_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics") {
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } } as T;
}
if (method === "DOM.getContentQuads") {
return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] } as T;
}
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } } as T;
if (method === "Runtime.evaluate") {
const expression = (params as { expression?: string }).expression ?? "";
if (expression.includes("overlayDetails")) {
return { result: { value: { hitIndex: 0 } } } as T;
}
if (expression.includes("overlayHostPresent")) {
return {
result: {
value: { overlayHostPresent: true, overlayHostConnected: true },
},
} as T;
}
if (expression.includes("count:")) {
return { result: { value: { count: 1, multiple: false } } } as T;
}
if (expression.includes("?.inputs[0]")) {
return { result: { objectId: "input-object" } } as T;
}
return { result: { value: true } } as T;
}
return {} as T;
});
const cdp = {
send,
detachSession: vi.fn(async () => {}),
ensureNetworkCapture: vi.fn(async () => {}),
networkEntriesSince: vi.fn(() => ({
tab_id: 7,
entries: [],
next_since: 0,
truncated: false,
})),
setDeviceMetricsOverride: vi.fn(async () => {}),
clearDeviceMetricsOverride: vi.fn(async () => {}),
setUserAgentOverride: vi.fn(async () => {}),
setTouchEmulationEnabled: vi.fn(async () => {}),
};
const dispatcher = new ToolDispatcher({ transport, sessions, cdp: cdp as TestDispatcherCdp });
dispatcher.start();
deliver(
makeRequest("tool.upload", {
session_id: "aa11",
ref: "@e1",
files: [{ transfer_id: "tr_1", name: "test.png", staged_path: "/stage/test.png" }],
}),
);
await flushMicrotasks();
await vi.waitFor(() => expect(sent).toHaveLength(1));
expect(sent[0]).toMatchObject({ result: { tab_id: 7, file_names: ["test.png"] } });
expect(sendMessage).toHaveBeenNthCalledWith(1, 7, {
type: "bh-automation-bypass",
enabled: true,
});
expect(sendMessage).toHaveBeenNthCalledWith(2, 7, {
type: "bh-automation-bypass",
enabled: false,
});
});
it("detaches CDP state before stopping a session", async () => {
const { transport, sent, deliver } = fakeTransport();
const sessions = new SessionManager({
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { type DownloadsApi, handleDownload } from "../download";
import { uploadThroughFileChooser } from "../file-chooser";
import { captureBrowserDownload } from "../download-capture";
import { uploadThroughActivatedFileInput } from "../file-input-transaction";
import type { CdpRunner } from "../shared";
import { handleUpload } from "../upload";
@@ -24,49 +25,57 @@ function tabsApi() {
};
}
function fakeEvent<T extends (...args: never[]) => void>() {
function fakeEvent<T extends (...args: never[]) => unknown>() {
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);
},
};
}
function uploadCdp(options: { inputCount?: number; multiple?: boolean; pickerCall?: string } = {}) {
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 === "Runtime.evaluate") {
const expression = (params as { expression?: string }).expression ?? "";
if (expression.includes("count:")) {
return {
result: {
value: {
count: options.inputCount ?? 1,
multiple: options.multiple ?? true,
pickerCall: options.pickerCall,
},
},
};
}
if (expression.includes("?.inputs[0]")) {
return { result: { objectId: "input-object" } };
}
return { result: { value: true } };
}
if (method === "DOM.describeNode") return { node: { backendNodeId: 456 } };
return {};
});
return {
calls,
cdp: { send: send as unknown as CdpRunner["send"] } satisfies CdpRunner,
};
}
describe("file transfer tools", () => {
it("enables chooser observation before clicking and injects only staged paths", async () => {
it("captures the file input activated by the requested click 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 { cdp, calls } = uploadCdp();
const result = await handleUpload(
manager,
@@ -82,61 +91,60 @@ describe("file transfer tools", () => {
);
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
expect(calls[0]).toMatchObject({
method: "Page.enable",
params: { enableFileChooserOpenedEvent: true },
});
const armCall = calls.find(
(call) =>
call.method === "Runtime.evaluate" &&
(call.params as { expression?: string }).expression?.includes("showOpenFilePicker"),
);
expect(armCall).toBeDefined();
expect((armCall?.params as { expression?: string }).expression).toContain(
"event.preventDefault()",
);
expect(calls).toContainEqual({
method: "DOM.setFileInputFiles",
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
});
expect(calls.at(-1)).toEqual({
method: "Page.enable",
params: { enableFileChooserOpenedEvent: false },
});
});
it("reports File System Access pickers as an explicit manual fallback", async () => {
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const cdp: CdpRunner = {
onEvent: (handler) => {
eventHandler = handler;
return { dispose: vi.fn() };
},
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
};
const result = await uploadThroughFileChooser({
it("fails immediately when the trigger does not activate a file input", async () => {
const { cdp } = uploadCdp({ inputCount: 0 });
const result = await uploadThroughActivatedFileInput({
cdp,
target: { tabId: 4 },
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => {
eventHandler?.({ tabId: 4 }, "Page.fileChooserOpened", { mode: "selectSingle" });
return { tab_id: 4, x: 10, y: 10 };
},
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
expect(result).toEqual({
expect(result).toMatchObject({
code: "unsupported",
message: "this file chooser is not backed by an input element",
data: {
reason: "unsupported_file_chooser",
chooser_kind: "non_input",
manual_fallback_available: true,
},
data: { reason: "file_input_not_activated", phase: "resolve_input" },
});
});
it("bounds a stuck chooser-control command and resets uncertain CDP state", async () => {
const detach = vi.fn(async () => {});
it("reports a File System Access picker without waiting for a timeout", async () => {
const { cdp } = uploadCdp({ inputCount: 0, pickerCall: "showOpenFilePicker" });
const result = await uploadThroughActivatedFileInput({
cdp,
target: { tabId: 4 },
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
expect(result).toMatchObject({
code: "unsupported",
message: "upload trigger invoked showOpenFilePicker instead of an input[type=file]",
data: { reason: "file_input_not_activated", phase: "resolve_input" },
});
});
it("bounds a stuck file-input probe", async () => {
const cdp: CdpRunner = {
onEvent: () => ({ dispose: vi.fn() }),
send: vi.fn(() => new Promise<never>(() => {})) as unknown as CdpRunner["send"],
detach,
};
const result = await uploadThroughFileChooser({
const result = await uploadThroughActivatedFileInput({
cdp,
target: { tabId: 4 },
files: ["/private/stage/one"],
@@ -146,95 +154,49 @@ describe("file transfer tools", () => {
expect(result).toMatchObject({
code: "timeout",
data: { reason: "file_chooser_control_failed", phase: "enable_events" },
});
expect(detach).toHaveBeenCalledWith(4);
});
it("distinguishes a completed click that did not open a chooser", async () => {
const send = vi.fn(async () => ({})) as unknown as CdpRunner["send"];
const cdp: CdpRunner = {
onEvent: () => ({ dispose: vi.fn() }),
send,
};
const result = await uploadThroughFileChooser({
cdp,
target: { tabId: 4 },
files: ["/private/stage/one"],
timeoutMs: 5,
trigger: async () => ({ tab_id: 4, x: 10, y: 10 }),
});
expect(result).toMatchObject({
code: "timeout",
data: { reason: "file_chooser_not_opened", phase: "await_event" },
});
expect(send).toHaveBeenLastCalledWith(4, "Page.enable", {
enableFileChooserOpenedEvent: false,
data: { reason: "file_input_probe_failed", phase: "arm_input_probe" },
});
});
it("accepts chooser events only from the exact CDP target", async () => {
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const setFiles = vi.fn();
const cdp: CdpRunner = {
onEvent: (handler) => {
eventHandler = handler;
return { dispose: vi.fn() };
},
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
sendToTarget: vi.fn(async (_target, method, params) => {
if (method === "DOM.setFileInputFiles") setFiles(params);
return {};
}) as unknown as NonNullable<CdpRunner["sendToTarget"]>,
};
const result = await uploadThroughFileChooser({
cdp,
target: { tabId: 4, sessionId: "child-a" },
files: ["/private/stage/one"],
timeoutMs: 100,
trigger: async () => {
eventHandler?.({ tabId: 4, sessionId: "child-b" }, "Page.fileChooserOpened", {
backendNodeId: 111,
});
eventHandler?.({ tabId: 4, sessionId: "child-a" }, "Page.fileChooserOpened", {
backendNodeId: 222,
});
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(result).toMatchObject({ click: { tab_id: 4 } });
expect(setFiles).toHaveBeenCalledOnce();
expect(setFiles).toHaveBeenCalledWith({
files: ["/private/stage/one"],
backendNodeId: 222,
});
});
it("captures the one download created under daemon staging", async () => {
it("routes one exact-target download through a browser-relative capability", 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 = {
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const initial = {
id: 9,
filename: "/private/stage/tr_1/result.zip",
state: "complete",
fileSize: 12,
url: "https://example.test/result.zip",
finalUrl: "https://example.test/result.zip",
filename: "result.zip",
state: "in_progress",
fileSize: -1,
totalBytes: 12,
mime: "application/zip",
danger: "safe",
} as chrome.downloads.DownloadItem;
const completed = {
...initial,
filename: "/profile/Downloads/BrowserSkill/tr_1/result.zip",
state: "complete",
fileSize: 12,
} 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]),
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => [completed]),
cancel: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
let suggested: chrome.downloads.DownloadFilenameSuggestion | undefined;
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
@@ -243,25 +205,103 @@ describe("file transfer tools", () => {
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
onCreated.emit(item);
cdpEvent?.({ tabId: 4 }, "Page.downloadWillBegin", {
url: initial.url,
suggestedFilename: "result.zip",
});
await new Promise<void>((resolve) => {
onDeterminingFilename.emit(initial, (value) => {
suggested = value;
resolve();
});
});
onCreated.emit(completed);
}
return {};
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
const result = await handleDownload(
manager,
{ session_id: "s1", ref: "@e3", staging_path: "/private/stage/tr_1" },
{ session_id: "s1", ref: "@e3", browser_relative_dir: "BrowserSkill/tr_1" },
{ cdp, tabsApi: tabsApi(), downloads },
);
expect(suggested).toEqual({
filename: "BrowserSkill/tr_1/result.zip",
conflictAction: "overwrite",
});
expect(result).toMatchObject({
tab_id: 4,
suggested_filename: "result.zip",
byte_size: 12,
staged_path: item.filename,
browser_path: completed.filename,
});
});
it("does not claim a download without an intent from the exact target", async () => {
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const onDeterminingFilename =
fakeEvent<
(
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true
>();
const downloads: DownloadsApi = {
onCreated,
onChanged,
onDeterminingFilename,
search: vi.fn(async () => []),
cancel: vi.fn(async () => {}),
};
let cdpEvent: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const cdp: CdpRunner = {
send: vi.fn(async () => ({})) as unknown as CdpRunner["send"],
onEvent: (handler) => {
cdpEvent = handler;
return { dispose: vi.fn() };
},
};
const unrelated = {
id: 17,
url: "https://example.test/unrelated.zip",
finalUrl: "https://example.test/unrelated.zip",
filename: "unrelated.zip",
state: "in_progress",
} as chrome.downloads.DownloadItem;
let defaultSuggestionCalled = false;
const result = await captureBrowserDownload({
cdp,
target: { tabId: 4, sessionId: "expected-child" },
downloads,
browserRelativeDir: "BrowserSkill/tr_1",
timeoutMs: 5,
trigger: async () => {
cdpEvent?.({ tabId: 4, sessionId: "other-child" }, "Page.downloadWillBegin", {
url: unrelated.url,
suggestedFilename: unrelated.filename,
});
onDeterminingFilename.emit(unrelated, (suggestion) => {
defaultSuggestionCalled = suggestion === undefined;
});
return { tab_id: 4, x: 10, y: 10 };
},
});
expect(defaultSuggestionCalled).toBe(true);
expect(downloads.cancel).not.toHaveBeenCalled();
expect(result).toMatchObject({
code: "cdp_failed",
data: { reason: "download_capture_failed" },
});
});
});
+2
View File
@@ -526,6 +526,7 @@ export class ToolDispatcher {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay,
})
: Promise.resolve({
code: "unsupported",
@@ -542,6 +543,7 @@ export class ToolDispatcher {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
bypassOverlay,
})
: Promise.resolve({
code: "unsupported",
@@ -0,0 +1,211 @@
// Transaction-scoped capture for one web download. Chrome owns the actual
// download and writes only beneath its Downloads root; BrowserSkill supplies a
// daemon-minted relative directory and reports the completed absolute path
// back to the daemon for validated import.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { ClickResult, RpcError } from "@/transport/types";
import { type CdpRunner, isRpcError } from "./shared";
type DeterminingFilenameListener = (
item: chrome.downloads.DownloadItem,
suggest: (suggestion?: chrome.downloads.DownloadFilenameSuggestion) => void,
) => void | true;
interface ListenerEvent<T> {
addListener(listener: T): void;
removeListener(listener: T): void;
}
export interface DownloadsApi {
onCreated: ListenerEvent<(item: chrome.downloads.DownloadItem) => void>;
onChanged: ListenerEvent<(delta: chrome.downloads.DownloadDelta) => void>;
onDeterminingFilename: ListenerEvent<DeterminingFilenameListener>;
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
cancel(downloadId: number): Promise<void>;
}
export const chromeDownloadsApi: DownloadsApi = {
get onCreated() {
return chrome.downloads.onCreated;
},
get onChanged() {
return chrome.downloads.onChanged;
},
get onDeterminingFilename() {
return chrome.downloads.onDeterminingFilename;
},
search: (query) => chrome.downloads.search(query),
cancel: (id) => chrome.downloads.cancel(id),
};
export interface DownloadCaptureOptions {
cdp: CdpRunner;
target: CdpTarget;
downloads: DownloadsApi;
browserRelativeDir: string;
timeoutMs: number;
signal?: AbortSignal;
trigger(): Promise<ClickResult | RpcError>;
}
export interface DownloadCaptureResult {
click: ClickResult;
item: chrome.downloads.DownloadItem;
}
function safeBasename(filename: string): string {
const basename = filename.split(/[\\/]/).pop()?.trim();
return basename && basename !== "." && basename !== ".." ? basename : "download";
}
function captureError(message: string): RpcError {
return {
code: "cdp_failed",
message,
data: { reason: "download_capture_failed" },
};
}
function sameTarget(source: { tabId?: number; sessionId?: string }, target: CdpTarget): boolean {
return source.tabId === target.tabId && source.sessionId === target.sessionId;
}
function matchesIntent(
item: chrome.downloads.DownloadItem,
intent: { url: string; suggestedFilename: string },
): boolean {
const urlMatches = item.url === intent.url || item.finalUrl === intent.url;
return urlMatches && safeBasename(item.filename) === safeBasename(intent.suggestedFilename);
}
export async function captureBrowserDownload(
options: DownloadCaptureOptions,
): Promise<DownloadCaptureResult | RpcError> {
let capturedId: number | undefined;
let intent: { url: string; suggestedFilename: string } | undefined;
const createdItems = new Map<number, chrome.downloads.DownloadItem>();
let settled = false;
let succeeded = false;
let timer: ReturnType<typeof setTimeout> | undefined;
let rejectCompletion!: (error: Error) => void;
let resolveCompletion!: (item: chrome.downloads.DownloadItem) => void;
const completion = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
resolveCompletion = resolve;
rejectCompletion = reject;
});
const fail = (error: Error) => {
if (settled) return;
settled = true;
rejectCompletion(error);
};
const complete = (item: chrome.downloads.DownloadItem) => {
if (settled) return;
settled = true;
resolveCompletion(item);
};
const claim = (item: chrome.downloads.DownloadItem): boolean => {
if (capturedId === undefined) {
capturedId = item.id;
return true;
}
if (capturedId === item.id) return true;
void options.downloads.cancel(item.id).catch(() => undefined);
fail(new Error("download trigger produced more than one file"));
return false;
};
const determiningListener: DeterminingFilenameListener = (item, suggest) => {
try {
if (!intent || !matchesIntent(item, intent) || !claim(item)) {
suggest();
return;
}
suggest({
filename: `${options.browserRelativeDir}/${safeBasename(intent.suggestedFilename)}`,
conflictAction: "overwrite",
});
const created = createdItems.get(item.id);
if (created?.state === "complete") complete(created);
} catch (err) {
suggest();
fail(err instanceof Error ? err : new Error(String(err)));
}
};
const createdListener = (item: chrome.downloads.DownloadItem) => {
createdItems.set(item.id, item);
if (capturedId !== item.id) return;
if (item.state === "interrupted") {
fail(new Error(item.error ?? "download interrupted"));
} else if (item.state === "complete") {
complete(item);
}
};
const changedListener = async (delta: chrome.downloads.DownloadDelta) => {
if (capturedId === undefined || delta.id !== capturedId || settled) return;
if (delta.state?.current === "interrupted" || delta.error?.current) {
fail(new Error(delta.error?.current ?? "download interrupted"));
return;
}
if (delta.state?.current === "complete") {
try {
const [item] = await options.downloads.search({ id: delta.id });
if (item) complete(item);
else fail(new Error("completed download disappeared"));
} catch (err) {
fail(err instanceof Error ? err : new Error(String(err)));
}
}
};
const onAbort = () => {
fail(new DOMException("aborted", "AbortError"));
};
const cdpSubscription = options.cdp.onEvent?.((source, method, raw) => {
if (method !== "Page.downloadWillBegin" || !sameTarget(source, options.target)) return;
const event = raw as { url?: unknown; suggestedFilename?: unknown };
if (typeof event.url !== "string" || typeof event.suggestedFilename !== "string") return;
if (intent) {
fail(new Error("download trigger produced more than one browser download intent"));
return;
}
intent = { url: event.url, suggestedFilename: event.suggestedFilename };
});
if (!cdpSubscription) {
return captureError("CDP download intent subscription unavailable");
}
options.downloads.onDeterminingFilename.addListener(determiningListener);
options.downloads.onCreated.addListener(createdListener);
options.downloads.onChanged.addListener(changedListener);
options.signal?.addEventListener("abort", onAbort, { once: true });
timer = setTimeout(
() => fail(new Error("download did not complete before timeout")),
options.timeoutMs,
);
try {
const triggered = await options.trigger();
if (isRpcError(triggered)) {
void completion.catch(() => undefined);
return triggered;
}
const item = await completion;
succeeded = true;
return { click: triggered, item };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return captureError(err instanceof Error ? err.message : String(err));
} finally {
settled = true;
if (timer) clearTimeout(timer);
if (!succeeded && capturedId !== undefined) {
await options.downloads.cancel(capturedId).catch(() => undefined);
}
options.signal?.removeEventListener("abort", onAbort);
options.downloads.onDeterminingFilename.removeListener(determiningListener);
options.downloads.onCreated.removeListener(createdListener);
options.downloads.onChanged.removeListener(changedListener);
cdpSubscription.dispose();
}
}
+41 -108
View File
@@ -1,31 +1,16 @@
// One-click/one-file browser download capture. chrome.downloads events are
// browser-global, so a coordinator prevents two bsk sessions from racing.
// Download orchestration: validate session/tab ownership, then delegate one
// browser-global chrome.downloads transaction to download-capture.ts.
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, DownloadParams, DownloadResult, RpcError } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import { captureBrowserDownload, chromeDownloadsApi, type DownloadsApi } from "./download-capture";
import { handleClick, type InteractionDeps, resolveBackendNode } 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 type { DownloadsApi } from "./download-capture";
export interface DownloadDeps extends InteractionDeps {
downloads?: DownloadsApi;
}
@@ -37,11 +22,6 @@ export async function handleDownload(
): 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;
@@ -49,90 +29,43 @@ export async function handleDownload(
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 {}
if (!params.browser_relative_dir) {
return { code: "invalid_params", message: "download requires a daemon capability directory" };
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
const address = await resolveBackendNode(deps.cdp, ctx, target, params, "download");
if (isRpcError(address)) return address;
const capture = await captureBrowserDownload({
cdp: deps.cdp,
target: address.cdpTarget,
downloads: deps.downloads ?? chromeDownloadsApi,
browserRelativeDir: params.browser_relative_dir,
timeoutMs: params.timeout_ms ?? 120_000,
signal: deps.signal,
trigger: () => {
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
return handleClick(manager, clickParams, deps);
},
});
if (isRpcError(capture)) return capture;
const { click, item } = capture;
return {
tab_id: target.tabId,
used_ref: click.used_ref,
used_selector: click.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,
browser_path: item.filename,
};
} finally {
if (timer) clearTimeout(timer);
if (createdListener) downloads.onCreated.removeListener(createdListener);
if (changedListener) downloads.onChanged.removeListener(changedListener);
downloadActive = false;
}
}
-265
View File
@@ -1,265 +0,0 @@
// One file-chooser transaction for an upload. Chrome's extension debugger
// bridge may leave a command pending indefinitely, so every state-changing
// command is bounded and an uncertain target is detached before returning.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { ClickResult, RpcError } from "@/transport/types";
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
const CLEANUP_TIMEOUT_MS = 1_000;
type ChooserPhase = "enable_events" | "trigger" | "await_event" | "set_files";
interface FileChooserOpened {
source: { tabId?: number; sessionId?: string };
backendNodeId?: number;
mode?: string;
}
export interface FileChooserUploadOptions {
cdp: CdpRunner;
target: CdpTarget;
files: string[];
timeoutMs: number;
signal?: AbortSignal;
trigger(timeoutMs: number): Promise<ClickResult | RpcError>;
}
export interface FileChooserUploadResult {
click: ClickResult;
mode?: string;
}
class BoundedWaitError extends Error {
constructor(
readonly kind: "timeout" | "aborted",
message: string,
) {
super(message);
}
}
function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
async function waitBounded<T>(
promise: Promise<T>,
deadline: number,
signal: AbortSignal | undefined,
timeoutMessage: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (signal?.aborted) throw new BoundedWaitError("aborted", "file chooser operation aborted");
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const boundary = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
if (signal) {
onAbort = () => reject(new BoundedWaitError("aborted", "file chooser operation aborted"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
return await Promise.race([promise, boundary]);
} finally {
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
function chooserError(
code: RpcError["code"],
message: string,
reason: "file_chooser_control_failed" | "file_chooser_not_opened",
phase: ChooserPhase,
): RpcError {
return { code, message, data: { reason, phase } };
}
function sameTarget(source: FileChooserOpened["source"], target: CdpTarget): boolean {
return source.tabId === target.tabId && source.sessionId === target.sessionId;
}
async function resetTarget(cdp: CdpRunner, tabId: number): Promise<void> {
if (!cdp.detach) return;
try {
await waitBounded(
cdp.detach(tabId),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"CDP detach timed out",
);
} catch {
// The target may already have detached or closed.
}
}
export async function uploadThroughFileChooser(
options: FileChooserUploadOptions,
): Promise<FileChooserUploadResult | RpcError> {
const deadline = Date.now() + options.timeoutMs;
let resolveOpened!: (value: FileChooserOpened) => void;
const opened = new Promise<FileChooserOpened>((resolve) => {
resolveOpened = resolve;
});
const subscription = options.cdp.onEvent?.((source, method, raw) => {
if (method !== "Page.fileChooserOpened" || !sameTarget(source, options.target)) return;
const event = raw as { backendNodeId?: unknown; mode?: unknown };
resolveOpened({
source,
...(typeof event.backendNodeId === "number" ? { backendNodeId: event.backendNodeId } : {}),
...(typeof event.mode === "string" ? { mode: event.mode } : {}),
});
});
if (!subscription) {
return { code: "unsupported", message: "CDP event subscription unavailable" };
}
let eventsEnabled = false;
let targetStateUnknown = false;
let outcome: FileChooserUploadResult | RpcError;
let aborted: BoundedWaitError | undefined;
try {
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "Page.enable", {
enableFileChooserOpenedEvent: true,
}),
deadline,
options.signal,
"enabling file chooser events timed out",
);
eventsEnabled = true;
} catch (err) {
if (err instanceof BoundedWaitError) {
targetStateUnknown = true;
if (err.kind === "aborted") throw err;
outcome = chooserError(
"timeout",
err.message,
"file_chooser_control_failed",
"enable_events",
);
} else {
outcome = chooserError(
"cdp_failed",
err instanceof Error ? err.message : String(err),
"file_chooser_control_failed",
"enable_events",
);
}
return outcome;
}
let click: ClickResult | RpcError;
try {
click = await waitBounded(
options.trigger(remainingMs(deadline)),
deadline,
options.signal,
"upload trigger timed out",
);
} catch (err) {
if (err instanceof BoundedWaitError) {
targetStateUnknown = true;
if (err.kind === "aborted") throw err;
return chooserError("timeout", err.message, "file_chooser_control_failed", "trigger");
}
return chooserError(
"cdp_failed",
err instanceof Error ? err.message : String(err),
"file_chooser_control_failed",
"trigger",
);
}
if (isRpcError(click)) return click;
let chooser: FileChooserOpened;
try {
chooser = await waitBounded(
opened,
deadline,
options.signal,
"upload trigger did not open a file chooser",
);
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
return chooserError(
"timeout",
err instanceof Error ? err.message : String(err),
"file_chooser_not_opened",
"await_event",
);
}
if (chooser.backendNodeId === undefined) {
return {
code: "unsupported",
message: "this file chooser is not backed by an input element",
data: {
reason: "unsupported_file_chooser",
chooser_kind: "non_input",
manual_fallback_available: true,
},
};
}
if (chooser.mode === "selectSingle" && options.files.length !== 1) {
return { code: "invalid_params", message: "file chooser accepts exactly one file" };
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "DOM.setFileInputFiles", {
files: options.files,
backendNodeId: chooser.backendNodeId,
}),
deadline,
options.signal,
"setting file input files timed out",
);
} catch (err) {
if (err instanceof BoundedWaitError) {
targetStateUnknown = true;
if (err.kind === "aborted") throw err;
return chooserError("timeout", err.message, "file_chooser_control_failed", "set_files");
}
return chooserError(
"cdp_failed",
err instanceof Error ? err.message : String(err),
"file_chooser_control_failed",
"set_files",
);
}
outcome = { click, ...(chooser.mode ? { mode: chooser.mode } : {}) };
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") aborted = err;
else throw err;
outcome = { code: "cancelled", message: "file chooser operation aborted" };
} finally {
subscription.dispose();
if (targetStateUnknown) {
await resetTarget(options.cdp, options.target.tabId);
} else if (eventsEnabled) {
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "Page.enable", {
enableFileChooserOpenedEvent: false,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"restoring file chooser events timed out",
);
} catch {
await resetTarget(options.cdp, options.target.tabId);
}
}
}
if (aborted) {
const error = new DOMException(aborted.message, "AbortError");
throw error;
}
return outcome;
}
@@ -0,0 +1,339 @@
// One upload transaction, independent of Page.fileChooserOpened delivery.
// A transaction-scoped DOM listener records the file input actually activated
// by the requested click and cancels its native default action. File System
// Access picker entry points are replaced only for the same transaction, so a
// non-input picker fails promptly without opening an OS dialog.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { ClickResult, RpcError } from "@/transport/types";
import { type CdpRunner, isRpcError, sendToCdpTarget } from "./shared";
const CLEANUP_TIMEOUT_MS = 1_000;
type UploadPhase = "arm_input_probe" | "trigger" | "resolve_input" | "set_files";
interface RuntimeReply {
result?: {
value?: unknown;
objectId?: string;
};
exceptionDetails?: {
text?: string;
exception?: { description?: string };
};
}
export interface FileInputTransactionOptions {
cdp: CdpRunner;
target: CdpTarget;
files: string[];
timeoutMs: number;
signal?: AbortSignal;
trigger(timeoutMs: number): Promise<ClickResult | RpcError>;
}
export interface FileInputTransactionResult {
click: ClickResult;
multiple: boolean;
}
class BoundedWaitError extends Error {
constructor(
readonly kind: "timeout" | "aborted",
message: string,
) {
super(message);
}
}
function remainingMs(deadline: number): number {
return Math.max(0, deadline - Date.now());
}
async function waitBounded<T>(
promise: Promise<T>,
deadline: number,
signal: AbortSignal | undefined,
timeoutMessage: string,
): Promise<T> {
const remaining = remainingMs(deadline);
if (signal?.aborted) throw new BoundedWaitError("aborted", "upload transaction aborted");
if (remaining === 0) throw new BoundedWaitError("timeout", timeoutMessage);
let timer: ReturnType<typeof setTimeout> | undefined;
let onAbort: (() => void) | undefined;
const boundary = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new BoundedWaitError("timeout", timeoutMessage)), remaining);
if (signal) {
onAbort = () => reject(new BoundedWaitError("aborted", "upload transaction aborted"));
signal.addEventListener("abort", onAbort, { once: true });
}
});
try {
return await Promise.race([promise, boundary]);
} finally {
if (timer) clearTimeout(timer);
if (signal && onAbort) signal.removeEventListener("abort", onAbort);
}
}
function uploadError(
code: RpcError["code"],
message: string,
reason: "file_input_probe_failed" | "file_input_not_activated" | "set_file_input_failed",
phase: UploadPhase,
): RpcError {
return { code, message, data: { reason, phase } };
}
function runtimeError(reply: RuntimeReply, fallback: string): Error | null {
if (!reply.exceptionDetails) return null;
return new Error(
reply.exceptionDetails.exception?.description ?? reply.exceptionDetails.text ?? fallback,
);
}
export async function uploadThroughActivatedFileInput(
options: FileInputTransactionOptions,
): Promise<FileInputTransactionResult | RpcError> {
const deadline = Date.now() + options.timeoutMs;
const objectGroup = `bsk-upload-${crypto.randomUUID()}`;
const stateKey = `__bskUpload_${crypto.randomUUID().replaceAll("-", "")}`;
const stateKeyLiteral = JSON.stringify(stateKey);
let probeAttempted = false;
try {
try {
probeAttempted = true;
const armed = await waitBounded(
sendToCdpTarget<RuntimeReply>(options.cdp, options.target, "Runtime.evaluate", {
expression: `(() => {
const key = ${stateKeyLiteral};
const owner = globalThis;
const doc = document;
const state = { inputs: [], listener: null, pickerCalls: [], pickers: [] };
Object.defineProperty(owner, key, { value: state, configurable: true });
state.listener = event => {
const path = typeof event.composedPath === "function" ? event.composedPath() : [];
const candidate = path[0] || event.target;
if (candidate && candidate.nodeType === 1 &&
candidate.localName === "input" && candidate.type === "file") {
if (!state.inputs.includes(candidate)) state.inputs.push(candidate);
event.preventDefault();
}
};
doc.addEventListener("click", state.listener, true);
const win = doc.defaultView;
for (const name of ["showOpenFilePicker", "showSaveFilePicker", "showDirectoryPicker"]) {
if (!win || typeof win[name] !== "function") continue;
const hadOwn = Object.prototype.hasOwnProperty.call(win, name);
const descriptor = Object.getOwnPropertyDescriptor(win, name);
state.pickers.push({ name, hadOwn, descriptor });
Object.defineProperty(win, name, {
configurable: true,
enumerable: descriptor?.enumerable ?? true,
writable: true,
value: () => {
state.pickerCalls.push(name);
return Promise.reject(
new DOMException("Picker intercepted by BrowserSkill", "AbortError")
);
},
});
}
return true;
})()`,
objectGroup,
returnByValue: true,
}),
deadline,
options.signal,
"arming file input probe timed out",
);
const armError = runtimeError(armed, "failed to arm file input probe");
if (armError) throw armError;
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
return uploadError(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
err instanceof Error ? err.message : String(err),
"file_input_probe_failed",
"arm_input_probe",
);
}
let click: ClickResult | RpcError;
try {
click = await waitBounded(
options.trigger(remainingMs(deadline)),
deadline,
options.signal,
"upload trigger timed out",
);
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
return uploadError(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
err instanceof Error ? err.message : String(err),
"file_input_probe_failed",
"trigger",
);
}
if (isRpcError(click)) return click;
try {
const summary = await waitBounded(
sendToCdpTarget<RuntimeReply>(options.cdp, options.target, "Runtime.evaluate", {
expression: `(() => {
const s = globalThis[${stateKeyLiteral}];
return s
? { count: s.inputs.length, multiple: s.inputs[0]?.multiple === true,
pickerCall: s.pickerCalls[0] }
: { count: 0, multiple: false };
})()`,
returnByValue: true,
}),
deadline,
options.signal,
"resolving activated file input timed out",
);
const summaryError = runtimeError(summary, "failed to inspect activated file input");
if (summaryError) throw summaryError;
const value = summary.result?.value as
| { count?: unknown; multiple?: unknown; pickerCall?: unknown }
| undefined;
const count = typeof value?.count === "number" ? value.count : 0;
const multiple = value?.multiple === true;
if (typeof value?.pickerCall === "string") {
return uploadError(
"unsupported",
`upload trigger invoked ${value.pickerCall} instead of an input[type=file]`,
"file_input_not_activated",
"resolve_input",
);
}
if (count !== 1) {
return uploadError(
"unsupported",
count === 0
? "upload trigger did not activate an input[type=file]"
: "upload trigger activated more than one input[type=file]",
"file_input_not_activated",
"resolve_input",
);
}
if (!multiple && options.files.length !== 1) {
return { code: "invalid_params", message: "file input accepts exactly one file" };
}
const input = await waitBounded(
sendToCdpTarget<RuntimeReply>(options.cdp, options.target, "Runtime.evaluate", {
expression: `globalThis[${stateKeyLiteral}]?.inputs[0]`,
objectGroup,
returnByValue: false,
}),
deadline,
options.signal,
"resolving activated file input object timed out",
);
const inputError = runtimeError(input, "failed to resolve activated file input object");
if (inputError) throw inputError;
const inputObjectId = input.result?.objectId;
if (!inputObjectId) throw new Error("activated file input returned no objectId");
const described = await waitBounded(
sendToCdpTarget<{ node?: { backendNodeId?: number } }>(
options.cdp,
options.target,
"DOM.describeNode",
{ objectId: inputObjectId },
),
deadline,
options.signal,
"describing activated file input timed out",
);
const backendNodeId = described.node?.backendNodeId;
if (typeof backendNodeId !== "number") {
throw new Error("DOM.describeNode returned no file input backendNodeId");
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "DOM.setFileInputFiles", {
files: options.files,
backendNodeId,
}),
deadline,
options.signal,
"setting file input files timed out",
);
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
return uploadError(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
err instanceof Error ? err.message : String(err),
"set_file_input_failed",
"set_files",
);
}
return { click, multiple };
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") throw err;
return uploadError(
err instanceof BoundedWaitError ? "timeout" : "cdp_failed",
err instanceof Error ? err.message : String(err),
"file_input_probe_failed",
"resolve_input",
);
}
} catch (err) {
if (err instanceof BoundedWaitError && err.kind === "aborted") {
return { code: "cancelled", message: "upload transaction aborted" };
}
throw err;
} finally {
if (probeAttempted) {
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "Runtime.evaluate", {
expression: `(() => {
const key = ${stateKeyLiteral};
const state = globalThis[key];
if (state?.listener) {
document.removeEventListener("click", state.listener, true);
}
const win = document.defaultView;
if (win) {
for (const picker of state?.pickers || []) {
try {
if (picker.hadOwn && picker.descriptor) {
Object.defineProperty(win, picker.name, picker.descriptor);
} else {
delete win[picker.name];
}
} catch {}
}
}
delete globalThis[key];
})()`,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"cleaning file input probe timed out",
);
} catch {
// Navigation may have invalidated the object; its document is gone too.
}
}
try {
await waitBounded(
sendToCdpTarget(options.cdp, options.target, "Runtime.releaseObjectGroup", {
objectGroup,
}),
Date.now() + CLEANUP_TIMEOUT_MS,
undefined,
"releasing upload object group timed out",
);
} catch {}
}
}
+1 -1
View File
@@ -128,7 +128,7 @@ async function wait(ms: number, signal?: AbortSignal): Promise<void> {
* `RpcError` if the caller supplied neither (or both), or if neither
* lookup matched.
*/
async function resolveBackendNode(
export async function resolveBackendNode(
cdp: CdpRunner,
ctx: SessionContext,
target: { tabId: number },
+8 -26
View File
@@ -1,11 +1,11 @@
// Upload orchestration: validate the session-scoped request, resolve its click
// target, then delegate the browser protocol transaction to file-chooser.ts.
// target, then delegate the browser protocol transaction to the file-input
// transaction module.
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 { uploadThroughFileChooser } from "./file-chooser";
import { handleClick, type InteractionDeps } from "./interaction";
import { uploadThroughActivatedFileInput } from "./file-input-transaction";
import { handleClick, type InteractionDeps, resolveBackendNode } from "./interaction";
import {
type CdpRunner,
enforceAgentWindow,
@@ -13,7 +13,6 @@ import {
lookupSession,
resolveTargetTab,
} from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref";
const DEFAULT_TIMEOUT_MS = 120_000;
@@ -21,21 +20,6 @@ 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,
@@ -47,8 +31,6 @@ export async function handleUpload(
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 ||
@@ -56,12 +38,12 @@ export async function handleUpload(
) {
return { code: "invalid_params", message: "upload requires daemon-staged files" };
}
const cdpTarget = chooserTarget(params, target.tabId, manager);
if (isRpcError(cdpTarget)) return cdpTarget;
const address = await resolveBackendNode(deps.cdp, ctx, target, params, "upload");
if (isRpcError(address)) return address;
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
const transaction = await uploadThroughFileChooser({
const transaction = await uploadThroughActivatedFileInput({
cdp: deps.cdp,
target: cdpTarget,
target: address.cdpTarget,
files: params.files.map((file) => file.staged_path as string),
timeoutMs,
signal: deps.signal,
+6 -5
View File
@@ -33,9 +33,10 @@ export type RpcErrorReason =
| "restricted_tab_url"
| "borrow_conflict"
| "screenshot_capture_failed"
| "file_chooser_control_failed"
| "file_chooser_not_opened"
| "unsupported_file_chooser"
| "file_input_probe_failed"
| "file_input_not_activated"
| "set_file_input_failed"
| "download_capture_failed"
| "cleanup_failed";
export interface RpcErrorData {
@@ -531,7 +532,7 @@ export interface DownloadParams {
selector?: string;
tab_id?: number;
timeout_ms?: number;
staging_path?: string;
browser_relative_dir?: string;
}
export interface DownloadResult {
@@ -542,7 +543,7 @@ export interface DownloadResult {
byte_size: number;
mime?: string;
danger?: string;
staged_path?: string;
browser_path?: string;
transfer_id?: string;
}