mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(bsk): support upload and download
This commit is contained in:
Generated
+1
@@ -137,6 +137,7 @@ name = "bsk"
|
|||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"base64",
|
||||||
"bsk-protocol",
|
"bsk-protocol",
|
||||||
"clap",
|
"clap",
|
||||||
"console",
|
"console",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ flate2 = "1.1.9"
|
|||||||
tar = "0.4.46"
|
tar = "0.4.46"
|
||||||
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
zip = { version = "7.2.0", default-features = false, features = ["deflate"] }
|
||||||
sha2 = "0.11.0"
|
sha2 = "0.11.0"
|
||||||
|
base64 = "0.22"
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
nix = { version = "0.29", features = ["signal", "process"] }
|
nix = { version = "0.29", features = ["signal", "process"] }
|
||||||
rand = "0.8"
|
rand = "0.8"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# BrowserSkill — Privacy Policy
|
# 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.
|
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
|
## 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
|
## 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. |
|
| **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. |
|
| **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. |
|
| **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. |
|
| **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
|
## 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.
|
- 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.
|
- 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.
|
- Use webcam, microphone, geolocation, or any device sensor.
|
||||||
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
|
- Include analytics, telemetry, crash reporting, advertising SDKs, or fingerprinting code.
|
||||||
- Track users across websites or across sessions.
|
- 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.
|
- **`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.
|
- **`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.
|
- **`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`.
|
- **`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.
|
- **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.
|
- 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.
|
- 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
|
## 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import type { Transport } from "@/transport/transport";
|
|||||||
import type {
|
import type {
|
||||||
ClickParams,
|
ClickParams,
|
||||||
ConsoleParams,
|
ConsoleParams,
|
||||||
|
DownloadParams,
|
||||||
EmulateParams,
|
EmulateParams,
|
||||||
EvaluateParams,
|
EvaluateParams,
|
||||||
FillParams,
|
FillParams,
|
||||||
@@ -28,10 +29,12 @@ import type {
|
|||||||
ScreenshotParams,
|
ScreenshotParams,
|
||||||
SelectParams,
|
SelectParams,
|
||||||
SnapshotParams,
|
SnapshotParams,
|
||||||
|
UploadParams,
|
||||||
WaitForNavigationParams,
|
WaitForNavigationParams,
|
||||||
} from "@/transport/types";
|
} from "@/transport/types";
|
||||||
import { isRequestFrame } from "@/transport/types";
|
import { isRequestFrame } from "@/transport/types";
|
||||||
import { handleConsole } from "./console";
|
import { handleConsole } from "./console";
|
||||||
|
import { handleDownload } from "./download";
|
||||||
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
import { type EmulateCdpRunner, handleEmulate } from "./emulate";
|
||||||
import { handleEvaluate } from "./evaluate";
|
import { handleEvaluate } from "./evaluate";
|
||||||
import { handleRequestHelp } from "./human-loop";
|
import { handleRequestHelp } from "./human-loop";
|
||||||
@@ -74,6 +77,7 @@ import {
|
|||||||
type TabReturnParams,
|
type TabReturnParams,
|
||||||
type TabSelectParams,
|
type TabSelectParams,
|
||||||
} from "./tabs";
|
} from "./tabs";
|
||||||
|
import { handleUpload } from "./upload";
|
||||||
import { handleWaitForNavigation } from "./waits";
|
import { handleWaitForNavigation } from "./waits";
|
||||||
import { handleWindowResize, type WindowResizeParams } from "./window";
|
import { handleWindowResize, type WindowResizeParams } from "./window";
|
||||||
|
|
||||||
@@ -504,6 +508,38 @@ export class ToolDispatcher {
|
|||||||
),
|
),
|
||||||
signal,
|
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":
|
case "tool.evaluate":
|
||||||
return handleEvaluate(
|
return handleEvaluate(
|
||||||
this.sessions,
|
this.sessions,
|
||||||
@@ -721,6 +757,8 @@ function sessionIdForBrowserControlMethod(req: RequestFrame): string | null {
|
|||||||
case "tool.fill":
|
case "tool.fill":
|
||||||
case "tool.press":
|
case "tool.press":
|
||||||
case "tool.select":
|
case "tool.select":
|
||||||
|
case "tool.upload":
|
||||||
|
case "tool.download":
|
||||||
case "tool.evaluate":
|
case "tool.evaluate":
|
||||||
case "tool.observe":
|
case "tool.observe":
|
||||||
case "tool.request_help":
|
case "tool.request_help":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -500,6 +500,49 @@ export interface SelectResult {
|
|||||||
dialogs?: JavaScriptDialogInfo[];
|
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
|
// M9 tool payloads — evaluate / wait_for_navigation / wait_ms
|
||||||
// --------------------------------------------------------------------------
|
// --------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export default defineConfig({
|
|||||||
permissions: [
|
permissions: [
|
||||||
"alarms",
|
"alarms",
|
||||||
"debugger",
|
"debugger",
|
||||||
|
"downloads",
|
||||||
"idle",
|
"idle",
|
||||||
"notifications",
|
"notifications",
|
||||||
"tabs",
|
"tabs",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ flate2 = { workspace = true }
|
|||||||
tar = { workspace = true }
|
tar = { workspace = true }
|
||||||
zip = { workspace = true }
|
zip = { workspace = true }
|
||||||
sha2 = { workspace = true }
|
sha2 = { workspace = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
|
|
||||||
[target.'cfg(unix)'.dependencies]
|
[target.'cfg(unix)'.dependencies]
|
||||||
nix = { workspace = true }
|
nix = { workspace = true }
|
||||||
|
|||||||
@@ -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 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) |
|
| `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
|
### Scripting & timing
|
||||||
|
|
||||||
| Command | Summary |
|
| Command | Summary |
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -71,7 +71,7 @@ pub(crate) fn parse_modifiers(input: &str) -> Result<Vec<KeyModifier>, String> {
|
|||||||
Ok(out)
|
Ok(out)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn split_target(
|
pub(crate) fn split_target(
|
||||||
positional: Option<String>,
|
positional: Option<String>,
|
||||||
explicit_ref: Option<String>,
|
explicit_ref: Option<String>,
|
||||||
explicit_selector: Option<String>,
|
explicit_selector: Option<String>,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod console;
|
|||||||
pub mod daemon;
|
pub mod daemon;
|
||||||
pub mod dialogs;
|
pub mod dialogs;
|
||||||
pub mod doctor;
|
pub mod doctor;
|
||||||
|
pub mod download;
|
||||||
pub mod emulate;
|
pub mod emulate;
|
||||||
pub mod ensure_daemon;
|
pub mod ensure_daemon;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
@@ -30,6 +31,7 @@ pub mod snapshot;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
pub mod tab;
|
pub mod tab;
|
||||||
pub mod update;
|
pub mod update;
|
||||||
|
pub mod upload;
|
||||||
pub mod waits;
|
pub mod waits;
|
||||||
pub mod window;
|
pub mod window;
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ use clap::{Args, Parser, Subcommand};
|
|||||||
|
|
||||||
use crate::cli::console::ConsoleArgs;
|
use crate::cli::console::ConsoleArgs;
|
||||||
use crate::cli::daemon::DaemonCmd;
|
use crate::cli::daemon::DaemonCmd;
|
||||||
|
use crate::cli::download::DownloadArgs;
|
||||||
use crate::cli::emulate::EmulateArgs;
|
use crate::cli::emulate::EmulateArgs;
|
||||||
use crate::cli::evaluate::EvaluateArgs;
|
use crate::cli::evaluate::EvaluateArgs;
|
||||||
use crate::cli::get_html::GetHtmlArgs;
|
use crate::cli::get_html::GetHtmlArgs;
|
||||||
@@ -52,6 +55,7 @@ use crate::cli::session::SessionCmd;
|
|||||||
use crate::cli::snapshot::SnapshotArgs;
|
use crate::cli::snapshot::SnapshotArgs;
|
||||||
use crate::cli::tab::TabCmd;
|
use crate::cli::tab::TabCmd;
|
||||||
use crate::cli::update::UpdateArgs;
|
use crate::cli::update::UpdateArgs;
|
||||||
|
use crate::cli::upload::UploadArgs;
|
||||||
use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs};
|
use crate::cli::waits::{WaitForNavigationArgs, WaitMsArgs};
|
||||||
use crate::cli::window::WindowCmd;
|
use crate::cli::window::WindowCmd;
|
||||||
|
|
||||||
@@ -176,6 +180,12 @@ pub enum Command {
|
|||||||
/// Set `<select>` option values by `value` attribute.
|
/// Set `<select>` option values by `value` attribute.
|
||||||
Select(SelectArgs),
|
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 a JavaScript expression inside the Agent Window.
|
||||||
Evaluate(EvaluateArgs),
|
Evaluate(EvaluateArgs),
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,7 +27,8 @@ use bsk_protocol::system::{
|
|||||||
VersionSkewEntry,
|
VersionSkewEntry,
|
||||||
};
|
};
|
||||||
use bsk_protocol::tools::{
|
use bsk_protocol::tools::{
|
||||||
HelpOutcome, RequestHelpResult, ReturnFailure, WaitMsParams, WaitMsResult,
|
DownloadParams, DownloadResult, HelpOutcome, RequestHelpResult, ReturnFailure,
|
||||||
|
TransferBeginParams, TransferIdParams, UploadParams, WaitMsParams, WaitMsResult,
|
||||||
};
|
};
|
||||||
use bsk_protocol::{
|
use bsk_protocol::{
|
||||||
CancelParams, CancelResult, ErrorCode, Method, PingResult, ResponseBody, RpcError, RpcId,
|
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),
|
Ok(v) => ResponseBody::Ok(v),
|
||||||
Err(e) => ResponseBody::Err(e),
|
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::ToolTabList
|
||||||
| Method::ToolTabCreate
|
| Method::ToolTabCreate
|
||||||
| Method::ToolTabClose
|
| Method::ToolTabClose
|
||||||
@@ -246,6 +252,8 @@ pub fn full_handler(status: DaemonStatus, state: Arc<DaemonState>) -> RpcHandler
|
|||||||
| Method::ToolFill
|
| Method::ToolFill
|
||||||
| Method::ToolPress
|
| Method::ToolPress
|
||||||
| Method::ToolSelect
|
| Method::ToolSelect
|
||||||
|
| Method::ToolUpload
|
||||||
|
| Method::ToolDownload
|
||||||
| Method::ToolEvaluate
|
| Method::ToolEvaluate
|
||||||
| Method::ToolWaitForNavigation
|
| Method::ToolWaitForNavigation
|
||||||
| Method::ToolRequestHelp
|
| 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();
|
let entry = inflight_guard.entry();
|
||||||
// `record_stop` must reach the extension while `record_await` holds the
|
// `record_stop` must reach the extension while `record_await` holds the
|
||||||
// serial busy lock — finishing the recording unblocks await.
|
// serial busy lock — finishing the recording unblocks await.
|
||||||
let outcome = if method == Method::ToolRecordStop {
|
let outcome = if method == Method::ToolRecordStop {
|
||||||
state
|
state
|
||||||
.tool_queues
|
.tool_queues
|
||||||
.dispatch_unlocked(&session_id, method, params, timeout, Some(entry))
|
.dispatch_unlocked(&session_id, method.clone(), params, timeout, Some(entry))
|
||||||
.await
|
.await
|
||||||
} else {
|
} else {
|
||||||
state
|
state
|
||||||
.tool_queues
|
.tool_queues
|
||||||
.dispatch(&session_id, method, params, timeout, Some(entry))
|
.dispatch(&session_id, method.clone(), params, timeout, Some(entry))
|
||||||
.await
|
.await
|
||||||
};
|
};
|
||||||
drop(inflight_guard);
|
drop(inflight_guard);
|
||||||
match outcome {
|
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),
|
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -757,6 +920,7 @@ async fn handle_session_stop(state: &Arc<DaemonState>, params: Value) -> Result<
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(stop) => {
|
Ok(stop) => {
|
||||||
|
state.transfers.release_session(&session_id.0);
|
||||||
let result = CliSessionStopResult {
|
let result = CliSessionStopResult {
|
||||||
stopped: vec![session_id.0],
|
stopped: vec![session_id.0],
|
||||||
failed: Vec::new(),
|
failed: Vec::new(),
|
||||||
@@ -827,6 +991,7 @@ async fn handle_session_stop_all(state: &Arc<DaemonState>) -> Result<Value, RpcE
|
|||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(stop) => {
|
Ok(stop) => {
|
||||||
|
state.transfers.release_session(&id.0);
|
||||||
stopped.push(id.0);
|
stopped.push(id.0);
|
||||||
returned_tab_ids.extend(stop.returned_tab_ids);
|
returned_tab_ids.extend(stop.returned_tab_ids);
|
||||||
return_failures.extend(stop.return_failures);
|
return_failures.extend(stop.return_failures);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod abort;
|
pub mod abort;
|
||||||
pub mod browsers;
|
pub mod browsers;
|
||||||
mod cancel_forward;
|
mod cancel_forward;
|
||||||
|
pub mod file_transfer;
|
||||||
pub mod inflight;
|
pub mod inflight;
|
||||||
pub mod info;
|
pub mod info;
|
||||||
pub mod ipc;
|
pub mod ipc;
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ pub fn run_foreground(cfg: DaemonConfig) -> Result<()> {
|
|||||||
.with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?;
|
.with_context(|| format!("bind IPC endpoint {}", sock_path.display()))?;
|
||||||
|
|
||||||
let state = Arc::new(DaemonState::new(cfg.clone()));
|
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 session_idle_task = spawn_session_idle_reaper(Arc::clone(&state));
|
||||||
let browser_liveness_task = spawn_browser_liveness_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:
|
// Fired by the update check task after a successful auto-update:
|
||||||
@@ -458,6 +462,7 @@ pub(crate) fn spawn_browser_liveness_reaper(
|
|||||||
for s in state.sessions.purge_browser(&client.id) {
|
for s in state.sessions.purge_browser(&client.id) {
|
||||||
state.tool_queues.remove(&s.id);
|
state.tool_queues.remove(&s.id);
|
||||||
state.session_interrupts.drop_session(&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");
|
debug!(session = %s.id, "purged session on browser liveness timeout");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -494,7 +499,10 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
|
|||||||
)
|
)
|
||||||
.await
|
.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) => {
|
Err(StopSessionError::SessionBusy | StopSessionError::Stopping) => {
|
||||||
debug!(session = %session_id, "idle session still active; retrying later");
|
debug!(session = %session_id, "idle session still active; retrying later");
|
||||||
}
|
}
|
||||||
@@ -505,6 +513,7 @@ pub(crate) fn spawn_session_idle_reaper(state: Arc<DaemonState>) -> tokio::task:
|
|||||||
&state.session_interrupts,
|
&state.session_interrupts,
|
||||||
&session_id,
|
&session_id,
|
||||||
);
|
);
|
||||||
|
state.transfers.release_session(&session_id.0);
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
warn!(session = %session_id, error = %err, "failed to stop idle session");
|
warn!(session = %session_id, error = %err, "failed to stop idle session");
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use tokio::task::JoinHandle;
|
|||||||
|
|
||||||
use super::abort::AbortRegistry;
|
use super::abort::AbortRegistry;
|
||||||
use super::browsers::BrowserRegistry;
|
use super::browsers::BrowserRegistry;
|
||||||
|
use super::file_transfer::TransferRegistry;
|
||||||
use super::inflight::ToolInflightRegistry;
|
use super::inflight::ToolInflightRegistry;
|
||||||
use super::ipc::IpcHandle;
|
use super::ipc::IpcHandle;
|
||||||
use super::queue::ToolQueueRegistry;
|
use super::queue::ToolQueueRegistry;
|
||||||
@@ -51,6 +52,9 @@ pub struct DaemonState {
|
|||||||
/// `SessionRegistry` because the signal is a transient runtime
|
/// `SessionRegistry` because the signal is a transient runtime
|
||||||
/// control state.
|
/// control state.
|
||||||
pub session_interrupts: Arc<SessionInterruptRegistry>,
|
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 {
|
impl DaemonState {
|
||||||
@@ -64,6 +68,7 @@ impl DaemonState {
|
|||||||
));
|
));
|
||||||
let abort_registry = Arc::new(AbortRegistry::new());
|
let abort_registry = Arc::new(AbortRegistry::new());
|
||||||
let session_interrupts = Arc::new(SessionInterruptRegistry::new());
|
let session_interrupts = Arc::new(SessionInterruptRegistry::new());
|
||||||
|
let transfers = Arc::new(TransferRegistry::new().expect("initialise transfer staging"));
|
||||||
Self {
|
Self {
|
||||||
config,
|
config,
|
||||||
browsers,
|
browsers,
|
||||||
@@ -72,6 +77,7 @@ impl DaemonState {
|
|||||||
abort_registry,
|
abort_registry,
|
||||||
tool_inflight,
|
tool_inflight,
|
||||||
session_interrupts,
|
session_interrupts,
|
||||||
|
transfers,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ async fn drive_connection(
|
|||||||
for session in state.sessions.purge_browser(&browser_id) {
|
for session in state.sessions.purge_browser(&browser_id) {
|
||||||
state.tool_queues.remove(&session.id);
|
state.tool_queues.remove(&session.id);
|
||||||
state.session_interrupts.drop_session(&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");
|
debug!(session = %session.id, "purged stale session before browser reconnect");
|
||||||
}
|
}
|
||||||
let (tx, mut rx) = mpsc::unbounded_channel::<Frame>();
|
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) {
|
for s in state.sessions.purge_browser(&browser_id) {
|
||||||
state.tool_queues.remove(&s.id);
|
state.tool_queues.remove(&s.id);
|
||||||
state.session_interrupts.drop_session(&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");
|
debug!(session = %s.id, "purged session on browser disconnect");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -503,6 +505,7 @@ fn handle_session_window_closed(
|
|||||||
&state.session_interrupts,
|
&state.session_interrupts,
|
||||||
&session_id,
|
&session_id,
|
||||||
) {
|
) {
|
||||||
|
state.transfers.release_session(&session_id.0);
|
||||||
info!(session = %session_id, "session removed: user closed Agent Window");
|
info!(session = %session_id, "session removed: user closed Agent Window");
|
||||||
} else {
|
} else {
|
||||||
debug!(session = %session_id, "session.window_closed for unknown session id");
|
debug!(session = %session_id, "session.window_closed for unknown session id");
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ fn dispatch(cli: Cli, format: Format) -> Result<(), CliError> {
|
|||||||
Command::Fill(args) => cli::interaction::dispatch_fill(args, format),
|
Command::Fill(args) => cli::interaction::dispatch_fill(args, format),
|
||||||
Command::Press(args) => cli::interaction::dispatch_press(args, format),
|
Command::Press(args) => cli::interaction::dispatch_press(args, format),
|
||||||
Command::Select(args) => cli::interaction::dispatch_select(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::Evaluate(args) => cli::evaluate::dispatch(args, format),
|
||||||
Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format),
|
Command::WaitForNavigation(args) => cli::waits::dispatch_wait_for_navigation(args, format),
|
||||||
Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format),
|
Command::WaitMs(args) => cli::waits::dispatch_wait_ms(args, format),
|
||||||
|
|||||||
@@ -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]
|
#[test]
|
||||||
fn parses_record_start_with_browser_and_url() {
|
fn parses_record_start_with_browser_and_url() {
|
||||||
let cli = parse(&[
|
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!(PressResult, "tool_press_result");
|
||||||
dump!(SelectParams, "tool_select_params");
|
dump!(SelectParams, "tool_select_params");
|
||||||
dump!(SelectResult, "tool_select_result");
|
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!(SnapshotParams, "tool_snapshot_params");
|
||||||
dump!(SnapshotResult, "tool_snapshot_result");
|
dump!(SnapshotResult, "tool_snapshot_result");
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ pub enum Method {
|
|||||||
ToolPress,
|
ToolPress,
|
||||||
#[serde(rename = "tool.select")]
|
#[serde(rename = "tool.select")]
|
||||||
ToolSelect,
|
ToolSelect,
|
||||||
|
#[serde(rename = "tool.upload")]
|
||||||
|
ToolUpload,
|
||||||
|
#[serde(rename = "tool.download")]
|
||||||
|
ToolDownload,
|
||||||
#[serde(rename = "tool.snapshot")]
|
#[serde(rename = "tool.snapshot")]
|
||||||
ToolSnapshot,
|
ToolSnapshot,
|
||||||
#[serde(rename = "tool.observe")]
|
#[serde(rename = "tool.observe")]
|
||||||
@@ -105,6 +109,17 @@ pub enum Method {
|
|||||||
#[serde(rename = "tool.record_await")]
|
#[serde(rename = "tool.record_await")]
|
||||||
ToolRecordAwait,
|
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")]
|
#[serde(rename = "cancel")]
|
||||||
Cancel,
|
Cancel,
|
||||||
}
|
}
|
||||||
@@ -156,6 +171,8 @@ impl Method {
|
|||||||
| Method::ToolFill
|
| Method::ToolFill
|
||||||
| Method::ToolPress
|
| Method::ToolPress
|
||||||
| Method::ToolSelect
|
| Method::ToolSelect
|
||||||
|
| Method::ToolUpload
|
||||||
|
| Method::ToolDownload
|
||||||
| Method::ToolEvaluate
|
| Method::ToolEvaluate
|
||||||
// May navigate via optional `url` and changes Agent Window
|
// May navigate via optional `url` and changes Agent Window
|
||||||
// chrome; gate behind pending-interrupt like other writes.
|
// chrome; gate behind pending-interrupt like other writes.
|
||||||
@@ -194,6 +211,11 @@ impl Method {
|
|||||||
| Method::SystemPing
|
| Method::SystemPing
|
||||||
| Method::SystemStatus
|
| Method::SystemStatus
|
||||||
| Method::BrowserList
|
| Method::BrowserList
|
||||||
|
| Method::TransferBegin
|
||||||
|
| Method::TransferChunk
|
||||||
|
| Method::TransferFinish
|
||||||
|
| Method::TransferRead
|
||||||
|
| Method::TransferRelease
|
||||||
| Method::Cancel => MethodEffect::ControlPlane,
|
| 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
pub mod console;
|
pub mod console;
|
||||||
pub mod dialog;
|
pub mod dialog;
|
||||||
pub mod emulate;
|
pub mod emulate;
|
||||||
|
pub mod file_transfer;
|
||||||
pub mod human_loop;
|
pub mod human_loop;
|
||||||
pub mod interaction;
|
pub mod interaction;
|
||||||
pub mod navigation;
|
pub mod navigation;
|
||||||
@@ -18,6 +19,7 @@ pub mod window;
|
|||||||
pub use console::*;
|
pub use console::*;
|
||||||
pub use dialog::*;
|
pub use dialog::*;
|
||||||
pub use emulate::*;
|
pub use emulate::*;
|
||||||
|
pub use file_transfer::*;
|
||||||
pub use human_loop::*;
|
pub use human_loop::*;
|
||||||
pub use interaction::*;
|
pub use interaction::*;
|
||||||
pub use navigation::*;
|
pub use navigation::*;
|
||||||
|
|||||||
@@ -143,6 +143,14 @@ flowchart LR
|
|||||||
- No credential storage in bsk — cookies stay in the user's browser profile.
|
- No credential storage in bsk — cookies stay in the user's browser profile.
|
||||||
- `evaluate` restricted to Agent Window tabs in sandbox mode.
|
- `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
|
## Repository layout
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 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) |
|
| `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
|
### Scripting & timing
|
||||||
|
|
||||||
| Command | Summary |
|
| Command | Summary |
|
||||||
|
|||||||
Reference in New Issue
Block a user