feat(bsk): support upload and download

This commit is contained in:
Ljy-0827
2026-08-31 17:58:33 +08:00
parent f06a514bd2
commit 406bb21b7e
33 changed files with 2038 additions and 9 deletions
Generated
+1
View File
@@ -137,6 +137,7 @@ name = "bsk"
version = "0.1.11"
dependencies = [
"anyhow",
"base64",
"bsk-protocol",
"clap",
"console",
+1
View File
@@ -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"
+6 -3
View File
@@ -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 `<all_urls>`** — 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
@@ -0,0 +1,144 @@
import { describe, expect, it, vi } from "vitest";
import { SessionManager } from "@/session-manager/manager";
import { type DownloadsApi, handleDownload } from "../download";
import type { CdpRunner } from "../shared";
import { handleUpload } from "../upload";
function sessions() {
return new SessionManager({
agentWindow: {
create: vi.fn(async () => 100),
remove: vi.fn(async () => {}),
ensureActiveTab: vi.fn(async () => {}),
},
});
}
function tabsApi() {
return {
get: vi.fn(
async (tabId: number) => ({ id: tabId, windowId: 100, active: true }) as chrome.tabs.Tab,
),
query: vi.fn(async () => [{ id: 4, windowId: 100, active: true } as chrome.tabs.Tab]),
};
}
function fakeEvent<T extends (...args: never[]) => void>() {
const listeners = new Set<T>();
return {
addListener: (listener: T) => listeners.add(listener),
removeListener: (listener: T) => listeners.delete(listener),
hasListener: (listener: T) => listeners.has(listener),
hasListeners: () => listeners.size > 0,
emit: (...args: Parameters<T>) => {
for (const listener of listeners) listener(...args);
},
};
}
describe("file transfer tools", () => {
it("arms chooser interception before clicking and injects only staged paths", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
let eventHandler: Parameters<NonNullable<CdpRunner["onEvent"]>>[0] | undefined;
const calls: Array<{ method: string; params?: object }> = [];
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
calls.push({ method, params });
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
eventHandler?.({ tabId: 4 }, "Page.fileChooserOpened", {
backendNodeId: 456,
mode: "selectMultiple",
});
}
return {};
});
const cdp: CdpRunner = {
onEvent: (handler) => {
eventHandler = handler;
return { dispose: vi.fn() };
},
send: send as unknown as CdpRunner["send"],
};
const result = await handleUpload(
manager,
{
session_id: "s1",
ref: "@e3",
files: [
{ transfer_id: "tr_1", name: "one.png", staged_path: "/private/stage/one" },
{ transfer_id: "tr_2", name: "two.png", staged_path: "/private/stage/two" },
],
},
{ cdp, tabsApi: tabsApi() },
);
expect(result).toMatchObject({ tab_id: 4, file_names: ["one.png", "two.png"] });
expect(calls[0]).toMatchObject({
method: "Page.setInterceptFileChooserDialog",
params: { enabled: true },
});
expect(calls).toContainEqual({
method: "DOM.setFileInputFiles",
params: { files: ["/private/stage/one", "/private/stage/two"], backendNodeId: 456 },
});
});
it("captures the one download created under daemon staging", async () => {
const manager = sessions();
const ctx = await manager.start("s1");
ctx.refStore.set("e3", 123, { tabId: 4 });
const onCreated = fakeEvent<(item: chrome.downloads.DownloadItem) => void>();
const onChanged = fakeEvent<(delta: chrome.downloads.DownloadDelta) => void>();
const item = {
id: 9,
filename: "/private/stage/tr_1/result.zip",
state: "complete",
fileSize: 12,
totalBytes: 12,
mime: "application/zip",
danger: "safe",
} as chrome.downloads.DownloadItem;
const downloads: DownloadsApi = {
onCreated: onCreated as unknown as DownloadsApi["onCreated"],
onChanged: onChanged as unknown as DownloadsApi["onChanged"],
search: vi.fn(async () => [item]),
cancel: vi.fn(async () => {}),
};
const send = vi.fn(async (_tabId: number, method: string, params?: object) => {
if (method === "Page.getLayoutMetrics")
return { cssLayoutViewport: { clientWidth: 1280, clientHeight: 720 } };
if (method === "DOM.getContentQuads") return { quads: [[0, 0, 20, 0, 20, 20, 0, 20]] };
if (
method === "Input.dispatchMouseEvent" &&
(params as { type?: string }).type === "mousePressed"
) {
onCreated.emit(item);
}
return {};
});
const cdp: CdpRunner = {
send: send as unknown as CdpRunner["send"],
};
const result = await handleDownload(
manager,
{ session_id: "s1", ref: "@e3", staging_path: "/private/stage/tr_1" },
{ cdp, tabsApi: tabsApi(), downloads },
);
expect(result).toMatchObject({
tab_id: 4,
suggested_filename: "result.zip",
byte_size: 12,
staged_path: item.filename,
});
});
});
+38
View File
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
import type {
ClickParams,
ConsoleParams,
DownloadParams,
EmulateParams,
EvaluateParams,
FillParams,
@@ -28,10 +29,12 @@ import type {
ScreenshotParams,
SelectParams,
SnapshotParams,
UploadParams,
WaitForNavigationParams,
} from "@/transport/types";
import { isRequestFrame } from "@/transport/types";
import { handleConsole } from "./console";
import { handleDownload } from "./download";
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
import { handleEvaluate } from "./evaluate";
import { handleRequestHelp } from "./human-loop";
@@ -79,6 +82,7 @@ import {
type TabReturnParams,
type TabSelectParams,
} from "./tabs";
import { handleUpload } from "./upload";
import { handleWaitForNavigation } from "./waits";
import { handleWindowResize, type WindowResizeParams } from "./window";
@@ -513,6 +517,38 @@ export class ToolDispatcher {
),
signal,
);
case "tool.upload":
return this.withHoverReleaseForRequest(
req.params as UploadParams,
() =>
this.cdp
? handleUpload(this.sessions, req.params as UploadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
})
: Promise.resolve({
code: "unsupported",
message: "upload requires CDP",
} satisfies RpcError),
signal,
);
case "tool.download":
return this.withHoverReleaseForRequest(
req.params as DownloadParams,
() =>
this.cdp
? handleDownload(this.sessions, req.params as DownloadParams, {
cdp: this.cdp,
tabsApi: chromeTabsApi,
signal,
})
: Promise.resolve({
code: "unsupported",
message: "download requires CDP",
} satisfies RpcError),
signal,
);
case "tool.evaluate":
return handleEvaluate(
this.sessions,
@@ -719,6 +755,8 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
case "tool.fill":
case "tool.press":
case "tool.select":
case "tool.upload":
case "tool.download":
case "tool.evaluate":
case "tool.observe":
case "tool.request_help":
+138
View File
@@ -0,0 +1,138 @@
// One-click/one-file browser download capture. chrome.downloads events are
// browser-global, so a coordinator prevents two bsk sessions from racing.
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, DownloadParams, DownloadResult, RpcError } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import { enforceAgentWindow, isRpcError, lookupSession, resolveTargetTab } from "./shared";
export interface DownloadsApi {
onCreated: chrome.events.Event<(item: chrome.downloads.DownloadItem) => void>;
onChanged: chrome.events.Event<(delta: chrome.downloads.DownloadDelta) => void>;
search(query: chrome.downloads.DownloadQuery): Promise<chrome.downloads.DownloadItem[]>;
cancel(downloadId: number): Promise<void>;
}
const chromeDownloadsApi: DownloadsApi = {
get onCreated() {
return chrome.downloads.onCreated;
},
get onChanged() {
return chrome.downloads.onChanged;
},
search: (query) => chrome.downloads.search(query),
cancel: (id) => chrome.downloads.cancel(id),
};
let downloadActive = false;
export interface DownloadDeps extends InteractionDeps {
downloads?: DownloadsApi;
}
export async function handleDownload(
manager: SessionManager,
params: DownloadParams,
deps: DownloadDeps,
): Promise<DownloadResult | RpcError> {
if (downloadActive) return { code: "invalid_params", message: "another bsk download is active" };
downloadActive = true;
const downloads = deps.downloads ?? chromeDownloadsApi;
let capturedId: number | undefined;
let createdListener: ((item: chrome.downloads.DownloadItem) => void) | undefined;
let changedListener: ((delta: chrome.downloads.DownloadDelta) => void) | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const ctx = lookupSession(manager, params, "download");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "download");
if (denied) return denied;
if (!params.staging_path)
return { code: "invalid_params", message: "download requires daemon staging" };
const normalisePath = (path: string) => path.replaceAll("\\", "/");
const normalisedStaging = normalisePath(params.staging_path);
const stagingPrefix = normalisedStaging.endsWith("/")
? normalisedStaging
: `${normalisedStaging}/`;
let rejectCompleted!: (error: Error) => void;
const completed = new Promise<chrome.downloads.DownloadItem>((resolve, reject) => {
rejectCompleted = reject;
createdListener = (item) => {
if (!normalisePath(item.filename).startsWith(stagingPrefix)) return;
if (capturedId !== undefined && capturedId !== item.id) {
reject(new Error("download trigger produced more than one file"));
return;
}
capturedId = item.id;
if (item.state === "complete") resolve(item);
};
changedListener = async (delta) => {
if (capturedId === undefined || delta.id !== capturedId) return;
if (delta.state?.current === "interrupted" || delta.error?.current) {
reject(new Error(delta.error?.current ?? "download interrupted"));
return;
}
if (delta.state?.current === "complete") {
const [item] = await downloads.search({ id: delta.id });
if (item) resolve(item);
else reject(new Error("completed download disappeared"));
}
};
downloads.onCreated.addListener(createdListener);
downloads.onChanged.addListener(changedListener);
timer = setTimeout(
() => reject(new Error("download did not complete before timeout")),
params.timeout_ms ?? 120_000,
);
});
const onAbort = () => {
if (capturedId !== undefined) void downloads.cancel(capturedId).catch(() => undefined);
rejectCompleted(new DOMException("aborted", "AbortError"));
};
deps.signal?.addEventListener("abort", onAbort, { once: true });
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", {
behavior: "allow",
downloadPath: params.staging_path,
});
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
const clicked = await handleClick(manager, clickParams, deps);
if (isRpcError(clicked)) {
void completed.catch(() => undefined);
return clicked;
}
const item = await completed;
return {
tab_id: target.tabId,
used_ref: clicked.used_ref,
used_selector: clicked.used_selector,
suggested_filename: item.filename.split(/[\\/]/).pop() ?? "download",
byte_size: item.fileSize >= 0 ? item.fileSize : item.totalBytes,
mime: item.mime || undefined,
danger: item.danger,
staged_path: item.filename,
};
} finally {
deps.signal?.removeEventListener("abort", onAbort);
try {
await deps.cdp.send(target.tabId, "Page.setDownloadBehavior", { behavior: "default" });
} catch {}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
} finally {
if (timer) clearTimeout(timer);
if (createdListener) downloads.onCreated.removeListener(createdListener);
if (changedListener) downloads.onChanged.removeListener(changedListener);
downloadActive = false;
}
}
+148
View File
@@ -0,0 +1,148 @@
// Atomic file-chooser upload: arm interception before clicking, then attach
// daemon-staged files to the chooser node. No local path is accepted from an
// agent-facing call; staged_path is injected by the daemon.
import type { CdpTarget } from "@/browser-driver/frame-graph";
import type { SessionManager } from "@/session-manager/manager";
import type { ClickParams, RpcError, UploadParams, UploadResult } from "@/transport/types";
import { handleClick, type InteractionDeps } from "./interaction";
import {
type CdpRunner,
enforceAgentWindow,
isRpcError,
lookupSession,
resolveTargetTab,
sendToCdpTarget,
} from "./shared";
import { resolveSnapshotRef } from "./snapshot-ref";
const DEFAULT_TIMEOUT_MS = 120_000;
export interface UploadDeps extends InteractionDeps {
cdp: CdpRunner;
}
function chooserTarget(
params: UploadParams,
tabId: number,
manager: SessionManager,
): CdpTarget | RpcError {
const ctx = manager.get(params.session_id);
if (!ctx) return { code: "not_found", message: `session ${params.session_id} unknown` };
if (params.ref) {
const ref = resolveSnapshotRef(ctx, params.ref, tabId);
if (isRpcError(ref)) return ref;
return { tabId, ...(ref.cdpSessionId ? { sessionId: ref.cdpSessionId } : {}) };
}
return { tabId };
}
export async function handleUpload(
manager: SessionManager,
params: UploadParams,
deps: UploadDeps,
): Promise<UploadResult | RpcError> {
const ctx = lookupSession(manager, params, "upload");
if (isRpcError(ctx)) return ctx;
const target = await resolveTargetTab(manager, ctx, params.tab_id, deps.tabsApi);
if (isRpcError(target)) return target;
const denied = enforceAgentWindow(ctx, target, "upload");
if (denied) return denied;
if (!deps.cdp?.onEvent)
return { code: "unsupported", message: "CDP event subscription unavailable" };
if (
params.files.length === 0 ||
params.files.length > 20 ||
params.files.some((file) => !file.staged_path)
) {
return { code: "invalid_params", message: "upload requires daemon-staged files" };
}
const cdpTarget = chooserTarget(params, target.tabId, manager);
if (isRpcError(cdpTarget)) return cdpTarget;
const timeoutMs = params.timeout_ms ?? DEFAULT_TIMEOUT_MS;
let timer: ReturnType<typeof setTimeout> | undefined;
let subscription: { dispose(): void } | undefined;
let resolveChooser!: (value: {
source: { tabId?: number; sessionId?: string };
backendNodeId: number;
mode?: string;
}) => void;
let rejectChooser!: (error: Error) => void;
const chooser = new Promise<{
source: { tabId?: number; sessionId?: string };
backendNodeId: number;
mode?: string;
}>((resolve, reject) => {
resolveChooser = resolve;
rejectChooser = reject;
});
subscription = deps.cdp.onEvent((source, method, raw) => {
if (method !== "Page.fileChooserOpened" || source.tabId !== target.tabId) return;
if (cdpTarget.sessionId && source.sessionId !== cdpTarget.sessionId) return;
const event = raw as { backendNodeId?: unknown; mode?: unknown };
if (typeof event.backendNodeId !== "number") {
rejectChooser(new Error("file chooser did not expose an input backendNodeId"));
return;
}
resolveChooser({
source,
backendNodeId: event.backendNodeId,
...(typeof event.mode === "string" ? { mode: event.mode } : {}),
});
});
timer = setTimeout(
() => rejectChooser(new Error("file chooser did not open before timeout")),
timeoutMs,
);
const onAbort = () => rejectChooser(new DOMException("aborted", "AbortError"));
deps.signal?.addEventListener("abort", onAbort, { once: true });
try {
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
enabled: true,
});
const clickParams: ClickParams = {
session_id: params.session_id,
ref: params.ref,
selector: params.selector,
tab_id: params.tab_id,
timeout_ms: params.timeout_ms,
};
const clicked = await handleClick(manager, clickParams, deps);
if (isRpcError(clicked)) {
void chooser.catch(() => undefined);
return clicked;
}
const opened = await chooser;
if (opened.mode === "selectSingle" && params.files.length !== 1) {
return { code: "invalid_params", message: "file chooser accepts exactly one file" };
}
const sourceTarget: CdpTarget = {
tabId: target.tabId,
...(opened.source.sessionId ? { sessionId: opened.source.sessionId } : {}),
};
await sendToCdpTarget(deps.cdp, sourceTarget, "DOM.setFileInputFiles", {
files: params.files.map((file) => file.staged_path as string),
backendNodeId: opened.backendNodeId,
});
return {
tab_id: target.tabId,
used_ref: clicked.used_ref,
used_selector: clicked.used_selector,
file_names: params.files.map((file) => file.name),
};
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") throw err;
return { code: "cdp_failed", message: err instanceof Error ? err.message : String(err) };
} finally {
if (timer) clearTimeout(timer);
subscription?.dispose();
deps.signal?.removeEventListener("abort", onAbort);
try {
await sendToCdpTarget(deps.cdp, cdpTarget, "Page.setInterceptFileChooserDialog", {
enabled: false,
});
} catch {
// Best-effort compensation; the target may have navigated/closed.
}
}
}
+43
View File
@@ -500,6 +500,49 @@ export interface SelectResult {
dialogs?: JavaScriptDialogInfo[];
}
export interface UploadFile {
transfer_id: string;
name: string;
staged_path?: string;
}
export interface UploadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
files: UploadFile[];
timeout_ms?: number;
}
export interface UploadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
file_names: string[];
}
export interface DownloadParams {
session_id: string;
ref?: string;
selector?: string;
tab_id?: number;
timeout_ms?: number;
staging_path?: string;
}
export interface DownloadResult {
tab_id: number;
used_ref?: string;
used_selector?: string;
suggested_filename: string;
byte_size: number;
mime?: string;
danger?: string;
staged_path?: string;
transfer_id?: string;
}
// --------------------------------------------------------------------------
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
// --------------------------------------------------------------------------
+1
View File
@@ -30,6 +30,7 @@ export default defineConfig({
permissions: [
"alarms",
"debugger",
"downloads",
"idle",
"notifications",
"tabs",
+1
View File
@@ -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 }
+13
View File
@@ -206,6 +206,19 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### File transfer (require `--session`)
| Command | Summary |
|---------|---------|
| `bsk upload <ref-or-selector> --file <path>` | Click one file chooser and attach an agent-readable local file (`--file` is repeatable) |
| `bsk download <ref-or-selector> --out <path>` | Click one download trigger and copy the single completed file to an exact local path (`--overwrite` is opt-in) |
The agent/harness decides whether a file transfer is appropriate and which local path belongs to the task. Treat upload as disclosure of that file to the current website, and download as accepting website-controlled bytes onto the local filesystem. Use only paths that are necessary for the user's bounded goal.
BrowserSkill enforces the mechanical boundary: files are staged under a session-scoped opaque transfer, only daemon-minted paths reach the extension, upload/download still obey Agent Window tab checks, and transfers are chunk/size bounded. Download staging is removed after the CLI copies it; upload staging remains available for a later form submission and is removed when the session ends. Downloads cannot overwrite an existing destination unless `--overwrite` is explicit. BrowserSkill does not inspect file content or decide whether its meaning is sensitive.
Do not use `request-help` merely because a native file chooser or browser download is involved; use these commands. Ask for help only when the intended upload file is unavailable/unreadable to the agent, the page requires genuinely human-only input, or the browser reports the transfer mechanism as unsupported.
### Scripting & timing
| Command | Summary |
+159
View File
@@ -0,0 +1,159 @@
//! `bsk download` — capture one browser download and atomically commit it.
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::Context;
use base64::Engine;
use bsk_protocol::Method;
use bsk_protocol::tools::{
DownloadParams, DownloadResult, TransferChunkParams, TransferChunkResult, TransferIdParams,
TransferReleaseResult,
};
use clap::Args;
use uuid::Uuid;
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 DownloadArgs {
/// Snapshot ref (`@e3`) or CSS selector for the download trigger.
pub target: Option<String>,
#[arg(long = "ref")]
pub ref_: Option<String>,
#[arg(long = "selector")]
pub selector: Option<String>,
#[arg(long)]
pub out: PathBuf,
#[arg(long)]
pub session: String,
#[arg(long = "tab-id")]
pub tab_id: Option<i64>,
#[arg(long, default_value = "2m", value_parser = parse_timeout_ms)]
pub timeout: u32,
#[arg(long)]
pub overwrite: bool,
}
pub fn dispatch(args: DownloadArgs, format: Format) -> Result<(), CliError> {
if args.out.exists() && !args.overwrite {
return Err(CliError::Local(anyhow::anyhow!(
"output already exists (pass --overwrite to replace it): {}",
args.out.display()
)));
}
let info = ensure_daemon().context("ensure daemon is running")?;
let (ref_, selector) = split_target(args.target, args.ref_, args.selector)?;
let params = DownloadParams {
session_id: args.session,
ref_,
selector,
tab_id: args.tab_id,
timeout_ms: Some(args.timeout),
staging_path: None,
};
let reply: DownloadResult = crate::cli::business_rpc::call(
info.sock_path.clone(),
"download",
Method::ToolDownload,
Some(params),
ipc_timeout(args.timeout),
)?;
let transfer_id = reply.transfer_id.clone().ok_or_else(|| {
CliError::Local(anyhow::anyhow!("daemon returned no download transfer id"))
})?;
let write_result = write_transfer(&info.sock_path, &transfer_id, &args.out, args.overwrite);
let _: Result<TransferReleaseResult, CliError> = crate::cli::business_rpc::call(
info.sock_path,
"transfer-release",
Method::TransferRelease,
Some(TransferIdParams { transfer_id }),
Duration::from_secs(5),
);
write_result?;
match format {
Format::Json => {
let mut value = serde_json::to_value(&reply).unwrap();
value["path"] = serde_json::json!(args.out.to_string_lossy());
if let Some(obj) = value.as_object_mut() {
obj.remove("transfer_id");
obj.remove("staged_path");
}
println!("{}", serde_json::to_string_pretty(&value).unwrap());
}
Format::Human => println!("{}", args.out.display()),
}
Ok(())
}
fn write_transfer(sock: &PathBuf, id: &str, out: &Path, overwrite: bool) -> Result<(), CliError> {
let parent = out
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or(Path::new("."));
let temp = parent.join(format!(".bsk-download-{}.part", Uuid::new_v4().simple()));
let result = (|| {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&temp)
.with_context(|| format!("create temporary download {}", temp.display()))
.map_err(CliError::Local)?;
let mut offset = 0u64;
loop {
let chunk: TransferChunkResult = crate::cli::business_rpc::call(
sock.clone(),
"transfer-read",
Method::TransferRead,
Some(TransferChunkParams {
transfer_id: id.to_string(),
offset,
data_base64: String::new(),
}),
Duration::from_secs(30),
)?;
let encoded = chunk.data_base64.as_deref().unwrap_or("");
let bytes = base64::engine::general_purpose::STANDARD
.decode(encoded)
.map_err(|e| CliError::Local(anyhow::anyhow!("decode download chunk: {e}")))?;
file.write_all(&bytes)
.map_err(|e| CliError::Local(e.into()))?;
offset = chunk.next_offset;
if chunk.eof {
break;
}
}
file.sync_all().map_err(|e| CliError::Local(e.into()))?;
drop(file);
if !overwrite {
std::fs::hard_link(&temp, out)
.with_context(|| format!("commit download without overwriting {}", out.display()))
.map_err(CliError::Local)?;
std::fs::remove_file(&temp)
.with_context(|| format!("remove temporary download {}", temp.display()))
.map_err(CliError::Local)?;
return Ok(());
}
if out.exists() {
std::fs::remove_file(out)
.with_context(|| format!("replace existing output {}", out.display()))
.map_err(CliError::Local)?;
}
std::fs::rename(&temp, out)
.with_context(|| format!("commit download to {}", out.display()))
.map_err(CliError::Local)
})();
if result.is_err() {
let _ = std::fs::remove_file(&temp);
}
result
}
fn ipc_timeout(timeout_ms: u32) -> Duration {
Duration::from_millis(u64::from(timeout_ms) + 5_000)
}
+1 -1
View File
@@ -71,7 +71,7 @@ pub(crate) fn parse_modifiers(input: &str) -> Result<Vec<KeyModifier>, String> {
Ok(out)
}
fn split_target(
pub(crate) fn split_target(
positional: Option<String>,
explicit_ref: Option<String>,
explicit_selector: Option<String>,
+10
View File
@@ -9,6 +9,7 @@ pub mod console;
pub mod daemon;
pub mod dialogs;
pub mod doctor;
pub mod download;
pub mod emulate;
pub mod ensure_daemon;
pub mod error;
@@ -31,6 +32,7 @@ pub mod snapshot;
pub mod status;
pub mod tab;
pub mod update;
pub mod upload;
pub mod waits;
pub mod window;
@@ -38,6 +40,7 @@ use clap::{Args, Parser, Subcommand};
use crate::cli::console::ConsoleArgs;
use crate::cli::daemon::DaemonCmd;
use crate::cli::download::DownloadArgs;
use crate::cli::emulate::EmulateArgs;
use crate::cli::evaluate::EvaluateArgs;
use crate::cli::get_html::GetHtmlArgs;
@@ -53,6 +56,7 @@ use crate::cli::session::SessionCmd;
use crate::cli::snapshot::SnapshotArgs;
use crate::cli::tab::TabCmd;
use crate::cli::update::UpdateArgs;
use crate::cli::upload::UploadArgs;
use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs};
use crate::cli::window::WindowCmd;
@@ -177,6 +181,12 @@ pub enum Command {
/// Set `<select>` 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),
+180
View File
@@ -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<String>,
#[arg(long = "ref")]
pub ref_: Option<String>,
#[arg(long = "selector")]
pub selector: Option<String>,
/// Local file to upload. Repeat for a multiple-file input.
#[arg(long = "file", required = true)]
pub files: Vec<PathBuf>,
#[arg(long)]
pub session: String,
#[arg(long = "tab-id")]
pub tab_id: Option<i64>,
#[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<TransferReleaseResult, CliError> {
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)
}
+493
View File
@@ -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<u64>,
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<bool>,
entries: Mutex<HashMap<String, Entry>>,
}
impl TransferRegistry {
pub fn new() -> anyhow::Result<Self> {
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<Self> {
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<TransferBeginResult, RpcError> {
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<TransferChunkResult, RpcError> {
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<TransferReadyResult, RpcError> {
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<Vec<PathBuf>, 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<DownloadStaging, RpcError> {
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<u64, RpcError> {
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<TransferChunkResult, RpcError> {
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<String> = 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<String>) -> RpcError {
RpcError {
code: ErrorCode::InvalidParams,
message: message.into(),
data: None,
}
}
fn not_found(message: impl Into<String>) -> RpcError {
RpcError {
code: ErrorCode::NotFound,
message: message.into(),
data: None,
}
}
fn permission(message: impl Into<String>) -> 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<String>) -> 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()
);
}
}
+169 -4
View File
@@ -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<DaemonState>) -> 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<DaemonState>) -> 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<String> = 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<String> = 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<DaemonState>, 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<DaemonState>, 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<DaemonState>, 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<DaemonState>, 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<DaemonState>, 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<String>) -> 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);
+1
View File
@@ -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;
+10 -1
View File
@@ -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<DaemonState>) -> 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<DaemonState>) -> 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");
+6
View File
@@ -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<SessionInterruptRegistry>,
/// Operation-scoped local file staging. The extension only sees paths
/// minted here; agent-facing RPCs use opaque transfer ids.
pub transfers: Arc<TransferRegistry>,
}
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,
}
}
}
+3
View File
@@ -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::<Frame>();
@@ -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");
+2
View File
@@ -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),
+39
View File
@@ -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(&[
@@ -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
}
}
}
@@ -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"
]
}
}
}
@@ -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"
}
}
}
}
}
@@ -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"
]
}
}
}
@@ -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");
+22
View File
@@ -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,
}
}
@@ -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<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
pub files: Vec<UploadFile>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
pub file_names: Vec<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub selector: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tab_id: Option<i64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timeout_ms: Option<u32>,
/// Daemon-injected destination directory for the browser process.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staging_path: Option<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub used_selector: Option<String>,
pub suggested_filename: String,
pub byte_size: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mime: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub danger: Option<String>,
/// Extension-internal completed path, stripped by the daemon.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub staged_path: Option<String>,
/// Opaque id returned by the daemon to the CLI.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub transfer_id: Option<String>,
}
#[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<String>,
}
#[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());
}
}
+2
View File
@@ -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::*;
+8
View File
@@ -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
```
+13
View File
@@ -206,6 +206,19 @@ Both capture from the moment the tab is attached and read a bounded per-tab buff
| `bsk select <ref-or-selector> --value <v>` | Set `<select>` option(s) by `value` (repeat `--value` for multi-select) |
| `bsk press <key>` | Key/combo (`Enter`, `Ctrl+A`, …; optional `--ref` to focus first) |
### File transfer (require `--session`)
| Command | Summary |
|---------|---------|
| `bsk upload <ref-or-selector> --file <path>` | Click one file chooser and attach an agent-readable local file (`--file` is repeatable) |
| `bsk download <ref-or-selector> --out <path>` | Click one download trigger and copy the single completed file to an exact local path (`--overwrite` is opt-in) |
The agent/harness decides whether a file transfer is appropriate and which local path belongs to the task. Treat upload as disclosure of that file to the current website, and download as accepting website-controlled bytes onto the local filesystem. Use only paths that are necessary for the user's bounded goal.
BrowserSkill enforces the mechanical boundary: files are staged under a session-scoped opaque transfer, only daemon-minted paths reach the extension, upload/download still obey Agent Window tab checks, and transfers are chunk/size bounded. Download staging is removed after the CLI copies it; upload staging remains available for a later form submission and is removed when the session ends. Downloads cannot overwrite an existing destination unless `--overwrite` is explicit. BrowserSkill does not inspect file content or decide whether its meaning is sensitive.
Do not use `request-help` merely because a native file chooser or browser download is involved; use these commands. Ask for help only when the intended upload file is unavailable/unreadable to the agent, the page requires genuinely human-only input, or the browser reports the transfer mechanism as unsupported.
### Scripting & timing
| Command | Summary |