mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
feat(packages): add dsh-plugin-browserskill — DeepSeek Harness tools for bsk
Add a new monorepo package that registers BrowserSkill (bsk) browser automation as model-visible tools in DeepSeek Harness (dsh): - 11 tools mapping to bsk CLI --json commands: session start/stop/list, navigate, snapshot, observe, click, fill, press, screenshot, emulate - multi-session support: optional session arg with a current-session pointer, session id echoed in every result, configurable concurrency cap (default 5), and dispose-time cleanup of plugin-started sessions - cancellation: exec.signal aborts kill the underlying bsk child process - terminal-style UI cards (command line as the call card, output as the result card); screenshots inline the PNG through the host attachment store when the model route accepts image input, else return the path - config: bskPath / defaultTimeoutMs / maxSessions via Schemastery schema - install guidance when the bsk binary is missing; bsk JSON error envelopes (code/message/hint) surfaced to the model - unit tests mock the bsk runner (no real browser required); the root lint script now also runs the package's typecheck + tests so the existing CI frontend job covers the new package (a dedicated CI job diff is provided in the PR; the pushing token lacks workflow scope)
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* Process runner for the `bsk` CLI. Every model-facing tool in this plugin
|
||||
* maps to one `bsk <cmd> --json` invocation: spawn the child, capture
|
||||
* stdout/stderr, honor the dsh cancellation signal by killing the child, and
|
||||
* map the CLI's JSON error envelope onto a thrown `BskError`.
|
||||
*/
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
|
||||
/** Shape of the JSON error envelope `bsk --json` prints on failure. */
|
||||
export interface BskErrorBody {
|
||||
code?: string;
|
||||
message?: string;
|
||||
hint?: string;
|
||||
exit_code?: number;
|
||||
data?: unknown;
|
||||
}
|
||||
|
||||
/** A failed `bsk` invocation (non-zero exit, timeout, or spawn failure). */
|
||||
export class BskError extends Error {
|
||||
readonly code?: string;
|
||||
readonly hint?: string;
|
||||
readonly exitCode?: number | null;
|
||||
readonly timedOut: boolean;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
options: { code?: string; hint?: string; exitCode?: number | null; timedOut?: boolean } = {},
|
||||
) {
|
||||
super(message);
|
||||
this.name = "BskError";
|
||||
this.code = options.code;
|
||||
this.hint = options.hint;
|
||||
this.exitCode = options.exitCode;
|
||||
this.timedOut = options.timedOut ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
export interface BskRunResult {
|
||||
/** Process exit code, or null when killed by a signal / never started. */
|
||||
code: number | null;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
timedOut: boolean;
|
||||
aborted: boolean;
|
||||
}
|
||||
|
||||
export interface BskRunOptions {
|
||||
signal?: AbortSignal;
|
||||
timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Minimal spawn signature so tests can substitute a fake child process. */
|
||||
export type SpawnImpl = (command: string, args: string[]) => ChildProcess;
|
||||
|
||||
export interface BskRunner {
|
||||
/** Run `bsk <args...> --json` and collect its output. */
|
||||
run(args: string[], options?: BskRunOptions): Promise<BskRunResult>;
|
||||
/** Kill every in-flight child (used when the plugin unloads). */
|
||||
killAll(): void;
|
||||
}
|
||||
|
||||
const KILL_GRACE_MS = 2000;
|
||||
|
||||
export function createBskRunner(bskPath: string, spawnImpl: SpawnImpl = spawn): BskRunner {
|
||||
const live = new Set<ChildProcess>();
|
||||
|
||||
function killChild(child: ChildProcess): void {
|
||||
if (child.exitCode !== null || child.signalCode !== null) return;
|
||||
child.kill("SIGTERM");
|
||||
const force = setTimeout(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL");
|
||||
}, KILL_GRACE_MS);
|
||||
force.unref();
|
||||
}
|
||||
|
||||
return {
|
||||
run(args, options = {}) {
|
||||
return new Promise<BskRunResult>((resolve, reject) => {
|
||||
let child: ChildProcess;
|
||||
try {
|
||||
child = spawnImpl(bskPath, [...args, "--json"]);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
live.add(child);
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let timedOut = false;
|
||||
let aborted = false;
|
||||
child.stdout?.on("data", (chunk: Buffer | string) => {
|
||||
stdout += chunk;
|
||||
});
|
||||
child.stderr?.on("data", (chunk: Buffer | string) => {
|
||||
stderr += chunk;
|
||||
});
|
||||
|
||||
const timeoutMs = options.timeoutMs;
|
||||
const timer =
|
||||
timeoutMs !== undefined && timeoutMs > 0
|
||||
? setTimeout(() => {
|
||||
timedOut = true;
|
||||
killChild(child);
|
||||
}, timeoutMs)
|
||||
: undefined;
|
||||
timer?.unref();
|
||||
|
||||
const onAbort = () => {
|
||||
aborted = true;
|
||||
killChild(child);
|
||||
};
|
||||
if (options.signal?.aborted) {
|
||||
onAbort();
|
||||
} else {
|
||||
options.signal?.addEventListener("abort", onAbort, { once: true });
|
||||
}
|
||||
|
||||
const settle = () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
options.signal?.removeEventListener("abort", onAbort);
|
||||
live.delete(child);
|
||||
};
|
||||
|
||||
child.on("error", (error) => {
|
||||
settle();
|
||||
reject(error);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
settle();
|
||||
resolve({ code, stdout, stderr, timedOut, aborted });
|
||||
});
|
||||
});
|
||||
},
|
||||
killAll() {
|
||||
for (const child of live) killChild(child);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** True when the spawn failure means the bsk binary itself is missing. */
|
||||
export function isCommandNotFound(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
/** Install guidance shown when the bsk CLI cannot be spawned. */
|
||||
export function bskInstallMessage(bskPath: string): string {
|
||||
return (
|
||||
`the bsk CLI ("${bskPath}") was not found. BrowserSkill must be installed and on PATH ` +
|
||||
"for browser tools to work — install it from https://github.com/Tencent/BrowserSkill " +
|
||||
"(see the README install script or `cargo install`), then retry."
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Interpret one finished run: throw `BskError` on timeout / non-zero exit
|
||||
* (parsing the CLI's JSON error envelope when present), otherwise parse and
|
||||
* return the stdout JSON payload.
|
||||
*/
|
||||
export function parseBskJson(result: BskRunResult, commandLabel: string): unknown {
|
||||
if (result.timedOut) {
|
||||
throw new BskError(`bsk ${commandLabel} timed out`, { timedOut: true });
|
||||
}
|
||||
const body = result.stdout.trim();
|
||||
if (result.code !== 0) {
|
||||
let parsed: BskErrorBody | undefined;
|
||||
try {
|
||||
parsed = JSON.parse(body) as BskErrorBody;
|
||||
} catch {
|
||||
parsed = undefined;
|
||||
}
|
||||
const message =
|
||||
parsed?.message ?? (result.stderr.trim() || body || `bsk ${commandLabel} failed`);
|
||||
throw new BskError(`bsk ${commandLabel} failed: ${message}`, {
|
||||
code: parsed?.code,
|
||||
hint: parsed?.hint,
|
||||
exitCode: result.code,
|
||||
});
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body);
|
||||
} catch {
|
||||
throw new BskError(
|
||||
`bsk ${commandLabel} did not produce JSON output: ${body.slice(0, 200) || "(empty)"}`,
|
||||
{ exitCode: result.code },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user