feat(dsh-plugin-browserskill): strict ownership boundary — foreign sessions are invisible

Tighten the shared-daemon boundary from 'cleanup only touches owned
sessions' to 'the plugin only ever sees and operates on sessions it
created':

- browser_session_list no longer queries the daemon at all; it returns
  the plugin's own session table (with the current marker), so foreign
  sessions cannot even be enumerated through the plugin.
- Every tool's optional session argument must name a plugin-created
  session; foreign/unknown ids are rejected with a clear error before
  any command reaches the daemon (the reference-adoption channel is
  gone).
- Unchanged: dispose stops exactly the owned set, stop requires
  ownership, the start reservation protocol, and handle-precise child
  kills.

Tests updated (59): explicit-foreign rejection without daemon contact,
registry-only listing, reservation race, stale-handle-tolerant dispose.
e2e: a manually started foreign session was rejected on reference,
absent from session_list, refused on stop, and survived dispose while
both plugin sessions were cleaned up.
This commit is contained in:
BB-fat
2026-08-14 04:12:38 +00:00
parent afdc398730
commit 3d050e0f26
6 changed files with 113 additions and 92 deletions
@@ -75,8 +75,10 @@ describe("dispose cleanup ownership", () => {
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());
// A foreign session cannot even be referenced any more.
await expect(snapshot.execute({ session: "ext9" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
await Promise.all(disposers.map((dispose) => dispose()));
@@ -45,13 +45,22 @@ describe("SessionRegistry", () => {
expect(registry.ownedIds()).toEqual(["s1"]);
});
it("resolve prefers the explicit session and adopts it as a reference", () => {
it("resolve accepts an explicit owned session and makes it current", () => {
const registry = new SessionRegistry(5);
start(registry, "s1");
expect(registry.resolve("foreign", "tool")).toBe("foreign");
expect(registry.current()).toBe("foreign");
start(registry, "s2");
expect(registry.resolve("s1", "tool")).toBe("s1");
expect(registry.current()).toBe("s1");
});
it("resolve rejects foreign ids and never adopts them", () => {
const registry = new SessionRegistry(5);
start(registry, "s1");
expect(() => registry.resolve("foreign", "tool")).toThrow(/does not belong to this plugin/);
expect(registry.isOwned("foreign")).toBe(false);
expect(registry.ownedIds()).toEqual(["s1"]);
// A rejected resolve must not move the current pointer.
expect(registry.current()).toBe("s1");
});
it("resolve falls back to the current session", () => {
@@ -65,11 +74,11 @@ describe("SessionRegistry", () => {
expect(() => registry.resolve(undefined, "browser_click")).toThrow(/browser_session_start/);
});
it("touch refreshes recency so fallback picks the most recent survivor", () => {
it("recency order survives remove of the current session", () => {
const registry = new SessionRegistry(5);
start(registry, "s1");
start(registry, "s2");
registry.touch("s1");
registry.resolve("s1", "tool");
registry.remove("s1");
expect(registry.current()).toBe("s2");
});
@@ -79,9 +88,8 @@ describe("SessionRegistry", () => {
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/);
expect(() => registry.resolveForStop("foreign")).toThrow(/does not belong to this plugin/);
expect(() => registry.resolveForStop("never-seen")).toThrow(/does not belong to this plugin/);
});
it("a refused stop does not move the current pointer", () => {
@@ -91,12 +99,10 @@ describe("SessionRegistry", () => {
expect(registry.current()).toBe("s1");
});
it("ownedIds excludes referenced sessions even after heavy interleaving", () => {
it("ownedIds always equals every registered session (foreign ids never enter)", () => {
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"]);
});
});
@@ -208,16 +208,31 @@ describe("multi-session behavior", () => {
expect(calls[1].args).toEqual(["snapshot", "--session", "s1"]);
});
it("an explicit session wins and becomes the new current session", async () => {
const { tools, calls, registry } = setup(responses);
await startSession(tools);
it("an explicit owned session wins and becomes the new current session", async () => {
const { tools, calls, registry } = setup({
"session start": seq([START_REPLY("s1"), START_REPLY("s2")]),
snapshot: SNAPSHOT_REPLY,
});
await startSession(tools, "s1");
await startSession(tools, "s2");
const snapshot = tools.get("browser_snapshot");
const value = (await snapshot?.execute({ session: "other" }, makeExec())) as {
const value = (await snapshot?.execute({ session: "s1" }, makeExec())) as {
session: string;
};
expect(value.session).toBe("other");
expect(calls[1].args).toEqual(["snapshot", "--session", "other"]);
expect(registry.current()).toBe("other");
expect(value.session).toBe("s1");
expect(calls[2].args).toEqual(["snapshot", "--session", "s1"]);
expect(registry.current()).toBe("s1");
});
it("rejects an explicit foreign session without touching the daemon", async () => {
const { tools, calls } = setup(responses);
await startSession(tools);
const callsBefore = calls.length;
const snapshot = tools.get("browser_snapshot");
await expect(snapshot?.execute({ session: "other" }, makeExec())).rejects.toThrow(
/does not belong to this plugin/,
);
expect(calls.length).toBe(callsBefore);
});
it("fails with guidance when no session exists", async () => {
@@ -263,12 +278,9 @@ describe("multi-session behavior", () => {
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/,
/does not belong to 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);
@@ -289,23 +301,22 @@ describe("browser_session_stop / list", () => {
expect(registry.current()).toBeUndefined();
});
it("lists daemon sessions with the current marker", async () => {
const { tools } = setup({
"session start": START_REPLY("s1"),
"session list": [
{ session_id: "s1", browser_instance_id: "chrome-1" },
{ session_id: "s2", browser_instance_id: "chrome-1" },
],
it("lists only plugin-owned sessions with the current marker (no daemon view)", async () => {
const { tools, calls } = setup({
"session start": seq([START_REPLY("s1"), START_REPLY("s2")]),
});
await startSession(tools);
await startSession(tools);
const list = tools.get("browser_session_list");
const value = (await list?.execute({}, makeExec())) as {
sessions: { sessionId: string; current: boolean }[];
};
expect(value.sessions).toEqual([
{ sessionId: "s1", browserInstanceId: "chrome-1", current: true },
{ sessionId: "s2", browserInstanceId: "chrome-1", current: false },
{ sessionId: "s1", browserInstanceId: "chrome-1", current: false },
{ sessionId: "s2", browserInstanceId: "chrome-1", current: true },
]);
// Registry-only: listing must not call the daemon at all.
expect(calls.some((c) => c.args.join(" ").startsWith("session list"))).toBe(false);
});
});
@@ -533,8 +544,8 @@ describe("error and cancellation semantics", () => {
killAll() {},
};
registerTools({ ctx: ctx as never, runner, registry, config: CONFIG });
const list = tools.get("browser_session_list");
await expect(list?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/);
const start = tools.get("browser_session_start");
await expect(start?.execute({}, makeExec())).rejects.toThrow(/BrowserSkill must be installed/);
});
});