diff --git a/Cargo.lock b/Cargo.lock index a34b300..eeb978b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -137,6 +137,7 @@ name = "bsk" version = "0.1.11" dependencies = [ "anyhow", + "base64", "bsk-protocol", "clap", "console", diff --git a/Cargo.toml b/Cargo.toml index d38556a..e123726 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -36,6 +36,7 @@ flate2 = "1.1.9" tar = "0.4.46" zip = { version = "7.2.0", default-features = false, features = ["deflate"] } sha2 = "0.11.0" +base64 = "0.22" tempfile = "3" nix = { version = "0.29", features = ["signal", "process"] } rand = "0.8" diff --git a/apps/extension/PRIVACY.md b/apps/extension/PRIVACY.md index 9517a3c..38e7968 100644 --- a/apps/extension/PRIVACY.md +++ b/apps/extension/PRIVACY.md @@ -1,6 +1,6 @@ # BrowserSkill — Privacy Policy -**Last updated:** May 25, 2026 +**Last updated:** August 20, 2026 This Privacy Policy describes how the **BrowserSkill** browser extension (the "Extension") handles information when you install and use it. BrowserSkill is published as part of the open-source [BrowserSkill](https://github.com/Tencent/BrowserSkill) project. The source code is publicly auditable. @@ -14,7 +14,7 @@ BrowserSkill is a local automation bridge that lets AI coding agents (such as Cu ## 2. Single Purpose -The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf. +The Extension's single purpose is to expose browser automation primitives (navigation, DOM observation, screenshots, clicks, form filling, task-scoped file transfer, and tab management) to a locally running BrowserSkill daemon over a WebSocket connection on `127.0.0.1`, so that an AI agent invoked by the user can interact with web pages on the user's behalf. ## 3. Data the Extension Accesses @@ -26,6 +26,7 @@ Depending on the commands the user (via their AI agent) sends to the local daemo | **User input simulated by the agent** | Mouse clicks, keystrokes, and form values that the AI agent dispatches through the Chrome DevTools Protocol (CDP). | Required to perform automation actions the user has asked the agent to do. | | **Tab and window metadata** | Tab IDs, URLs, titles, window IDs of the Agent Window and any tabs the user explicitly authorizes. | Required to target automation commands at the correct tab/window. | | **Local extension storage** | A randomly generated 8-character instance ID and an optional user-supplied label. | Used so the local daemon can recognize this browser instance across reconnects. No personal data is stored. | +| **File transfers requested by the agent** | Local files explicitly supplied to `bsk upload`, and the file created by a single `bsk download` action. | Required to attach a task file to a web page or return a browser-generated download to the invoking local agent. | | **OS notifications** | Permission to display a system notification when the agent requests to "borrow" one of the user's existing tabs. | Required to obtain explicit, per-tab user consent before the agent touches any pre-existing tab. | ## 4. Data the Extension Does **Not** Collect @@ -34,7 +35,7 @@ BrowserSkill does **not**: - Send any data to remote servers, the Extension's authors, or any third party. - Call any LLM, AI, or cloud API. The Extension contains no API keys, model identifiers, or remote endpoints. -- Read or transmit cookies, browsing history, bookmarks, downloads, saved passwords, or autofill data. +- Read or transmit cookies, browsing history, bookmarks, saved passwords, or autofill data. It observes only the download initiated by an active `bsk download` call, not download history generally. - Use webcam, microphone, geolocation, or any device sensor. - Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code. - Track users across websites or across sessions. @@ -50,6 +51,7 @@ The Extension requests the following Chrome permissions. Each is used solely for - **`alarms`** — Periodically wake the service worker to keep the local WebSocket connection alive. - **`idle`** — Detect when the device returns from idle/locked so the Extension can promptly re-establish the local WebSocket connection after the machine wakes. No idle data is stored or transmitted. - **`notifications`** — Show a system notification to obtain user approval before the agent borrows a user-owned tab. +- **`downloads`** — Observe and, on cancellation, stop the one browser download initiated by an active `bsk download` command. It is not used to enumerate download history. - **`storage`** — Persist a random instance ID and optional label in `chrome.storage.local`. - **Host permission ``** — Inject a small status overlay (showing "Agent Active") on pages controlled by the agent, and enable automation across whatever sites the user directs the agent to. The Extension does **not** read or transmit page content from sites the agent is not actively driving. @@ -61,6 +63,7 @@ All Extension activity stays on the user's local device. The only network traffi - The instance ID and optional label persist in `chrome.storage.local` until the user uninstalls the Extension or clears extension storage. - Page content, screenshots, DOM snapshots, and other observed data are returned to the local daemon in response to commands and are **not retained by the Extension**. They live only as long as the agent's tool call. +- Upload and download bytes are staged by the local daemon in a private, session-scoped directory. Download staging is removed after it is copied to the requested destination. Upload staging is retained until the session ends so a later form submission can still read the attached file. Remaining staging is removed when the session ends or disconnects, or when the daemon next starts after a crash. ## 8. User Control diff --git a/apps/extension/src/tools/__tests__/file-transfer.test.ts b/apps/extension/src/tools/__tests__/file-transfer.test.ts new file mode 100644 index 0000000..16e1eef --- /dev/null +++ b/apps/extension/src/tools/__tests__/file-transfer.test.ts @@ -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 void>() { + const listeners = new Set(); + 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) => { + 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>[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, + }); + }); +}); diff --git a/apps/extension/src/tools/dispatcher.ts b/apps/extension/src/tools/dispatcher.ts index e0a5ac4..c7cca85 100644 --- a/apps/extension/src/tools/dispatcher.ts +++ b/apps/extension/src/tools/dispatcher.ts @@ -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": diff --git a/apps/extension/src/tools/download.ts b/apps/extension/src/tools/download.ts new file mode 100644 index 0000000..e1b942e --- /dev/null +++ b/apps/extension/src/tools/download.ts @@ -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; + cancel(downloadId: number): Promise; +} + +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 { + 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 | 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((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; + } +} diff --git a/apps/extension/src/tools/upload.ts b/apps/extension/src/tools/upload.ts new file mode 100644 index 0000000..afee918 --- /dev/null +++ b/apps/extension/src/tools/upload.ts @@ -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 { + 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 | 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. + } + } +} diff --git a/apps/extension/src/transport/types.ts b/apps/extension/src/transport/types.ts index dc60fe2..3590257 100644 --- a/apps/extension/src/transport/types.ts +++ b/apps/extension/src/transport/types.ts @@ -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 // -------------------------------------------------------------------------- diff --git a/apps/extension/wxt.config.ts b/apps/extension/wxt.config.ts index 6e272d9..ff4fc53 100644 --- a/apps/extension/wxt.config.ts +++ b/apps/extension/wxt.config.ts @@ -30,6 +30,7 @@ export default defineConfig({ permissions: [ "alarms", "debugger", + "downloads", "idle", "notifications", "tabs", diff --git a/crates/bsk-cli/Cargo.toml b/crates/bsk-cli/Cargo.toml index afc980f..1475e4b 100644 --- a/crates/bsk-cli/Cargo.toml +++ b/crates/bsk-cli/Cargo.toml @@ -53,6 +53,7 @@ flate2 = { workspace = true } tar = { workspace = true } zip = { workspace = true } sha2 = { workspace = true } +base64 = { workspace = true } [target.'cfg(unix)'.dependencies] nix = { workspace = true } diff --git a/crates/bsk-cli/skill/SKILL.md b/crates/bsk-cli/skill/SKILL.md index 42bb452..1b7eccf 100644 --- a/crates/bsk-cli/skill/SKILL.md +++ b/crates/bsk-cli/skill/SKILL.md @@ -206,6 +206,19 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff | `bsk select --value ` | Set `` option values by `value` attribute. Select(SelectArgs), + /// Upload one or more local files through a page file chooser. + Upload(UploadArgs), + + /// Capture one browser download and write it to a local path. + Download(DownloadArgs), + /// Evaluate a JavaScript expression inside the Agent Window. Evaluate(EvaluateArgs), diff --git a/crates/bsk-cli/src/cli/upload.rs b/crates/bsk-cli/src/cli/upload.rs new file mode 100644 index 0000000..ac408e2 --- /dev/null +++ b/crates/bsk-cli/src/cli/upload.rs @@ -0,0 +1,180 @@ +//! `bsk upload` — stage caller-readable files and attach them to a page. + +use std::fs::File; +use std::io::Read; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use base64::Engine; +use bsk_protocol::Method; +use bsk_protocol::tools::{ + TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult, + TransferIdParams, TransferReadyResult, TransferReleaseResult, UploadFile, UploadParams, + UploadResult, +}; +use clap::Args; + +use crate::cli::ensure_daemon::ensure_daemon; +use crate::cli::error::{CliError, Format}; +use crate::cli::interaction::split_target; +use crate::cli::navigate::parse_timeout_ms; + +#[derive(Debug, Clone, Args)] +pub struct UploadArgs { + /// Snapshot ref (`@e3`) or CSS selector for the file input / chooser trigger. + pub target: Option, + #[arg(long = "ref")] + pub ref_: Option, + #[arg(long = "selector")] + pub selector: Option, + /// Local file to upload. Repeat for a multiple-file input. + #[arg(long = "file", required = true)] + pub files: Vec, + #[arg(long)] + pub session: String, + #[arg(long = "tab-id")] + pub tab_id: Option, + #[arg(long, default_value = "2m", value_parser = parse_timeout_ms)] + pub timeout: u32, +} + +pub fn dispatch(args: UploadArgs, format: Format) -> Result<(), CliError> { + if args.files.len() > crate::daemon::file_transfer::MAX_UPLOAD_FILES { + return Err(CliError::Local(anyhow::anyhow!( + "upload accepts at most {} files", + crate::daemon::file_transfer::MAX_UPLOAD_FILES + ))); + } + let info = ensure_daemon().context("ensure daemon is running")?; + let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?; + let mut staged = Vec::new(); + let result = (|| { + for path in &args.files { + staged.push(stage_file(&info.sock_path, &args.session, path)?); + } + let params = UploadParams { + session_id: args.session, + ref_, + selector, + tab_id: args.tab_id, + files: staged + .iter() + .map(|(id, name)| UploadFile { + transfer_id: id.clone(), + name: name.clone(), + staged_path: None, + }) + .collect(), + timeout_ms: Some(args.timeout), + }; + crate::cli::business_rpc::call::<_, UploadResult>( + info.sock_path.clone(), + "upload", + Method::ToolUpload, + Some(params), + ipc_timeout(args.timeout), + ) + })(); + if result.is_err() { + for (id, _) in &staged { + let _ = release(&info.sock_path, id); + } + } + let reply = result?; + match format { + Format::Json => println!("{}", serde_json::to_string_pretty(&reply).unwrap()), + Format::Human => println!( + "upload ok tab={} files={}", + reply.tab_id, + reply.file_names.join(", ") + ), + } + Ok(()) +} + +fn stage_file(sock: &PathBuf, session: &str, path: &PathBuf) -> Result<(String, String), CliError> { + let mut file = File::open(path) + .with_context(|| format!("open upload file {}", path.display())) + .map_err(CliError::Local)?; + let meta = file + .metadata() + .with_context(|| format!("read upload file metadata {}", path.display())) + .map_err(CliError::Local)?; + if !meta.is_file() { + return Err(CliError::Local(anyhow::anyhow!( + "upload source is not a regular file: {}", + path.display() + ))); + } + let name = path + .file_name() + .and_then(|v| v.to_str()) + .filter(|v| !v.is_empty()) + .ok_or_else(|| CliError::Local(anyhow::anyhow!("upload file has no valid basename")))? + .to_string(); + let begin: TransferBeginResult = crate::cli::business_rpc::call( + sock.clone(), + "transfer-begin", + Method::TransferBegin, + Some(TransferBeginParams { + session_id: session.to_string(), + name: name.clone(), + byte_size: meta.len(), + }), + Duration::from_secs(10), + )?; + let staged = (|| { + let mut offset = 0u64; + let mut buf = vec![0u8; begin.chunk_size as usize]; + loop { + let n = file.read(&mut buf).map_err(|e| CliError::Local(e.into()))?; + if n == 0 { + break; + } + let reply: TransferChunkResult = crate::cli::business_rpc::call( + sock.clone(), + "transfer-chunk", + Method::TransferChunk, + Some(TransferChunkParams { + transfer_id: begin.transfer_id.clone(), + offset, + data_base64: base64::engine::general_purpose::STANDARD.encode(&buf[..n]), + }), + Duration::from_secs(30), + )?; + offset = reply.next_offset; + } + let _: TransferReadyResult = crate::cli::business_rpc::call( + sock.clone(), + "transfer-finish", + Method::TransferFinish, + Some(TransferIdParams { + transfer_id: begin.transfer_id.clone(), + }), + Duration::from_secs(10), + )?; + Ok::<_, CliError>(()) + })(); + if let Err(err) = staged { + let _ = release(sock, &begin.transfer_id); + return Err(err); + } + Ok((begin.transfer_id, name)) +} + +fn release(sock: &PathBuf, id: &str) -> Result { + crate::cli::business_rpc::call( + sock.clone(), + "transfer-release", + Method::TransferRelease, + Some(TransferIdParams { + transfer_id: id.to_string(), + }), + Duration::from_secs(5), + ) +} + +fn ipc_timeout(timeout_ms: u32) -> Duration { + Duration::from_millis(u64::from(timeout_ms) + 5_000) +} diff --git a/crates/bsk-cli/src/daemon/file_transfer.rs b/crates/bsk-cli/src/daemon/file_transfer.rs new file mode 100644 index 0000000..a3084eb --- /dev/null +++ b/crates/bsk-cli/src/daemon/file_transfer.rs @@ -0,0 +1,493 @@ +//! Session-scoped, content-transparent staging for upload/download tools. +//! +//! The registry deliberately exposes no arbitrary-path operations. Upload +//! bytes arrive from the invoking CLI in bounded chunks; browser downloads +//! land in a daemon-minted directory and are read back by the invoking CLI. + +use std::collections::HashMap; +use std::fs::{self, File, OpenOptions}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; + +use base64::Engine; +use bsk_protocol::tools::{ + TransferBeginParams, TransferBeginResult, TransferChunkParams, TransferChunkResult, + TransferIdParams, TransferReadyResult, TransferReleaseResult, +}; +use bsk_protocol::{ErrorCode, RpcError}; +use uuid::Uuid; + +use super::paths; + +pub const TRANSFER_CHUNK_SIZE: u32 = 512 * 1024; +pub const MAX_TRANSFER_BYTES: u64 = 512 * 1024 * 1024; +pub const MAX_UPLOAD_FILES: usize = 20; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Direction { + Upload, + Download, +} + +#[derive(Debug)] +struct Entry { + session_id: String, + direction: Direction, + path: PathBuf, + expected_size: Option, + written: u64, + ready: bool, +} + +#[derive(Debug)] +pub struct DownloadStaging { + pub transfer_id: String, + pub directory: PathBuf, +} + +#[derive(Debug)] +pub struct TransferRegistry { + root: PathBuf, + initialized: Mutex, + entries: Mutex>, +} + +impl TransferRegistry { + pub fn new() -> anyhow::Result { + Ok(Self { + root: paths::bsk_home()?.join("run").join("transfers"), + initialized: Mutex::new(false), + entries: Mutex::new(HashMap::new()), + }) + } + + #[cfg(test)] + fn at_root(root: PathBuf) -> anyhow::Result { + let registry = Self { + root, + initialized: Mutex::new(false), + entries: Mutex::new(HashMap::new()), + }; + registry.ensure_root()?; + Ok(registry) + } + + pub fn initialize(&self) -> anyhow::Result<()> { + self.ensure_root() + } + + fn ensure_root(&self) -> anyhow::Result<()> { + let mut initialized = self.initialized.lock().unwrap(); + if *initialized { + return Ok(()); + } + if self.root.exists() { + // Transfers never survive a daemon; removing stale directories on + // startup gives crash cleanup without a second persistence model. + let _ = fs::remove_dir_all(&self.root); + } + fs::create_dir_all(&self.root)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&self.root, fs::Permissions::from_mode(0o700))?; + } + *initialized = true; + Ok(()) + } + + pub fn begin_upload(&self, p: TransferBeginParams) -> Result { + self.ensure_root() + .map_err(|err| protocol_error(err.to_string()))?; + if p.byte_size > MAX_TRANSFER_BYTES { + return Err(invalid(format!( + "file size {} exceeds transfer limit {}", + p.byte_size, MAX_TRANSFER_BYTES + ))); + } + let mut entries = self.entries.lock().unwrap(); + let session_bytes: u64 = entries + .values() + .filter(|entry| entry.session_id == p.session_id) + .map(|entry| entry.expected_size.unwrap_or(entry.written)) + .sum(); + if session_bytes.saturating_add(p.byte_size) > MAX_TRANSFER_BYTES { + return Err(invalid(format!( + "session transfer staging exceeds limit {}", + MAX_TRANSFER_BYTES + ))); + } + let id = new_id(); + let dir = self.root.join(&id); + fs::create_dir(&dir).map_err(io_error)?; + set_private_dir(&dir).map_err(io_error)?; + let path = dir.join("payload"); + OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(io_error)?; + entries.insert( + id.clone(), + Entry { + session_id: p.session_id, + direction: Direction::Upload, + path, + expected_size: Some(p.byte_size), + written: 0, + ready: false, + }, + ); + Ok(TransferBeginResult { + transfer_id: id, + chunk_size: TRANSFER_CHUNK_SIZE, + }) + } + + pub fn write_chunk(&self, p: TransferChunkParams) -> Result { + let bytes = base64::engine::general_purpose::STANDARD + .decode(p.data_base64) + .map_err(|e| invalid(format!("invalid transfer chunk: {e}")))?; + if bytes.len() > TRANSFER_CHUNK_SIZE as usize { + return Err(invalid("transfer chunk exceeds negotiated size")); + } + let mut entries = self.entries.lock().unwrap(); + let entry = entries + .get_mut(&p.transfer_id) + .ok_or_else(|| not_found("transfer not found"))?; + if entry.direction != Direction::Upload || entry.ready { + return Err(invalid("transfer is not writable")); + } + if p.offset != entry.written { + return Err(invalid(format!( + "non-sequential transfer offset: expected {}, got {}", + entry.written, p.offset + ))); + } + let next = entry.written.saturating_add(bytes.len() as u64); + if next > entry.expected_size.unwrap_or(MAX_TRANSFER_BYTES) { + return Err(invalid("transfer exceeds declared byte size")); + } + let mut file = OpenOptions::new() + .append(true) + .open(&entry.path) + .map_err(io_error)?; + file.write_all(&bytes).map_err(io_error)?; + entry.written = next; + Ok(TransferChunkResult { + next_offset: next, + eof: false, + data_base64: None, + }) + } + + pub fn finish_upload(&self, p: TransferIdParams) -> Result { + let mut entries = self.entries.lock().unwrap(); + let entry = entries + .get_mut(&p.transfer_id) + .ok_or_else(|| not_found("transfer not found"))?; + if entry.direction != Direction::Upload { + return Err(invalid("transfer is not an upload")); + } + if entry.written != entry.expected_size.unwrap_or(entry.written) { + return Err(invalid(format!( + "incomplete transfer: expected {} bytes, received {}", + entry.expected_size.unwrap_or(0), + entry.written + ))); + } + entry.ready = true; + Ok(TransferReadyResult { + transfer_id: p.transfer_id, + byte_size: entry.written, + }) + } + + pub fn resolve_uploads( + &self, + session_id: &str, + ids: &[String], + ) -> Result, RpcError> { + let entries = self.entries.lock().unwrap(); + ids.iter() + .map(|id| { + let entry = entries + .get(id) + .ok_or_else(|| not_found("upload transfer not found"))?; + if entry.session_id != session_id + || entry.direction != Direction::Upload + || !entry.ready + { + return Err(permission( + "upload transfer is outside this session or not ready", + )); + } + Ok(entry.path.clone()) + }) + .collect() + } + + pub fn begin_download(&self, session_id: &str) -> Result { + self.ensure_root() + .map_err(|err| protocol_error(err.to_string()))?; + let id = new_id(); + let dir = self.root.join(&id); + fs::create_dir(&dir).map_err(io_error)?; + set_private_dir(&dir).map_err(io_error)?; + self.entries.lock().unwrap().insert( + id.clone(), + Entry { + session_id: session_id.to_string(), + direction: Direction::Download, + path: dir.clone(), + expected_size: None, + written: 0, + ready: false, + }, + ); + Ok(DownloadStaging { + transfer_id: id, + directory: dir, + }) + } + + pub fn finish_download(&self, id: &str, reported_path: &Path) -> Result { + let canonical = reported_path.canonicalize().map_err(io_error)?; + let mut entries = self.entries.lock().unwrap(); + let entry = entries + .get_mut(id) + .ok_or_else(|| not_found("download transfer not found"))?; + let dir = entry.path.canonicalize().map_err(io_error)?; + if entry.direction != Direction::Download || !canonical.starts_with(&dir) { + return Err(permission("download escaped its staging directory")); + } + let meta = fs::metadata(&canonical).map_err(io_error)?; + if !meta.is_file() || meta.len() > MAX_TRANSFER_BYTES { + return Err(invalid( + "download is not a regular file or exceeds the transfer limit", + )); + } + entry.path = canonical; + entry.written = meta.len(); + entry.expected_size = Some(meta.len()); + entry.ready = true; + Ok(meta.len()) + } + + pub fn read_chunk(&self, p: TransferChunkParams) -> Result { + let entries = self.entries.lock().unwrap(); + let entry = entries + .get(&p.transfer_id) + .ok_or_else(|| not_found("transfer not found"))?; + if entry.direction != Direction::Download || !entry.ready { + return Err(invalid("transfer is not readable")); + } + if p.offset > entry.written { + return Err(invalid("read offset is beyond transfer length")); + } + let mut file = File::open(&entry.path).map_err(io_error)?; + file.seek(SeekFrom::Start(p.offset)).map_err(io_error)?; + let mut buf = vec![0u8; TRANSFER_CHUNK_SIZE as usize]; + let count = file.read(&mut buf).map_err(io_error)?; + buf.truncate(count); + let next = p.offset + count as u64; + Ok(TransferChunkResult { + next_offset: next, + eof: next >= entry.written, + data_base64: Some(base64::engine::general_purpose::STANDARD.encode(buf)), + }) + } + + pub fn release(&self, p: TransferIdParams) -> TransferReleaseResult { + let entry = self.entries.lock().unwrap().remove(&p.transfer_id); + if let Some(entry) = entry { + let dir = if entry.path.is_dir() { + entry.path + } else { + entry.path.parent().unwrap_or(&self.root).to_path_buf() + }; + let _ = fs::remove_dir_all(dir); + TransferReleaseResult { released: true } + } else { + TransferReleaseResult { released: false } + } + } + + pub fn release_session(&self, session_id: &str) { + let ids: Vec = self + .entries + .lock() + .unwrap() + .iter() + .filter(|(_, entry)| entry.session_id == session_id) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + self.release(TransferIdParams { transfer_id: id }); + } + } +} + +fn new_id() -> String { + format!("tr_{}", Uuid::new_v4().simple()) +} + +fn set_private_dir(path: &Path) -> std::io::Result<()> { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + } + Ok(()) +} + +fn invalid(message: impl Into) -> RpcError { + RpcError { + code: ErrorCode::InvalidParams, + message: message.into(), + data: None, + } +} +fn not_found(message: impl Into) -> RpcError { + RpcError { + code: ErrorCode::NotFound, + message: message.into(), + data: None, + } +} +fn permission(message: impl Into) -> RpcError { + RpcError { + code: ErrorCode::PermissionDenied, + message: message.into(), + data: None, + } +} +fn io_error(err: std::io::Error) -> RpcError { + RpcError { + code: ErrorCode::ProtocolError, + message: err.to_string(), + data: None, + } +} + +fn protocol_error(message: impl Into) -> RpcError { + RpcError { + code: ErrorCode::ProtocolError, + message: message.into(), + data: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn registry() -> (tempfile::TempDir, TransferRegistry) { + let temp = tempfile::tempdir().unwrap(); + let registry = TransferRegistry::at_root(temp.path().join("transfers")).unwrap(); + (temp, registry) + } + + #[test] + fn upload_is_not_resolvable_until_complete_or_from_another_session() { + let (_temp, registry) = registry(); + let begin = registry + .begin_upload(TransferBeginParams { + session_id: "s1".into(), + name: "image.png".into(), + byte_size: 3, + }) + .unwrap(); + assert!( + registry + .resolve_uploads("s1", &[begin.transfer_id.clone()]) + .is_err() + ); + registry + .write_chunk(TransferChunkParams { + transfer_id: begin.transfer_id.clone(), + offset: 0, + data_base64: base64::engine::general_purpose::STANDARD.encode(b"abc"), + }) + .unwrap(); + registry + .finish_upload(TransferIdParams { + transfer_id: begin.transfer_id.clone(), + }) + .unwrap(); + assert!( + registry + .resolve_uploads("s2", &[begin.transfer_id.clone()]) + .is_err() + ); + let [path] = registry + .resolve_uploads("s1", &[begin.transfer_id]) + .unwrap() + .try_into() + .unwrap(); + assert_eq!(fs::read(path).unwrap(), b"abc"); + } + + #[test] + fn download_must_finish_inside_its_minted_directory() { + let (temp, registry) = registry(); + let staging = registry.begin_download("s1").unwrap(); + let outside = temp.path().join("outside.bin"); + fs::write(&outside, b"secret").unwrap(); + assert!( + registry + .finish_download(&staging.transfer_id, &outside) + .is_err() + ); + + let inside = staging.directory.join("result.bin"); + fs::write(&inside, b"result").unwrap(); + assert_eq!( + registry + .finish_download(&staging.transfer_id, &inside) + .unwrap(), + 6 + ); + } + + #[test] + fn releasing_a_session_removes_all_staging() { + let (_temp, registry) = registry(); + let first = registry.begin_download("s1").unwrap(); + let second = registry.begin_download("s2").unwrap(); + registry.release_session("s1"); + assert!(!first.directory.exists()); + assert!(second.directory.exists()); + } + + #[test] + fn upload_staging_is_bounded_across_a_session() { + let (_temp, registry) = registry(); + registry + .begin_upload(TransferBeginParams { + session_id: "s1".into(), + name: "large.bin".into(), + byte_size: MAX_TRANSFER_BYTES, + }) + .unwrap(); + assert!( + registry + .begin_upload(TransferBeginParams { + session_id: "s1".into(), + name: "one-more-byte.bin".into(), + byte_size: 1, + }) + .is_err() + ); + assert!( + registry + .begin_upload(TransferBeginParams { + session_id: "s2".into(), + name: "other-session.bin".into(), + byte_size: 1, + }) + .is_ok() + ); + } +} diff --git a/crates/bsk-cli/src/daemon/ipc.rs b/crates/bsk-cli/src/daemon/ipc.rs index 38d0046..38ee666 100644 --- a/crates/bsk-cli/src/daemon/ipc.rs +++ b/crates/bsk-cli/src/daemon/ipc.rs @@ -27,7 +27,8 @@ use bsk_protocol::system::{ VersionSkewEntry, }; use bsk_protocol::tools::{ - HelpOutcome, RequestHelpResult, ReturnFailure, WaitMsParams, WaitMsResult, + DownloadParams, DownloadResult, HelpOutcome, RequestHelpResult, ReturnFailure, + TransferBeginParams, TransferIdParams, UploadParams, WaitMsParams, WaitMsResult, }; use bsk_protocol::{ CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId, @@ -223,6 +224,11 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler Ok(v) => ResponseBody::Ok(v), Err(e) => ResponseBody::Err(e), }, + Method::TransferBegin => handle_transfer_begin(&state, params), + Method::TransferChunk => handle_transfer_chunk(&state, params), + Method::TransferFinish => handle_transfer_finish(&state, params), + Method::TransferRead => handle_transfer_read(&state, params), + Method::TransferRelease => handle_transfer_release(&state, params), Method::ToolTabList | Method::ToolTabCreate | Method::ToolTabClose @@ -246,6 +252,8 @@ pub fn full_handler(status: DaemonStatus, state: Arc) -> RpcHandler | Method::ToolFill | Method::ToolPress | Method::ToolSelect + | Method::ToolUpload + | Method::ToolDownload | Method::ToolEvaluate | Method::ToolWaitForNavigation | Method::ToolRequestHelp @@ -338,24 +346,179 @@ async fn handle_tool_dispatch( }); } }; + let mut params = params; + let mut download_transfer_id: Option = None; + if method == Method::ToolUpload { + let mut upload: UploadParams = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + if upload.files.is_empty() || upload.files.len() > super::file_transfer::MAX_UPLOAD_FILES { + return ResponseBody::Err(invalid_params(format!( + "upload requires 1..={} files", + super::file_transfer::MAX_UPLOAD_FILES + ))); + } + let ids: Vec = upload.files.iter().map(|f| f.transfer_id.clone()).collect(); + let paths = match state.transfers.resolve_uploads(&session_id.0, &ids) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(err), + }; + for (file, path) in upload.files.iter_mut().zip(paths) { + file.staged_path = Some(path.to_string_lossy().into_owned()); + } + params = serde_json::to_value(upload).unwrap_or(Value::Null); + } else if method == Method::ToolDownload { + let mut download: DownloadParams = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + let staging = match state.transfers.begin_download(&session_id.0) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(err), + }; + download.staging_path = Some(staging.directory.to_string_lossy().into_owned()); + download_transfer_id = Some(staging.transfer_id); + params = serde_json::to_value(download).unwrap_or(Value::Null); + } let entry = inflight_guard.entry(); // `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)) + .dispatch_unlocked(&session_id, method.clone(), params, timeout, Some(entry)) .await } else { state .tool_queues - .dispatch(&session_id, method, params, timeout, Some(entry)) + .dispatch(&session_id, method.clone(), params, timeout, Some(entry)) .await }; drop(inflight_guard); match outcome { + Ok(v) if method == Method::ToolDownload => { + let id = download_transfer_id.expect("download transfer allocated"); + let mut result: DownloadResult = match serde_json::from_value(v) { + Ok(v) => v, + Err(err) => { + state + .transfers + .release(TransferIdParams { transfer_id: id }); + return ResponseBody::Err(RpcError { + code: ErrorCode::ProtocolError, + message: format!("invalid tool.download result: {err}"), + data: None, + }); + } + }; + let Some(path) = result.staged_path.take() else { + state + .transfers + .release(TransferIdParams { transfer_id: id }); + return ResponseBody::Err(RpcError { + code: ErrorCode::ProtocolError, + message: "tool.download returned no staged_path".into(), + data: None, + }); + }; + match state + .transfers + .finish_download(&id, std::path::Path::new(&path)) + { + Ok(size) => { + result.byte_size = size; + result.transfer_id = Some(id); + ResponseBody::Ok(serde_json::to_value(result).unwrap_or(Value::Null)) + } + Err(err) => { + state + .transfers + .release(TransferIdParams { transfer_id: id }); + ResponseBody::Err(err) + } + } + } Ok(v) => ResponseBody::Ok(v), - Err(err) => ResponseBody::Err(err.into_rpc()), + Err(err) => { + if let Some(id) = download_transfer_id { + state + .transfers + .release(TransferIdParams { transfer_id: id }); + } + ResponseBody::Err(err.into_rpc()) + } + } +} + +fn handle_transfer_begin(state: &Arc, params: Value) -> ResponseBody { + let p: TransferBeginParams = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + if state + .sessions + .get(&SessionId(p.session_id.clone())) + .is_none() + { + return ResponseBody::Err(RpcError { + code: ErrorCode::NotFound, + message: format!("session {} unknown", p.session_id), + data: None, + }); + } + match state.transfers.begin_upload(p) { + Ok(v) => ResponseBody::Ok(serde_json::to_value(v).unwrap_or(Value::Null)), + Err(err) => ResponseBody::Err(err), + } +} + +fn handle_transfer_chunk(state: &Arc, params: Value) -> ResponseBody { + let p = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + match state.transfers.write_chunk(p) { + Ok(v) => ResponseBody::Ok(serde_json::to_value(v).unwrap_or(Value::Null)), + Err(err) => ResponseBody::Err(err), + } +} + +fn handle_transfer_finish(state: &Arc, params: Value) -> ResponseBody { + let p = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + match state.transfers.finish_upload(p) { + Ok(v) => ResponseBody::Ok(serde_json::to_value(v).unwrap_or(Value::Null)), + Err(err) => ResponseBody::Err(err), + } +} + +fn handle_transfer_read(state: &Arc, params: Value) -> ResponseBody { + let p = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + match state.transfers.read_chunk(p) { + Ok(v) => ResponseBody::Ok(serde_json::to_value(v).unwrap_or(Value::Null)), + Err(err) => ResponseBody::Err(err), + } +} + +fn handle_transfer_release(state: &Arc, params: Value) -> ResponseBody { + let p: TransferIdParams = match serde_json::from_value(params) { + Ok(v) => v, + Err(err) => return ResponseBody::Err(invalid_params(err.to_string())), + }; + ResponseBody::Ok(serde_json::to_value(state.transfers.release(p)).unwrap_or(Value::Null)) +} + +fn invalid_params(message: impl Into) -> RpcError { + RpcError { + code: ErrorCode::InvalidParams, + message: message.into(), + data: None, } } @@ -798,6 +961,7 @@ async fn handle_session_stop( .await { Ok(stop) => { + state.transfers.release_session(&session_id.0); let result = CliSessionStopResult { stopped: vec![session_id.0], failed: Vec::new(), @@ -897,6 +1061,7 @@ async fn handle_session_stop_all( .await { Ok(stop) => { + state.transfers.release_session(&id.0); stopped.push(id.0); returned_tab_ids.extend(stop.returned_tab_ids); return_failures.extend(stop.return_failures); diff --git a/crates/bsk-cli/src/daemon/mod.rs b/crates/bsk-cli/src/daemon/mod.rs index c639f73..3196062 100644 --- a/crates/bsk-cli/src/daemon/mod.rs +++ b/crates/bsk-cli/src/daemon/mod.rs @@ -3,6 +3,7 @@ pub mod abort; pub mod browsers; mod cancel_forward; +pub mod file_transfer; pub mod inflight; pub mod info; pub mod ipc; diff --git a/crates/bsk-cli/src/daemon/start.rs b/crates/bsk-cli/src/daemon/start.rs index 241eb85..cb6460c 100644 --- a/crates/bsk-cli/src/daemon/start.rs +++ b/crates/bsk-cli/src/daemon/start.rs @@ -225,6 +225,10 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> { .with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?; let state = Arc::new(DaemonState::new(cfg.clone())); + state + .transfers + .initialize() + .context("initialize transfer staging")?; let session_idle_task = spawn_session_idle_reaper(Arc::clone(&state)); let browser_liveness_task = spawn_browser_liveness_reaper(Arc::clone(&state)); // Fired by the update check task after a successful auto-update: @@ -448,6 +452,7 @@ pub(crate) fn spawn_browser_liveness_reaper( for s in state.sessions.purge_browser(&client.id) { state.tool_queues.remove(&s.id); state.session_interrupts.drop_session(&s.id); + state.transfers.release_session(&s.id.0); debug!(session = %s.id, "purged session on browser liveness timeout"); } } @@ -485,7 +490,10 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc) -> tokio::task: ) .await { - Ok(_) => info!(session = %session_id, "idle session stopped"), + Ok(_) => { + state.transfers.release_session(&session_id.0); + info!(session = %session_id, "idle session stopped"); + } Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => { debug!(session = %session_id, "idle session still active; retrying later"); } @@ -496,6 +504,7 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc) -> tokio::task: &state.session_interrupts, &session_id, ); + state.transfers.release_session(&session_id.0); } Err(err) => { warn!(session = %session_id, error = %err, "failed to stop idle session"); diff --git a/crates/bsk-cli/src/daemon/state.rs b/crates/bsk-cli/src/daemon/state.rs index ad3b48b..1001f3e 100644 --- a/crates/bsk-cli/src/daemon/state.rs +++ b/crates/bsk-cli/src/daemon/state.rs @@ -7,6 +7,7 @@ use tokio::task::JoinHandle; use super::abort::AbortRegistry; use super::browsers::BrowserRegistry; +use super::file_transfer::TransferRegistry; use super::inflight::ToolInflightRegistry; use super::ipc::IpcHandle; use super::queue::ToolQueueRegistry; @@ -51,6 +52,9 @@ pub struct DaemonState { /// `SessionRegistry` because the signal is a transient runtime /// control state. pub session_interrupts: Arc, + /// Operation-scoped local file staging. The extension only sees paths + /// minted here; agent-facing RPCs use opaque transfer ids. + pub transfers: Arc, } impl DaemonState { @@ -64,6 +68,7 @@ impl DaemonState { )); let abort_registry = Arc::new(AbortRegistry::new()); let session_interrupts = Arc::new(SessionInterruptRegistry::new()); + let transfers = Arc::new(TransferRegistry::new().expect("initialise transfer staging")); Self { config, browsers, @@ -72,6 +77,7 @@ impl DaemonState { abort_registry, tool_inflight, session_interrupts, + transfers, } } } diff --git a/crates/bsk-cli/src/daemon/ws.rs b/crates/bsk-cli/src/daemon/ws.rs index 435b0a9..e571f3d 100644 --- a/crates/bsk-cli/src/daemon/ws.rs +++ b/crates/bsk-cli/src/daemon/ws.rs @@ -295,6 +295,7 @@ async fn drive_connection( for session in state.sessions.purge_browser(&browser_id) { state.tool_queues.remove(&session.id); state.session_interrupts.drop_session(&session.id); + state.transfers.release_session(&session.id.0); debug!(session = %session.id, "purged stale session before browser reconnect"); } let (tx, mut rx) = mpsc::unbounded_channel::(); @@ -407,6 +408,7 @@ async fn drive_connection( for s in state.sessions.purge_browser(&browser_id) { state.tool_queues.remove(&s.id); state.session_interrupts.drop_session(&s.id); + state.transfers.release_session(&s.id.0); debug!(session = %s.id, "purged session on browser disconnect"); } } else { @@ -503,6 +505,7 @@ fn handle_session_window_closed( &state.session_interrupts, &session_id, ) { + state.transfers.release_session(&session_id.0); info!(session = %session_id, "session removed: user closed Agent Window"); } else { debug!(session = %session_id, "session.window_closed for unknown session id"); diff --git a/crates/bsk-cli/src/main.rs b/crates/bsk-cli/src/main.rs index 86c1451..6ac5468 100644 --- a/crates/bsk-cli/src/main.rs +++ b/crates/bsk-cli/src/main.rs @@ -97,6 +97,8 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> { Command::Fill(args) => cli::interaction::dispatch_fill(args, format), Command::Press(args) => cli::interaction::dispatch_press(args, format), Command::Select(args) => cli::interaction::dispatch_select(args, format), + Command::Upload(args) => cli::upload::dispatch(args, format), + Command::Download(args) => cli::download::dispatch(args, format), Command::Evaluate(args) => cli::evaluate::dispatch(args, format), Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format), Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format), diff --git a/crates/bsk-cli/tests/cli_parse.rs b/crates/bsk-cli/tests/cli_parse.rs index f8cb0f2..ff7c87f 100644 --- a/crates/bsk-cli/tests/cli_parse.rs +++ b/crates/bsk-cli/tests/cli_parse.rs @@ -213,6 +213,45 @@ fn rejects_zero_click_count() { ); } +#[test] +fn parses_upload_with_repeated_files() { + let cli = parse(&[ + "bsk", + "upload", + "@e3", + "--file", + "one.png", + "--file", + "two.png", + "--session", + "s1", + ]); + let Command::Upload(args) = cli.command else { + panic!("expected upload command"); + }; + assert_eq!(args.target.as_deref(), Some("@e3")); + assert_eq!(args.files.len(), 2); +} + +#[test] +fn parses_download_with_exact_output_policy() { + let cli = parse(&[ + "bsk", + "download", + "#export", + "--out", + "result.zip", + "--session", + "s1", + "--overwrite", + ]); + let Command::Download(args) = cli.command else { + panic!("expected download command"); + }; + assert_eq!(args.target.as_deref(), Some("#export")); + assert!(args.overwrite); +} + #[test] fn parses_record_start_with_browser_and_url() { let cli = parse(&[ diff --git a/crates/bsk-protocol/schema/tool_download_params.json b/crates/bsk-protocol/schema/tool_download_params.json new file mode 100644 index 0000000..ec3c844 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_download_params.json @@ -0,0 +1,47 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DownloadParams", + "type": "object", + "required": [ + "session_id" + ], + "properties": { + "ref": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "staging_path": { + "description": "Daemon-injected destination directory for the browser process.", + "type": [ + "string", + "null" + ] + }, + "tab_id": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "timeout_ms": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + } +} diff --git a/crates/bsk-protocol/schema/tool_download_result.json b/crates/bsk-protocol/schema/tool_download_result.json new file mode 100644 index 0000000..ea2ac7c --- /dev/null +++ b/crates/bsk-protocol/schema/tool_download_result.json @@ -0,0 +1,62 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "DownloadResult", + "type": "object", + "required": [ + "byte_size", + "suggested_filename", + "tab_id" + ], + "properties": { + "byte_size": { + "type": "integer", + "format": "uint64", + "minimum": 0.0 + }, + "danger": { + "type": [ + "string", + "null" + ] + }, + "mime": { + "type": [ + "string", + "null" + ] + }, + "staged_path": { + "description": "Extension-internal completed path, stripped by the daemon.", + "type": [ + "string", + "null" + ] + }, + "suggested_filename": { + "type": "string" + }, + "tab_id": { + "type": "integer", + "format": "int64" + }, + "transfer_id": { + "description": "Opaque id returned by the daemon to the CLI.", + "type": [ + "string", + "null" + ] + }, + "used_ref": { + "type": [ + "string", + "null" + ] + }, + "used_selector": { + "type": [ + "string", + "null" + ] + } + } +} diff --git a/crates/bsk-protocol/schema/tool_upload_params.json b/crates/bsk-protocol/schema/tool_upload_params.json new file mode 100644 index 0000000..98a0747 --- /dev/null +++ b/crates/bsk-protocol/schema/tool_upload_params.json @@ -0,0 +1,70 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UploadParams", + "type": "object", + "required": [ + "files", + "session_id" + ], + "properties": { + "files": { + "type": "array", + "items": { + "$ref": "#/definitions/UploadFile" + } + }, + "ref": { + "type": [ + "string", + "null" + ] + }, + "selector": { + "type": [ + "string", + "null" + ] + }, + "session_id": { + "type": "string" + }, + "tab_id": { + "type": [ + "integer", + "null" + ], + "format": "int64" + }, + "timeout_ms": { + "type": [ + "integer", + "null" + ], + "format": "uint32", + "minimum": 0.0 + } + }, + "definitions": { + "UploadFile": { + "type": "object", + "required": [ + "name", + "transfer_id" + ], + "properties": { + "name": { + "type": "string" + }, + "staged_path": { + "type": [ + "string", + "null" + ] + }, + "transfer_id": { + "type": "string" + } + } + } + } +} diff --git a/crates/bsk-protocol/schema/tool_upload_result.json b/crates/bsk-protocol/schema/tool_upload_result.json new file mode 100644 index 0000000..192c10d --- /dev/null +++ b/crates/bsk-protocol/schema/tool_upload_result.json @@ -0,0 +1,33 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "UploadResult", + "type": "object", + "required": [ + "file_names", + "tab_id" + ], + "properties": { + "file_names": { + "type": "array", + "items": { + "type": "string" + } + }, + "tab_id": { + "type": "integer", + "format": "int64" + }, + "used_ref": { + "type": [ + "string", + "null" + ] + }, + "used_selector": { + "type": [ + "string", + "null" + ] + } + } +} diff --git a/crates/bsk-protocol/src/bin/dump-schema.rs b/crates/bsk-protocol/src/bin/dump-schema.rs index e1f4e6d..6599dce 100644 --- a/crates/bsk-protocol/src/bin/dump-schema.rs +++ b/crates/bsk-protocol/src/bin/dump-schema.rs @@ -85,6 +85,10 @@ fn main() { dump!(PressResult, "tool_press_result"); dump!(SelectParams, "tool_select_params"); dump!(SelectResult, "tool_select_result"); + dump!(UploadParams, "tool_upload_params"); + dump!(UploadResult, "tool_upload_result"); + dump!(DownloadParams, "tool_download_params"); + dump!(DownloadResult, "tool_download_result"); dump!(SnapshotParams, "tool_snapshot_params"); dump!(SnapshotResult, "tool_snapshot_result"); diff --git a/crates/bsk-protocol/src/method.rs b/crates/bsk-protocol/src/method.rs index 75f8b4d..740498c 100644 --- a/crates/bsk-protocol/src/method.rs +++ b/crates/bsk-protocol/src/method.rs @@ -78,6 +78,10 @@ pub enum Method { ToolPress, #[serde(rename = "tool.select")] ToolSelect, + #[serde(rename = "tool.upload")] + ToolUpload, + #[serde(rename = "tool.download")] + ToolDownload, #[serde(rename = "tool.snapshot")] ToolSnapshot, #[serde(rename = "tool.observe")] @@ -105,6 +109,17 @@ pub enum Method { #[serde(rename = "tool.record_await")] ToolRecordAwait, + #[serde(rename = "transfer.begin")] + TransferBegin, + #[serde(rename = "transfer.chunk")] + TransferChunk, + #[serde(rename = "transfer.finish")] + TransferFinish, + #[serde(rename = "transfer.read")] + TransferRead, + #[serde(rename = "transfer.release")] + TransferRelease, + #[serde(rename = "cancel")] Cancel, } @@ -156,6 +171,8 @@ impl Method { | Method::ToolFill | Method::ToolPress | Method::ToolSelect + | Method::ToolUpload + | Method::ToolDownload | Method::ToolEvaluate // May navigate via optional `url` and changes Agent Window // chrome; gate behind pending-interrupt like other writes. @@ -194,6 +211,11 @@ impl Method { | Method::SystemPing | Method::SystemStatus | Method::BrowserList + | Method::TransferBegin + | Method::TransferChunk + | Method::TransferFinish + | Method::TransferRead + | Method::TransferRelease | Method::Cancel => MethodEffect::ControlPlane, } } diff --git a/crates/bsk-protocol/src/tools/file_transfer.rs b/crates/bsk-protocol/src/tools/file_transfer.rs new file mode 100644 index 0000000..2c2f5b8 --- /dev/null +++ b/crates/bsk-protocol/src/tools/file_transfer.rs @@ -0,0 +1,170 @@ +//! Content-transparent file transfer contracts. +//! +//! Public tool calls carry opaque transfer ids. Filesystem paths are injected +//! by the daemon only on the daemon -> extension hop and are never accepted as +//! an agent-facing source or destination. + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct UploadFile { + pub transfer_id: String, + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staged_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct UploadParams { + pub session_id: String, + #[serde( + rename = "ref", + alias = "ref_", + default, + skip_serializing_if = "Option::is_none" + )] + pub ref_: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + pub files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct UploadResult { + pub tab_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_selector: Option, + pub file_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DownloadParams { + pub session_id: String, + #[serde( + rename = "ref", + alias = "ref_", + default, + skip_serializing_if = "Option::is_none" + )] + pub ref_: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selector: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, + /// Daemon-injected destination directory for the browser process. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staging_path: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct DownloadResult { + pub tab_id: i64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub used_selector: Option, + pub suggested_filename: String, + pub byte_size: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mime: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub danger: Option, + /// Extension-internal completed path, stripped by the daemon. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub staged_path: Option, + /// Opaque id returned by the daemon to the CLI. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub transfer_id: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferBeginParams { + pub session_id: String, + pub name: String, + pub byte_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferBeginResult { + pub transfer_id: String, + pub chunk_size: u32, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferChunkParams { + pub transfer_id: String, + pub offset: u64, + pub data_base64: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferChunkResult { + pub next_offset: u64, + #[serde(default)] + pub eof: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub data_base64: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferIdParams { + pub transfer_id: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferReadyResult { + pub transfer_id: String, + pub byte_size: u64, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] +pub struct TransferReleaseResult { + pub released: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn upload_agent_shape_omits_daemon_path_and_uses_ref_wire_name() { + let value = serde_json::to_value(UploadParams { + session_id: "s1".into(), + ref_: Some("@e3".into()), + selector: None, + tab_id: None, + files: vec![UploadFile { + transfer_id: "tr_1".into(), + name: "image.png".into(), + staged_path: None, + }], + timeout_ms: None, + }) + .unwrap(); + assert_eq!(value["ref"], "@e3"); + assert!(value["files"][0].get("staged_path").is_none()); + } + + #[test] + fn download_agent_shape_omits_daemon_path() { + let value = serde_json::to_value(DownloadParams { + session_id: "s1".into(), + ref_: None, + selector: Some("#export".into()), + tab_id: None, + timeout_ms: None, + staging_path: None, + }) + .unwrap(); + assert!(value.get("staging_path").is_none()); + } +} diff --git a/crates/bsk-protocol/src/tools/mod.rs b/crates/bsk-protocol/src/tools/mod.rs index 88347b3..9e841bb 100644 --- a/crates/bsk-protocol/src/tools/mod.rs +++ b/crates/bsk-protocol/src/tools/mod.rs @@ -3,6 +3,7 @@ pub mod console; pub mod dialog; pub mod emulate; +pub mod file_transfer; pub mod human_loop; pub mod interaction; pub mod navigation; @@ -21,6 +22,7 @@ pub mod window; pub use console::*; pub use dialog::*; pub use emulate::*; +pub use file_transfer::*; pub use human_loop::*; pub use interaction::*; pub use navigation::*; diff --git a/docs/architecture.md b/docs/architecture.md index 2e02c57..23db98e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -143,6 +143,14 @@ flowchart LR - No credential storage in bsk — cookies stay in the user's browser profile. - `evaluate` restricted to Agent Window tabs in sandbox mode. +### File-transfer boundary + +- The invoking agent/harness decides whether a transfer is authorized and supplies the task-local source or destination path. +- The CLI is the only component that reads an upload source or writes the final download destination. The extension never receives either agent-facing path. +- The daemon issues opaque, session-scoped transfer IDs and stages bounded chunks in a private runtime directory. It injects only its own staging paths into extension RPCs and validates that a completed download remains inside its minted directory. +- The extension only performs the browser-side mechanism: intercept one chooser or capture one download, scoped to the requested session and Agent Window tab. It does not classify content or make policy decisions. +- Download staging is released after the CLI commits the file. Upload staging remains until session teardown because the page may read an attached file only on a later form submission. All staging is released on session stop/browser disconnect and on daemon startup after a crash. Existing download destinations are not overwritten unless the CLI caller explicitly opts in. + ## Repository layout ``` diff --git a/skill/SKILL.md b/skill/SKILL.md index 42bb452..1b7eccf 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -206,6 +206,19 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff | `bsk select --value ` | Set `