fix(dsh-plugin-browserskill): strict session ownership on a shared daemon

Review hardening for daemon-sharing deployments (other agents,
terminals, or dsh instances on the same bsk daemon):

- Ownership split: only sessions created by browser_session_start are
  'owned'. Explicitly referenced foreign sessions are still tracked for
  the current-session pointer, but browser_session_stop refuses them
  and unload cleanup stops exactly the owned set — no path can stop a
  session this plugin did not create.
- Start race: the capacity check is now a synchronous
  reserveStart/completeStart/abandonStart protocol, so two concurrent
  starts can never both pass the cap (check-and-reserve is atomic on
  the event loop); a rejected or failed start never leaks a session.
- Stale handles: dispose already swallowed per-stop errors; the owned
  set is computed at dispose time so an externally stopped session is a
  no-op, not a failure.

Tests (+7): reservation race (two concurrent starts, cap 1 -> exactly
one spawn), foreign-stop refusal (no stop command reaches the daemon),
reference adoption never becoming owned, resolveForStop pointer
stability, and an apply-level dispose test proving only owned sessions
are stopped while stale stops are tolerated. e2e: a manually started
foreign session survived the plugin's full lifecycle while both plugin
sessions were cleaned up.
This commit is contained in:
BB-fat
2026-08-14 03:45:52 +00:00
parent 6b96fcca32
commit afdc398730
6 changed files with 304 additions and 52 deletions
+14 -9
View File
@@ -162,19 +162,23 @@ export function registerTools(deps: ToolDeps): void {
if ((args.width === undefined) !== (args.height === undefined)) {
throw new Error("width and height must be given together");
}
// Check the cap BEFORE spawning so a rejected start never leaks a session.
registry.assertCapacity();
// Reserve the slot BEFORE spawning: check-and-reserve is synchronous,
// so concurrent starts can never both pass the cap and leak a session.
registry.reserveStart();
const startArgs = ["session", "start"];
if (args.width !== undefined && args.height !== undefined) {
startArgs.push("--width", String(args.width), "--height", String(args.height));
}
if (args.noFocus === true) startArgs.push("--no-focus");
if (args.browser !== undefined) startArgs.push("--browser", args.browser);
const reply = (await runBsk(deps, exec, startArgs, "session start")) as {
session_id: string;
browser_instance_id: string;
};
registry.add({
let reply: { session_id: string; browser_instance_id: string };
try {
reply = (await runBsk(deps, exec, startArgs, "session start")) as typeof reply;
} catch (error) {
registry.abandonStart();
throw error;
}
registry.completeStart({
sessionId: reply.session_id,
browserInstanceId: reply.browser_instance_id,
startedAtMs: Date.now(),
@@ -230,7 +234,8 @@ export function registerTools(deps: ToolDeps): void {
name: "browser_session_stop",
description:
"Stop a browser session and close its Agent Window. Stops the given session, or the " +
"current session when `session` is omitted.",
"current session when `session` is omitted. Only sessions created by browser_session_start " +
"can be stopped — sessions owned by other programs sharing the bsk daemon are refused.",
parameters: { session: SESSION_PARAM },
output: {
schema: {
@@ -243,7 +248,7 @@ export function registerTools(deps: ToolDeps): void {
],
},
async execute(args, exec) {
const sessionId = registry.resolve(args.session, "browser_session_stop");
const sessionId = registry.resolveForStop(args.session);
await runBsk(deps, exec, ["session", "stop", sessionId], "session stop");
registry.remove(sessionId);
return { stopped: sessionId };