Skip to content

Commit a4e6685

Browse files
committed
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.
1 parent 52aa356 commit a4e6685

13 files changed

Lines changed: 784 additions & 104 deletions

File tree

packages/dsh-plugin-browserskill/README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,14 @@ shell's theme cannot bleed back in.
107107
the dsh `webServer` route seam (dsh 0.1's Typert Remote pipeline is closed to out-of-tree
108108
packages). All commands for one session — tool calls and frame captures alike — run through a
109109
per-session FIFO, because the daemon accepts only one unfinished command per session.
110+
- **Trust model**: these routes expose live screenshots (and an interrupt write), so they
111+
replicate the browser-trust fence dsh applies to its own `/api` routes: the request Host
112+
must be a loopback authority (`localhost`, `127.0.0.0/8`, `[::1]`), a present Origin must
113+
match the Host, `sec-fetch-site: cross-site` is refused, and POST requires an
114+
`application/json` body (cross-site simple requests can never satisfy that). The channel is
115+
therefore built for **loopback-only serving** — binding the dsh web server to `0.0.0.0` and
116+
reaching it through a LAN address will (deliberately) fail the fence; do not put these
117+
routes behind a non-loopback reverse proxy without adding your own authentication.
110118
- Configure with `observationEnabled` / `thumbnailIntervalMs` / `idleIntervalMs`.
111119

112120
## Behavior notes

packages/dsh-plugin-browserskill/src/client/ObservationOverlay.tsx

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -434,10 +434,15 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
434434
const popOut = async (): Promise<void> => {
435435
const pip = pipApi();
436436
if (pip === undefined) return;
437-
const win = await pip.requestWindow({ width: size.w, height: size.h });
438-
cloneStylesInto(win);
439-
win.addEventListener("pagehide", () => setPipWindow(null));
440-
setPipWindow(win);
437+
try {
438+
const win = await pip.requestWindow({ width: size.w, height: size.h });
439+
cloneStylesInto(win);
440+
win.addEventListener("pagehide", () => setPipWindow(null));
441+
setPipWindow(win);
442+
} catch {
443+
// requestWindow rejects without a user gesture (or when the window was
444+
// closed mid-request): stay on the in-page card, no state change.
445+
}
441446
};
442447

443448
// Hidden while no owned session exists (and no PiP is up).
@@ -496,7 +501,28 @@ export function ObservationOverlay({ store }: { store: ObservationClientStore })
496501
data-testid="obs-resize"
497502
aria-label="Resize overlay"
498503
role="separator"
504+
aria-valuenow={size.w}
505+
aria-valuetext={`${Math.round(size.w)} by ${Math.round(size.h)} pixels`}
506+
aria-valuemin={MIN_SIZE.w}
507+
aria-valuemax={Math.round(viewport().w * 0.8)}
508+
tabIndex={0}
499509
onPointerDown={beginDrag("resize")}
510+
onKeyDown={(event) => {
511+
const step = 16;
512+
const delta =
513+
event.key === "ArrowRight"
514+
? { w: step, h: 0 }
515+
: event.key === "ArrowLeft"
516+
? { w: -step, h: 0 }
517+
: event.key === "ArrowDown"
518+
? { w: 0, h: step }
519+
: event.key === "ArrowUp"
520+
? { w: 0, h: -step }
521+
: undefined;
522+
if (delta === undefined) return;
523+
event.preventDefault();
524+
setSize(clampSize({ w: size.w + delta.w, h: size.h + delta.h }, viewport()));
525+
}}
500526
/>
501527
</div>
502528
);

packages/dsh-plugin-browserskill/src/client/observation-store.ts

Lines changed: 71 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
/**
22
* Client-side data layer for the observation overlay: initial state fetch,
3-
* SSE increment stream, on-demand thumbnail blob loading through the
4-
* session-authorized readAttachment path, and the interrupt call. All I/O is
5-
* injected so tests never touch a network.
3+
* SSE increment stream (with a state refetch on every (re)open — events that
4+
* fired while the stream was down are otherwise lost forever), on-demand
5+
* thumbnail blob loading through the session-authorized readAttachment path,
6+
* and the interrupt call. All I/O is injected so tests never touch a network.
67
*/
78

