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
@@ -4,44 +4,72 @@
* The pointer moves to whatever session was most recently started, stopped,
* or operated on, so a model can run several browsers side by side without
* repeating the id on every call.
*
* Ownership discipline (the bsk daemon may be shared with other agents,
* terminals, or dsh instances): a session is **owned** only when it was
* created by this plugin's browser_session_start; an explicit `session`
* argument naming a foreign session is tracked as a **reference** so the
* current-session pointer keeps working, but referenced sessions are never
* stopped — not by browser_session_stop and not by unload cleanup.
*/
export interface TrackedSession {
sessionId: string;
browserInstanceId?: string;
startedAtMs: number;
/** True only for sessions this plugin created (and therefore may stop). */
owned: boolean;
}
export class SessionRegistry {
private readonly sessions = new Map<string, TrackedSession>();
private currentId: string | undefined;
/**
* Slots reserved by in-flight starts. reserveStart/completeStart/abandonStart
* run synchronously around the async spawn, so concurrent starts can never
* both pass the capacity check (check-and-reserve is atomic on the event loop).
*/
private pendingStarts = 0;
constructor(private readonly maxSessions: number) {}
/**
* Register a freshly started session and make it current.
* @throws when the configured concurrency cap is already reached.
* Reserve a start slot synchronously, BEFORE spawning.
* @throws when the configured concurrency cap (tracked + in-flight) is reached.
*/
add(session: TrackedSession): void {
if (!this.sessions.has(session.sessionId)) this.assertCapacity();
this.sessions.set(session.sessionId, session);
this.currentId = session.sessionId;
}
/**
* Throw when starting another session would exceed the concurrency cap.
* Call BEFORE spawning a new session so a rejected start never leaks one.
*/
assertCapacity(): void {
if (this.sessions.size >= this.maxSessions) {
reserveStart(): void {
if (this.sessions.size + this.pendingStarts >= this.maxSessions) {
throw new Error(
`session limit reached (${this.maxSessions} concurrent sessions); ` +
"stop one with browser_session_stop before starting another",
);
}
this.pendingStarts += 1;
}
/** Forget a stopped session; falls back to the most recent remaining one. */
/** Give back a reservation after a start that never produced a session. */
abandonStart(): void {
this.pendingStarts = Math.max(0, this.pendingStarts - 1);
}
/**
* Register a freshly started (owned) session, consuming its reservation,
* and make it current.
*/
completeStart(session: Omit<TrackedSession, "owned">): void {
this.pendingStarts = Math.max(0, this.pendingStarts - 1);
// Backstop only: with the reservation protocol above this never fires.
if (!this.sessions.has(session.sessionId) && this.sessions.size >= this.maxSessions) {
throw new Error(
`session limit reached (${this.maxSessions} concurrent sessions); ` +
"stop one with browser_session_stop before starting another",
);
}
this.sessions.set(session.sessionId, { ...session, owned: true });
this.currentId = session.sessionId;
}
/** Forget a session; falls back to the most recent remaining one. */
remove(sessionId: string): void {
this.sessions.delete(sessionId);
if (this.currentId === sessionId) {
@@ -50,7 +78,7 @@ export class SessionRegistry {
}
}
/** Mark a session as most recently used. Unknown ids are adopted. */
/** Mark a session as most recently used. Unknown ids are adopted as references (never owned). */
touch(sessionId: string): void {
const existing = this.sessions.get(sessionId);
if (existing !== undefined) {
@@ -58,7 +86,7 @@ export class SessionRegistry {
this.sessions.delete(sessionId);
this.sessions.set(sessionId, existing);
} else {
this.sessions.set(sessionId, { sessionId, startedAtMs: Date.now() });
this.sessions.set(sessionId, { sessionId, startedAtMs: Date.now(), owned: false });
}
this.currentId = sessionId;
}
@@ -73,13 +101,26 @@ export class SessionRegistry {
return [...this.sessions.values()];
}
/** Ids of owned sessions — the exact set unload cleanup is allowed to stop. */
ownedIds(): string[] {
return this.list()
.filter((session) => session.owned)
.map((session) => session.sessionId);
}
/** Whether the session was created by this plugin and may be stopped by it. */
isOwned(sessionId: string): boolean {
return this.sessions.get(sessionId)?.owned === true;
}
size(): number {
return this.sessions.size;
}
/**
* Resolve the session a tool call acts on: an explicit `session` argument
* wins (and becomes current); otherwise fall back to the current session.
* Resolve the session an operation tool call acts on: an explicit `session`
* argument wins (and becomes current); otherwise fall back to the current
* session. Foreign ids are adopted as references.
* @throws when neither is available.
*/
resolve(explicit: string | undefined, toolName: string): string {
@@ -97,4 +138,28 @@ export class SessionRegistry {
this.touch(current);
return current;
}
/**
* Resolve the session a STOP call acts on, without adopting foreign ids:
* only owned sessions may be stopped, and the current pointer is not moved
* by a rejected stop.
* @throws when the target is unknown, foreign, or no session exists.
*/
resolveForStop(explicit: string | undefined): string {
const candidate =
explicit !== undefined && explicit.trim().length > 0 ? explicit : this.current();
if (candidate === undefined) {
throw new Error(
"browser_session_stop needs a session but none is active — start one with browser_session_start " +
"or pass an explicit `session` id",
);
}
if (!this.isOwned(candidate)) {
throw new Error(
`browser_session_stop refuses to stop session "${candidate}": it was not created by this plugin. ` +
"Only sessions returned by browser_session_start can be stopped (use `bsk session stop` yourself for others).",
);
}
return candidate;
}
}