mirror of
https://github.com/Tencent/BrowserSkill.git
synced 2026-09-10 13:01:23 +08:00
fix(dsh-plugin-browserskill): address review findings (leaks, trust fence, queue race)
Blocking: - B1: delete screenshot scratch PNGs — observation frames reuse one fixed per-session path and unlink after every read (finally); browser_screenshot unlinks once the bytes are in the attachment store (kept only when the file itself is the model-facing artifact). - B2: replicate dsh's browser-trust fence on /bsk-observation/*: loopback Host only, Origin must match Host, sec-fetch-site: cross-site refused, POST requires application/json; README documents the loopback trust premise and the 0.0.0.0 warning. Major: - M1: KeyedExecutor tail chains previous+task (allSettled) so a task aborted while queued cannot release the next one into the running session. - M2: client tracks the live frame per session — replacing/removing/reset revokes the old blob URL at once; loads settling after replacement never resurrect it. Minor: drop replaced thumbRefs (+on remove); beginAction fires inside the queue (no label overwrite while queued, no idle flash); half-initialized session cleanup stops through the queue with one retry; SSE (re)open refetches /state (+ snapshot flag renamed subscribed); install probe uses --version (no daemon spawn); emulate mobile documents the width+height requirement (the daemon refuses it alone — verified). Nit: single BskRunOptions declaration; tails map entry dropped on drain; SSE cleanup on res close; popOut catches requestWindow rejections; resize handle gains keyboard control (arrows, 16px steps, aria value attrs). Tests: +14 (fence rules, scratch lifecycle, queue race, blob revocation, instrumentation timing, SSE resync). 113/113 green.
This commit is contained in:
@@ -1,8 +1,8 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { ObservationService } from "../src/observation";
|
||||
import { KeyedExecutor } from "../src/queue";
|
||||
import type { BskRunner, BskRunOptions, BskRunResult } from "../src/runner";
|
||||
@@ -621,3 +621,180 @@ describe("presentation", () => {
|
||||
expect(view).toMatchObject({ card: "terminal", output: "[session s1] screenshot of tab 7" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("screenshot scratch file lifecycle", () => {
|
||||
/** Runner that honors --out like the real bsk (writes the PNG there). */
|
||||
function outWritingRunner() {
|
||||
const calls: FakeCall[] = [];
|
||||
return {
|
||||
calls,
|
||||
runner: {
|
||||
async run(args: string[], options: BskRunOptions = {}): Promise<BskRunResult> {
|
||||
calls.push({ args, options });
|
||||
if (args[0] === "session") {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
if (args[0] === "screenshot") {
|
||||
const out = args[args.indexOf("--out") + 1];
|
||||
writeFileSync(out, Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
return {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({
|
||||
tab_id: 7,
|
||||
width: 4,
|
||||
height: 4,
|
||||
format: "png",
|
||||
path: out,
|
||||
byte_size: 4,
|
||||
}),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
return { code: 2, stdout: "{}", stderr: "", timedOut: false, aborted: false };
|
||||
},
|
||||
killAll() {},
|
||||
killFor: () => 0,
|
||||
} as BskRunner,
|
||||
};
|
||||
}
|
||||
|
||||
function setupWithRunner(runner: BskRunner, services: Record<string, unknown> = {}) {
|
||||
const { ctx, tools } = makeCtx(services);
|
||||
const registry = new SessionRegistry(5);
|
||||
registerTools({
|
||||
ctx: ctx as never,
|
||||
runner,
|
||||
registry,
|
||||
config: CONFIG,
|
||||
observation: disabledObservation({ ctx, runner, registry }),
|
||||
queue: new KeyedExecutor(),
|
||||
});
|
||||
return { tools, registry };
|
||||
}
|
||||
|
||||
const IMAGE_SERVICES = {
|
||||
attachments: {
|
||||
imageLimits: { mediaTypes: ["image/png"], maxImageBytes: 1e7, maxMessageImageBytes: 1e7 },
|
||||
saveImage: async (input: { data: Uint8Array; mediaType: string }) => ({
|
||||
attachmentId: "att-1",
|
||||
mediaType: input.mediaType,
|
||||
bytes: input.data.byteLength,
|
||||
width: 4,
|
||||
height: 4,
|
||||
}),
|
||||
},
|
||||
llm: { resolveModelInfo: async () => ({ inputModalities: ["text", "image"] }) },
|
||||
};
|
||||
|
||||
it("deletes the scratch PNG once the bytes are in the attachment store", async () => {
|
||||
const { runner, calls } = outWritingRunner();
|
||||
const { tools } = setupWithRunner(runner, IMAGE_SERVICES);
|
||||
await startSession(tools);
|
||||
const exec = makeExec({
|
||||
agent: {
|
||||
session: { requestHeader: () => ({ config: { provider: "p", model: "vl" } }) },
|
||||
options: {},
|
||||
},
|
||||
});
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const value = (await screenshot?.execute({}, exec)) as { image?: unknown; path: string };
|
||||
expect(value.image).toBeDefined();
|
||||
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
|
||||
expect(out).toBeDefined();
|
||||
expect(existsSync(out ?? "")).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the PNG only when it is the model-facing artifact (no store)", async () => {
|
||||
const { runner, calls } = outWritingRunner();
|
||||
const { tools } = setupWithRunner(runner);
|
||||
await startSession(tools);
|
||||
const screenshot = tools.get("browser_screenshot");
|
||||
const value = (await screenshot?.execute({}, makeExec())) as { image?: unknown; path: string };
|
||||
expect(value.image).toBeUndefined();
|
||||
const out = calls.find((c) => c.args[0] === "screenshot")?.args.at(-1);
|
||||
expect(existsSync(out ?? "")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("observation action instrumentation timing", () => {
|
||||
it("a queued call never overwrites the in-flight action label", async () => {
|
||||
const { ctx, tools } = makeCtx();
|
||||
const registry = new SessionRegistry(5);
|
||||
let releaseNavigate!: () => void;
|
||||
const navigateGate = new Promise<void>((resolve) => {
|
||||
releaseNavigate = resolve;
|
||||
});
|
||||
const runner: BskRunner = {
|
||||
async run(args: string[]): Promise<BskRunResult> {
|
||||
if (args[0] === "session") {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ session_id: "s1", browser_instance_id: "chrome-1" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
if (args[0] === "navigate") {
|
||||
await navigateGate;
|
||||
return {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ tab_id: 7, url: "http://x" }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
code: 0,
|
||||
stdout: JSON.stringify({ text: "ok", ref_count: 0, tab_id: 7, truncated: false }),
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
aborted: false,
|
||||
};
|
||||
},
|
||||
killAll() {},
|
||||
killFor: () => 0,
|
||||
};
|
||||
const queue = new KeyedExecutor();
|
||||
const observation = new ObservationService({
|
||||
ctx: ctx as never,
|
||||
runner,
|
||||
registry,
|
||||
queue,
|
||||
options: { enabled: true, thumbnailIntervalMs: 1500, idleIntervalMs: 8000 },
|
||||
});
|
||||
registerTools({ ctx: ctx as never, runner, registry, config: CONFIG, observation, queue });
|
||||
|
||||
await startSession(tools);
|
||||
const navigate = tools.get("browser_navigate");
|
||||
const snapshot = tools.get("browser_snapshot");
|
||||
const a = navigate?.execute({ url: "http://x" }, makeExec()) as Promise<unknown>;
|
||||
// A starts immediately (empty queue) and blocks on the gate.
|
||||
await vi.waitFor(() =>
|
||||
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating"),
|
||||
);
|
||||
// B queues behind A: the label must stay "navigating" until A settles.
|
||||
const b = snapshot?.execute({}, makeExec()) as Promise<unknown>;
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("navigating");
|
||||
releaseNavigate();
|
||||
await a;
|
||||
await vi.waitFor(() =>
|
||||
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("snapshotting"),
|
||||
);
|
||||
await b;
|
||||
await vi.waitFor(() =>
|
||||
expect(observation.getState().find((s) => s.sessionId === "s1")?.action).toBe("idle"),
|
||||
);
|
||||
observation.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user