89
import type { ObservationEvent, SessionObservation } from "../observation";
910

1011
export interface EventSourceLike {
1112
onmessage: ((event: { data: string }) => void) | null;
13+
/** Fires on the initial connect AND on every automatic reconnect. */
14+
onopen?: (() => void) | null;
1215
close(): void;
1316
}
1417

@@ -32,7 +35,8 @@ export interface ThumbnailState {
3235

3336
export interface OverlaySnapshot {
3437
readonly sessions: readonly SessionObservation[];
35-
readonly connected: boolean;
38+
/** Whether the SSE increment stream is currently subscribed. */
39+
readonly subscribed: boolean;
3640
readonly thumbnails: Readonly<Record<string, ThumbnailState>>;
3741
/** False when the host reports the browser/daemon as unreachable. */
3842
readonly available: boolean;
@@ -42,14 +46,22 @@ const STATE_URL = "/bsk-observation/state";
4246
const EVENTS_URL = "/bsk-observation/events";
4347
const INTERRUPT_URL = "/bsk-observation/interrupt";
4448

49+
function revoke(url: string | undefined): void {
50+
if (url !== undefined && typeof URL.revokeObjectURL === "function") {
51+
URL.revokeObjectURL(url);
52+
}
53+
}
54+
4555
export class ObservationClientStore {
4656
private sessions = new Map<string, SessionObservation>();
4757
private thumbs = new Map<string, ThumbnailState>();
58+
/** sessionId → the attachment id currently displayed for it (leak guard). */
59+
private readonly thumbBySession = new Map<string, string>();
4860
private listeners = new Set<() => void>();
4961
private events: EventSourceLike | undefined;
5062
private snapshot: OverlaySnapshot = {
5163
sessions: [],
52-
connected: false,
64+
subscribed: false,
5365
thumbnails: {},
5466
available: true,
5567
};
@@ -68,17 +80,15 @@ export class ObservationClientStore {
6880
private publish(): void {
6981
this.snapshot = {
7082
sessions: [...this.sessions.values()],
71-
connected: this.events !== undefined,
83+
subscribed: this.events !== undefined,
7284
thumbnails: Object.fromEntries(this.thumbs),
7385
available: this.available,
7486
};
7587
for (const listener of [...this.listeners]) listener();
7688
}
7789

78-
/** Initial fetch + SSE subscription. Idempotent. */
79-
start(): void {
80-
if (this.started) return;
81-
this.started = true;
90+
/** (Re)pull the full state: initial load and every SSE (re)open. */
91+
private refreshState(): void {
8292
void this.deps
8393
.fetchFn(STATE_URL)
8494
.then(async (res) => {
@@ -87,11 +97,25 @@ export class ObservationClientStore {
8797
sessions?: SessionObservation[];
8898
available?: boolean;
8999
};
90-
this.sessions = new Map((body.sessions ?? []).map((s) => [s.sessionId, s]));
100+
const sessions = body.sessions ?? [];
101+
this.sessions = new Map(sessions.map((s) => [s.sessionId, s]));
102+
// Prune thumbnails of sessions that vanished while we were away.
103+
const alive = new Set(sessions.map((s) => s.sessionId));
104+
for (const sessionId of [...this.thumbBySession.keys()]) {
105+
if (!alive.has(sessionId)) this.dropThumb(sessionId);
106+
}
107+
for (const s of sessions) this.trackThumb(s.sessionId, s.thumbnailAttachmentId);
91108
if (typeof body.available === "boolean") this.available = body.available;
92109
this.publish();
93110
})
94111
.catch(() => {});
112+
}
113+
114+
/** Initial fetch + SSE subscription. Idempotent. */
115+
start(): void {
116+
if (this.started) return;
117+
this.started = true;
118+
this.refreshState();
95119
const events = this.deps.eventSourceFactory(EVENTS_URL);
96120
events.onmessage = (message) => {
97121
let event: ObservationEvent;
@@ -102,6 +126,10 @@ export class ObservationClientStore {
102126
}
103127
this.apply(event);
104128
};
129+
events.onopen = () => {
130+
// Reconnects lose every event fired during the outage — resync.
131+
if (this.events === events) this.refreshState();
132+
};
105133
this.events = events;
106134
this.publish();
107135
}
@@ -110,23 +138,45 @@ export class ObservationClientStore {
110138
this.events?.close();
111139
this.events = undefined;
112140
this.started = false;
113-
for (const thumb of this.thumbs.values()) {
114-
if (thumb.url !== undefined && typeof URL.revokeObjectURL === "function") {
115-
URL.revokeObjectURL(thumb.url);
116-
}
117-
}
141+
for (const thumb of this.thumbs.values()) revoke(thumb.url);
118142
this.thumbs.clear();
143+
this.thumbBySession.clear();
119144
this.sessions.clear();
120145
this.publish();
121146
}
122147

148+
/** Forget one session's tracked thumbnail, revoking its blob URL. */
149+
private dropThumb(sessionId: string): void {
150+
const attachmentId = this.thumbBySession.get(sessionId);
151+
if (attachmentId === undefined) return;
152+
this.thumbBySession.delete(sessionId);
153+
revoke(this.thumbs.get(attachmentId)?.url);
154+
this.thumbs.delete(attachmentId);
155+
}
156+
157+
/**
158+
* Track the frame a session currently shows; replacing a frame revokes the
159+
* old blob URL immediately — blob URLs must not accumulate one per frame.
160+
*/
161+
private trackThumb(sessionId: string, attachmentId: string | undefined): void {
162+
const previous = this.thumbBySession.get(sessionId);
163+
if (previous === attachmentId) return;
164+
this.dropThumb(sessionId);
165+
if (attachmentId !== undefined) this.thumbBySession.set(sessionId, attachmentId);
166+
}
167+
123168
private apply(event: ObservationEvent): void {
124169
if (event.type === "reset") {
125170
this.sessions.clear();
171+
for (const thumb of this.thumbs.values()) revoke(thumb.url);
172+
this.thumbs.clear();
173+
this.thumbBySession.clear();
126174
} else if (event.type === "remove" && event.session !== undefined) {
127175
this.sessions.delete(event.session.sessionId);
176+
this.dropThumb(event.session.sessionId);
128177
} else if (event.type === "upsert" && event.session !== undefined) {
129178
this.sessions.set(event.session.sessionId, event.session);
179+
this.trackThumb(event.session.sessionId, event.session.thumbnailAttachmentId);
130180
} else if (event.type === "availability") {
131181
this.available = event.available;
132182
}
@@ -144,6 +194,11 @@ export class ObservationClientStore {
144194
this.publish();
145195
this.deps.loadImage(attachmentId).then(
146196
(url) => {
197+
if (!this.thumbs.has(attachmentId)) {
198+
// Replaced or removed while loading: never resurrect (or leak) it.
199+
revoke(url);
200+
return;
201+
}
147202
this.thumbs.set(attachmentId, { status: "ready", url });
148203
this.publish();
149204
},

packages/dsh-plugin-browserskill/src/index.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,8 +90,10 @@ export function apply(
9090
});
9191

9292
// Non-blocking install probe: warn early when bsk is missing instead of
93-
// failing the first tool call with a bare spawn error.
94-
runner.run(["status"], { timeoutMs: 10_000 }).then(
93+
// failing the first tool call with a bare spawn error. Uses --version on
94+
// purpose — it answers without starting the daemon (`bsk status` would
95+
// ensure-spawn one, an expensive side effect for a probe).
96+
runner.run(["--version"], { timeoutMs: 10_000 }).then(
9597
() => {},
9698
(error: unknown) => {
9799
const detail = error instanceof Error ? error.message : String(error);

packages/dsh-plugin-browserskill/src/observation-http.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,56 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void {
3535
res.end(JSON.stringify(body));
3636
}
3737

38+
/**
39+
* Browser-trust fence, mirroring the one dsh applies to its /api routes (ours
40+
* live outside that prefix, so the checks are replicated here):
41+
* - Host must be a loopback authority (localhost / 127.0.0.0/8 / [::1]) — the
42+
* observation channel exposes live screenshots and must never answer a LAN
43+
* or DNS-rebound name;
44+
* - a present Origin must be same-host with the Host header (blocks cross-site
45+
* reads), and `sec-fetch-site: cross-site` is refused outright;
46+
* - POST must be `application/json` — anything a cross-site *simple request*
47+
* can send (form/plain) never reaches the handler, which kills CSRF.
48+
*/
49+
function fenceViolation(req: IncomingMessage): string | undefined {
50+
const host = req.headers.host ?? "";
51+
const hostname = /^\[.*\](?::\d+)?$/.test(host)
52+
? host.slice(1, host.indexOf("]"))
53+
: host.split(":")[0];
54+
const isLoopback =
55+
hostname === "localhost" ||
56+
hostname.endsWith(".localhost") ||
57+
hostname === "::1" ||
58+
/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname);
59+
if (!isLoopback) return "host is not a loopback authority";
60+
const origin = req.headers.origin;
61+
if (origin !== undefined && origin !== "null") {
62+
let originHost: string | undefined;
63+
try {
64+
originHost = new URL(origin).host;
65+
} catch {
66+
return "unparseable Origin header";
67+
}
68+
if (originHost !== host) return "Origin does not match Host";
69+
}
70+
if (req.headers["sec-fetch-site"] === "cross-site") return "sec-fetch-site: cross-site";
71+
if (req.method === "POST") {
72+
const contentType = req.headers["content-type"] ?? "";
73+
if (!/^\s*application\/json\s*(;|$)/.test(contentType)) {
74+
return "POST requires an application/json body";
75+
}
76+
}
77+
return undefined;
78+
}
79+
80+
/** Run the fence; returns true when the request was rejected (handled). */
81+
function fenceRejected(req: IncomingMessage, res: ServerResponse): boolean {
82+
const violation = fenceViolation(req);
83+
if (violation === undefined) return false;
84+
sendJson(res, 403, { error: `forbidden: ${violation}` });
85+
return true;
86+
}
87+
3888
/**
3989
* Register the observation routes. No-op (with a console note) when the
4090
* composition has no web server.
@@ -57,6 +107,7 @@ export function registerObservationRoutes(
57107
sendJson(res, 405, { error: "method not allowed" });
58108
return;
59109
}
110+
if (fenceRejected(req, res)) return;
60111
sendJson(res, 200, {
61112
sessions: observation.getState(),
62113
available: observation.isAvailable(),
@@ -71,6 +122,7 @@ export function registerObservationRoutes(
71122
sendJson(res, 405, { error: "method not allowed" });
72123
return;
73124
}
125+
if (fenceRejected(req, res)) return;
74126
res.writeHead(200, {
75127
"content-type": "text/event-stream",
76128
"cache-control": "no-cache",
@@ -80,7 +132,7 @@ export function registerObservationRoutes(
80132
res.write(`data: ${JSON.stringify(event)}\n\n`);
81133
});
82134
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
83-
req.on("close", () => {
135+
res.on("close", () => {
84136
clearInterval(heartbeat);
85137
unsubscribe();
86138
});
@@ -94,6 +146,7 @@ export function registerObservationRoutes(
94146
sendJson(res, 405, { error: "method not allowed" });
95147
return;
96148
}
149+
if (fenceRejected(req, res)) return;
97150
let body = "";
98151
req.on("data", (chunk: Buffer | string) => {
99152
body += chunk;
@@ -121,6 +174,7 @@ export function registerObservationRoutes(
121174
sendJson(res, 405, { error: "method not allowed" });
122175
return;
123176
}
177+
if (fenceRejected(req, res)) return;
124178
const pathname = decodeURIComponent(new URL(req.url ?? "/", "http://x").pathname);
125179
const attachmentId = pathname.slice(`${ROUTE_BASE}/thumbnail/`.length);
126180
const frame = await observation.readThumbnail(attachmentId);

0 commit comments

Comments
 (0)