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
@@ -0,0 +1,89 @@
// Unload-cleanup ownership discipline: dispose must stop exactly the sessions
// this plugin created — never referenced/foreign sessions on the shared
// daemon — and a stale handle (already stopped elsewhere, daemon restarted)
// must not abort the remaining stops.
import type { ToolDefinition, ToolRunContext } from "@deepseek-ai/dsh-tools";
import { describe, expect, it } from "vitest";
import { apply } from "../src/index";
import type { BskRunOptions, BskRunResult } from "../src/runner";
interface FakeCall {
args: string[];
}
function ok(payload: unknown): BskRunResult {
return { code: 0, stdout: JSON.stringify(payload), stderr: "", timedOut: false, aborted: false };
}
function fail(message: string): BskRunResult {
return {
code: 4,
stdout: JSON.stringify({ code: "session_not_found", message }),
stderr: "",
timedOut: false,
aborted: false,
};
}
function makeExec(): ToolRunContext {
return {
callId: "t",
name: "t",
signal: new AbortController().signal,
} as unknown as ToolRunContext;
}
describe("dispose cleanup ownership", () => {
it("stops only plugin-owned sessions and tolerates stale handles", async () => {
const calls: FakeCall[] = [];
const startReplies = [
{ session_id: "own1", browser_instance_id: "b1" },
{ session_id: "own2", browser_instance_id: "b1" },
];
const runner = {
async run(args: string[], _options: BskRunOptions = {}): Promise<BskRunResult> {
calls.push({ args });
const joined = args.join(" ");
if (joined.startsWith("status")) return ok({});
if (joined.startsWith("session start")) return ok(startReplies.shift());
if (joined.startsWith("snapshot")) {
return ok({ text: "x", ref_count: 1, tab_id: 7, truncated: false });
}
if (joined === "session stop own1") return ok({});
if (joined === "session stop own2") {
// Stale handle: already stopped by someone else / daemon restarted.
return fail("session not found");
}
return fail(`unexpected command: ${joined}`);
},
killAll() {},
};
const tools = new Map<string, ToolDefinition>();
const disposers: Array<() => Promise<void>> = [];
const ctx = {
tools: { register: (def: ToolDefinition) => tools.set(def.name, def) },
get: () => undefined,
effect: (fn: () => () => Promise<void>) => {
disposers.push(fn());
},
};
apply(ctx as never, { maxSessions: 5 }, { runnerFactory: () => runner });
const start = tools.get("browser_session_start");
const snapshot = tools.get("browser_snapshot");
if (start === undefined || snapshot === undefined) throw new Error("tools not registered");
await start.execute({}, makeExec());
await start.execute({}, makeExec());
// A foreign session gets referenced (current-pointer bookkeeping) but never owned.
await snapshot.execute({ session: "ext9" }, makeExec());
await Promise.all(disposers.map((dispose) => dispose()));
const stops = calls
.map((call) => call.args.join(" "))
.filter((joined) => joined.startsWith("session stop"));
expect(stops.sort()).toEqual(["session stop own1", "session stop own2"]);
expect(stops.some((joined) => joined.includes("ext9"))).toBe(false);
});
});
@@ -1,13 +1,19 @@
import { describe, expect, it } from "vitest";
import { SessionRegistry } from "../src/sessions";
/** Drive one full successful start through the reservation protocol. */
function start(registry: SessionRegistry, sessionId: string): void {
registry.reserveStart();
registry.completeStart({ sessionId, startedAtMs: Date.now() });
}
describe("SessionRegistry", () => {
it("tracks the current session across add and remove", () => {
it("tracks the current session across start and remove", () => {
const registry = new SessionRegistry(5);
expect(registry.current()).toBeUndefined();
registry.add({ sessionId: "s1", startedAtMs: 1 });
start(registry, "s1");
expect(registry.current()).toBe("s1");
registry.add({ sessionId: "s2", startedAtMs: 2 });
start(registry, "s2");
expect(registry.current()).toBe("s2");
registry.remove("s2");
expect(registry.current()).toBe("s1");
@@ -15,27 +21,42 @@ describe("SessionRegistry", () => {
expect(registry.current()).toBeUndefined();
});
it("enforces the concurrency cap", () => {
it("enforces the concurrency cap including in-flight starts", () => {
const registry = new SessionRegistry(2);
registry.add({ sessionId: "s1", startedAtMs: 1 });
registry.add({ sessionId: "s2", startedAtMs: 2 });
expect(() => registry.add({ sessionId: "s3", startedAtMs: 3 })).toThrow(/session limit/);
start(registry, "s1");
start(registry, "s2");
expect(() => registry.reserveStart()).toThrow(/session limit/);
registry.remove("s1");
registry.add({ sessionId: "s3", startedAtMs: 3 });
registry.reserveStart();
registry.completeStart({ sessionId: "s3", startedAtMs: Date.now() });
expect(registry.size()).toBe(2);
});
it("resolve prefers the explicit session and adopts unknown ids", () => {
it("counts reserved (not yet spawned) starts against the cap — the race case", () => {
const registry = new SessionRegistry(2);
registry.reserveStart();
registry.reserveStart();
// Both slots are in flight; a third concurrent start must reject even
// though no session has been registered yet.
expect(() => registry.reserveStart()).toThrow(/session limit/);
registry.abandonStart();
registry.reserveStart();
registry.completeStart({ sessionId: "s1", startedAtMs: Date.now() });
expect(registry.ownedIds()).toEqual(["s1"]);
});
it("resolve prefers the explicit session and adopts it as a reference", () => {
const registry = new SessionRegistry(5);
registry.add({ sessionId: "s1", startedAtMs: 1 });
expect(registry.resolve("s9", "tool")).toBe("s9");
expect(registry.current()).toBe("s9");
expect(registry.list().map((s) => s.sessionId)).toContain("s9");
start(registry, "s1");
expect(registry.resolve("foreign", "tool")).toBe("foreign");
expect(registry.current()).toBe("foreign");
expect(registry.isOwned("foreign")).toBe(false);
expect(registry.ownedIds()).toEqual(["s1"]);
});
it("resolve falls back to the current session", () => {
const registry = new SessionRegistry(5);
registry.add({ sessionId: "s1", startedAtMs: 1 });
start(registry, "s1");
expect(registry.resolve(undefined, "tool")).toBe("s1");
});
@@ -46,10 +67,36 @@ describe("SessionRegistry", () => {
it("touch refreshes recency so fallback picks the most recent survivor", () => {
const registry = new SessionRegistry(5);
registry.add({ sessionId: "s1", startedAtMs: 1 });
registry.add({ sessionId: "s2", startedAtMs: 2 });
start(registry, "s1");
start(registry, "s2");
registry.touch("s1");
registry.remove("s1");
expect(registry.current()).toBe("s2");
});
it("resolveForStop accepts owned sessions and refuses foreign ones", () => {
const registry = new SessionRegistry(5);
start(registry, "s1");
expect(registry.resolveForStop(undefined)).toBe("s1");
expect(registry.resolveForStop("s1")).toBe("s1");
registry.resolve("foreign", "browser_snapshot");
expect(() => registry.resolveForStop("foreign")).toThrow(/not created by this plugin/);
expect(() => registry.resolveForStop("never-seen")).toThrow(/not created by this plugin/);
});
it("a refused stop does not move the current pointer", () => {
const registry = new SessionRegistry(5);
start(registry, "s1");
expect(() => registry.resolveForStop("foreign")).toThrow();
expect(registry.current()).toBe("s1");
});
it("ownedIds excludes referenced sessions even after heavy interleaving", () => {
const registry = new SessionRegistry(5);
start(registry, "own1");
registry.resolve("ext1", "tool");
start(registry, "own2");
registry.resolve("ext2", "tool");
expect(registry.ownedIds().sort()).toEqual(["own1", "own2"]);
});
});
@@ -238,6 +238,41 @@ describe("multi-session behavior", () => {
// A rejected start must not spawn another bsk session (no leaked session).
expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1);
});
it("shares the cap atomically across concurrent starts (reservation race)", async () => {
const { tools, calls } = setup(
{ "session start": seq([START_REPLY("s1"), START_REPLY("s2")]) },
{},
1,
);
const start = tools.get("browser_session_start");
if (start === undefined) throw new Error("not registered");
// Two truly concurrent starts against a cap of 1: exactly one may spawn.
const outcomes = await Promise.allSettled([
start.execute({}, makeExec()),
start.execute({}, makeExec()),
]);
const fulfilled = outcomes.filter((o) => o.status === "fulfilled");
const rejected = outcomes.filter((o) => o.status === "rejected");
expect(fulfilled).toHaveLength(1);
expect(rejected).toHaveLength(1);
expect(String((rejected[0] as PromiseRejectedResult).reason)).toMatch(/session limit/);
expect(calls.filter((c) => c.args.join(" ").startsWith("session start"))).toHaveLength(1);
});
it("refuses to stop a session the plugin did not create", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
// Reference a foreign session (adopted for the current pointer, never owned).
const snapshot = tools.get("browser_snapshot");
await snapshot?.execute({ session: "ext7" }, makeExec());
const stop = tools.get("browser_session_stop");
await expect(stop?.execute({ session: "ext7" }, makeExec())).rejects.toThrow(
/not created by this plugin/,
);
// No stop command may reach the daemon for a foreign session.
expect(calls.some((c) => c.args.join(" ").startsWith("session stop"))).toBe(false);
});
});
describe("browser_session_stop / list", () => {