diff --git a/docs/decisions.md b/docs/decisions.md index 538bf228..bc68e245 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -7189,3 +7189,31 @@ Platform release and Matěj's check in a browser on his Environment (root decisi shows it, a browser restart with no view open, an app's server that starts with ids that ended, a tab recorded before the list has it, a live tab whose event reaches the hub late, a tab that ended before the app recorded it, a record from an earlier boot, and a step of the wall clock. + +**Step 1, implemented in this revision (2026-10-11).** +- `src/browser/people/holds.ts` reads the holder files and the agents' bindings with their + daemons (point 5), and writes and reads the list of live tabs (point 4). It also gives the + apps' rule for reconciling with that list (`keptTabs`: an id goes only when a list of the + current boot, taken after its record, lacks it) and the boot clock (`readBootClock`), which + steps 2 and 4 use. +- The hub (`hub.ts`): + - reviews every page target on each change of viewers or holds, with the three graces of + point 5; + - re-reads the holds before it closes a tab, and keeps the tab when that read fails. Only a + directory or file that does not exist reads as holding nothing; any other failure, and a + holder file that is not one, keeps the last reading; + - keeps its blank window, which is in no list; + - writes the list of live tabs from a fresh `Target.getTargets` snapshot, stamped with the + boot-clock time the snapshot began, after each discovery, on every change and every 30 s + while it is connected, one snapshot at a time. +- The service answers the four loopback requests of point 6. It refuses them through the + gateway (404) and to any request with `Origin` or `Sec-Fetch-Site` (403). While the hub does + not know the browser's targets, close and restore answer 503; a close accepted before goes + on after the reconnect. +- The view page posts `lazurio-browser:closed` to its app when its tab ends. +- `lazurio browser serve` creates the holder directory (0700) and watches it and + agent-browser's socket directory. + +Until T3 Code writes its holder files (step 4), a T3 Code thread's window stays while its agent +works in it or someone views it. Otherwise it ends about an hour after the agent's last +command. The Folder manual's `tab_gone` recovery already covers that. diff --git a/src/browser/people/client.ts b/src/browser/people/client.ts index 5ab89368..954a0dbe 100644 --- a/src/browser/people/client.ts +++ b/src/browser/people/client.ts @@ -1282,6 +1282,8 @@ function tabClosed(): void { for (const control of [back, forward, reload, address]) control.disabled = true; showEnd(say.closed, say.openNewTab, () => location.assign("/")); + // The app around the view drops the tab from its strip (F47 point 6). + tell({ type: "lazurio-browser:closed", view: location.href }); } /** A new remote tab at the page area's size (F39 point 1); null when the diff --git a/src/browser/people/holds.ts b/src/browser/people/holds.ts new file mode 100644 index 00000000..78c09154 --- /dev/null +++ b/src/browser/people/holds.ts @@ -0,0 +1,373 @@ +import { randomBytes } from "node:crypto"; +import { type FSWatcher, watch } from "node:fs"; +import { + mkdir, + readdir, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import { dirname, isAbsolute, join } from "node:path"; + +/** Who keeps a remote tab of the Environment browser (decision F47 points 4 + * and 5), read by the view's hub, which alone ends a tab: + * - the places that hold it: one file per strip that the app's server writes, + * `$XDG_RUNTIME_DIR/lazurio-browser/holders/.json` = + * `{"strip", "label", "tabs": […]}` (a T3 Code thread, the + * Launchpad); + * - the agents that work in it: an agent-browser session bound to it + * (`/.target`) whose daemon runs (`.pid` + * names a live process). The binding alone does not count: agent-browser + * keeps it past the daemon on purpose, and the daemon ends after an hour + * without a command. */ + +export type Hold = Readonly<{ strip: string; label: string }>; + +export type Holds = Readonly<{ + /** The strips that hold each target. */ + holders: ReadonlyMap; + /** The sessions whose agent works in each target: bound, daemon running. */ + working: ReadonlyMap; + /** Every session bound to each target, its daemon running or not: who + * opened an agent's window. */ + bound: ReadonlyMap; +}>; + +export const noHolds: Holds = Object.freeze({ + holders: new Map(), + working: new Map(), + bound: new Map(), +}); + +/** A strip's name is a file name: agent-browser's session alphabet, as T3 + * Code's `t3-` and the Launchpad's `launchpad` are. */ +const stripPattern = /^[A-Za-z0-9_-]{1,64}$/; +const targetIdPattern = /^[0-9A-Fa-f]{32}$/; +const maxHolderBytes = 64 * 1024; +const maxTabsPerHolder = 500; +const maxLabelLength = 200; + +export function isStrip(value: unknown): value is string { + return typeof value === "string" && stripPattern.test(value); +} + +/** `$XDG_RUNTIME_DIR/lazurio-browser/holders`, where the apps' servers write + * their holds; undefined without an absolute runtime directory. */ +export function holderDirectory( + env: Readonly>, +): string | undefined { + const runtime = env.XDG_RUNTIME_DIR; + return runtime !== undefined && isAbsolute(runtime) + ? join(runtime, "lazurio-browser", "holders") + : undefined; +} + +/** `$XDG_RUNTIME_DIR/lazurio-browser/tabs.json`: the hub's list of the + * browser's live page targets, which every app's server reconciles its holds + * with (F47 point 4). */ +export function liveTabsPath( + env: Readonly>, +): string | undefined { + const runtime = env.XDG_RUNTIME_DIR; + return runtime !== undefined && isAbsolute(runtime) + ? join(runtime, "lazurio-browser", "tabs.json") + : undefined; +} + +/** A time on the Environment's boot clock (F47 point 4): the boot's id and + * the seconds since it. Unlike the wall clock it never steps back, and every + * process of the Environment reads the same one, so it orders an app's record + * against the hub's snapshot. */ +export type BootTime = Readonly<{ boot: string; at: number }>; + +const bootIdPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** Reads the boot clock: `/proc/sys/kernel/random/boot_id` and the first + * number of `/proc/uptime` (CLOCK_BOOTTIME). Null where there is none: not + * Linux, where no Environment browser runs. */ +export async function readBootClock( + read: (path: string) => Promise = (path) => readFile(path, "utf8"), +): Promise { + try { + const boot = (await read("/proc/sys/kernel/random/boot_id")).trim(); + const at = Number.parseFloat( + (await read("/proc/uptime")).trim().split(/\s+/)[0] ?? "", + ); + return bootIdPattern.test(boot) && Number.isFinite(at) && at >= 0 + ? { boot, at } + : null; + } catch { + return null; + } +} + +/** A boot-clock time as it is stored, or null when the value is not one. */ +export function parseBootTime(value: unknown): BootTime | null { + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + return typeof record.boot === "string" && + bootIdPattern.test(record.boot) && + typeof record.at === "number" && + Number.isFinite(record.at) && + record.at >= 0 + ? { boot: record.boot, at: record.at } + : null; +} + +/** One snapshot of the browser's page targets: `takenAt` is the boot-clock + * time the hub began it, before it asked the browser. */ +export type LiveTabs = Readonly<{ tabs: readonly string[]; takenAt: BootTime }>; + +/** Writes the list atomically: a reader sees the old list or the new one. */ +export async function writeLiveTabs( + path: string, + tabs: readonly string[], + takenAt: BootTime, +): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + const temporary = `${path}.${randomBytes(6).toString("hex")}.tmp`; + try { + await writeFile(temporary, `${JSON.stringify({ tabs, takenAt })}\n`, { + mode: 0o600, + }); + await rename(temporary, path); + } catch (error) { + await rm(temporary, { force: true }).catch(() => undefined); + throw error; + } +} + +/** The list a reader finds, or null when the file is not one. */ +export function parseLiveTabs(text: string): LiveTabs | null { + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + const takenAt = parseBootTime(record.takenAt); + if (!Array.isArray(record.tabs) || takenAt === null) return null; + if ( + !record.tabs.every( + (id): id is string => typeof id === "string" && targetIdPattern.test(id), + ) + ) + return null; + return { + tabs: record.tabs.map((id) => (id as string).toUpperCase()), + takenAt, + }; +} + +/** An app's reconciliation (F47 point 4): the ids it keeps from its stored + * ones, `boot` being the current boot. A list of another boot is ignored. + * An id the list lacks goes when the list was taken after the app recorded + * it: a snapshot lists every target that exists when it begins, so such an + * id had ended by then, while a tab recorded after the snapshot began stays. + * A record from an earlier boot, or without a time, is older than every list + * of this boot. Without a list, or without the boot clock, it keeps + * everything. */ +export function keptTabs( + stored: readonly Readonly<{ id: string; recordedAt: BootTime | null }>[], + list: LiveTabs | null, + boot: string | null, +): string[] { + if (list === null || boot === null || list.takenAt.boot !== boot) + return stored.map((tab) => tab.id); + const live = new Set(list.tabs); + return stored + .filter( + (tab) => + live.has(tab.id.toUpperCase()) || + (tab.recordedAt !== null && + tab.recordedAt.boot === boot && + tab.recordedAt.at >= list.takenAt.at), + ) + .map((tab) => tab.id); +} + +/** The hold one holder file records, or null when the file is not one: its + * `strip` must be its file name, and only target ids count. */ +export function parseHolder( + fileName: string, + text: string, +): Readonly<{ strip: string; label: string; tabs: readonly string[] }> | null { + if (!fileName.endsWith(".json")) return null; + const name = fileName.slice(0, -".json".length); + if (!isStrip(name) || text.length > maxHolderBytes) return null; + let value: unknown; + try { + value = JSON.parse(text); + } catch { + return null; + } + if (typeof value !== "object" || value === null) return null; + const record = value as Record; + if (record.strip !== name || !Array.isArray(record.tabs)) return null; + const label = + typeof record.label === "string" + ? record.label.trim().slice(0, maxLabelLength) + : ""; + const tabs = [ + ...new Set( + record.tabs + .filter( + (id): id is string => + typeof id === "string" && targetIdPattern.test(id), + ) + .map((id) => id.toUpperCase()), + ), + ].slice(0, maxTabsPerHolder); + return { strip: name, label, tabs }; +} + +/** True while a process with this id runs (one of another user counts too, + * as `kill -0` answers EPERM for it). */ +export function processAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid < 2) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +const add = (map: Map, key: string, value: string) => { + const list = map.get(key); + if (list === undefined) map.set(key, [value]); + else if (!list.includes(value)) list.push(value); +}; + +/** True for "no such file or directory": the one read failure that means + * nothing is there. */ +const missing = (error: unknown) => + (error as NodeJS.ErrnoException | null)?.code === "ENOENT"; + +/** A directory's names; one that does not exist is empty (no app wrote a + * hold, no agent ran). Any other failure is thrown. */ +async function names(directory: string): Promise { + try { + return (await readdir(directory)).sort(); + } catch (error) { + if (missing(error)) return []; + throw error; + } +} + +/** A file's text; null when it is gone (removed between the listing and the + * read). Any other failure is thrown. */ +async function text(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (missing(error)) return null; + throw error; + } +} + +/** Reads both directories, failing closed (F47 point 5): only a directory or + * file that does not exist reads as holding nothing. Any other failure (a + * permission, an I/O error) and a holder file that is not one are thrown, so + * the hub keeps its last reading and no tab ends for a read it could not + * make. A holder file is replaced atomically by its app, so a broken one is + * a fault to see, not a release. Names that are no strip's file (an app's + * temporary file) are skipped. An agent-browser session counts as ended only + * when its `.pid` is gone or names a well-formed process id that no longer + * runs; one that cannot be understood (empty, signed, zero) counts as + * working, which keeps its tab. */ +export async function readHolds( + directories: Readonly<{ + holders?: string | undefined; + sockets?: string | undefined; + }>, + alive: (pid: number) => boolean = processAlive, +): Promise { + const holders = new Map(); + const working = new Map(); + const bound = new Map(); + if (directories.holders !== undefined) { + const directory = directories.holders; + for (const name of await names(directory)) { + if (!name.endsWith(".json") || !isStrip(name.slice(0, -".json".length))) + continue; + const content = await text(join(directory, name)); + if (content === null) continue; + const hold = parseHolder(name, content); + if (hold === null) throw new Error(`unreadable holder file ${name}`); + for (const id of hold.tabs) { + const list = holders.get(id); + const entry = { strip: hold.strip, label: hold.label }; + if (list === undefined) holders.set(id, [entry]); + else list.push(entry); + } + } + } + if (directories.sockets !== undefined) { + const directory = directories.sockets; + for (const name of await names(directory)) { + if (!name.endsWith(".target")) continue; + const session = name.slice(0, -".target".length); + if (!isStrip(session)) continue; + const content = await text(join(directory, name)); + if (content === null) continue; + let targetId: unknown; + try { + targetId = (JSON.parse(content) as { targetId?: unknown } | null) + ?.targetId; + } catch { + throw new Error(`unreadable binding file ${name}`); + } + if (typeof targetId !== "string" || !targetIdPattern.test(targetId)) + continue; + const id = targetId.toUpperCase(); + add(bound, id, session); + const pidText = await text(join(directory, `${session}.pid`)); + if (pidText === null) continue; + // Only a well-formed process id proven ended releases the tab; an + // empty, signed or zero one is unclear, and unclear keeps it. + const digits = pidText.trim(); + const pid = /^[0-9]{1,10}$/.test(digits) ? Number(digits) : Number.NaN; + if (!(Number.isSafeInteger(pid) && pid >= 2) || alive(pid)) + add(working, id, session); + } + } + return { holders, working, bound }; +} + +/** Calls `changed` whenever something in one of the directories changes. + * A directory that does not exist yet is watched once it does: `retry` is + * called by the hub on its periodic read. */ +export function watchHolds( + directories: readonly (string | undefined)[], + changed: () => void, +): Readonly<{ retry: () => void; stop: () => void }> { + const watchers = new Map(); + const retry = () => { + for (const directory of directories) { + if (directory === undefined || watchers.has(directory)) continue; + try { + const watcher = watch(directory, { persistent: false }, () => + changed(), + ); + watcher.on("error", () => { + watcher.close(); + watchers.delete(directory); + }); + watchers.set(directory, watcher); + } catch {} + } + }; + retry(); + return Object.freeze({ + retry, + stop: () => { + for (const watcher of watchers.values()) watcher.close(); + watchers.clear(); + }, + }); +} diff --git a/src/browser/people/hub.ts b/src/browser/people/hub.ts index 3867f70e..1c298975 100644 --- a/src/browser/people/hub.ts +++ b/src/browser/people/hub.ts @@ -1,14 +1,8 @@ import { randomBytes } from "node:crypto"; -import { - mkdir, - readdir, - readFile, - rm, - stat, - writeFile, -} from "node:fs/promises"; +import { mkdir, readdir, rm, stat, writeFile } from "node:fs/promises"; import { basename, join } from "node:path"; import type { CdpConnection, CdpEvent } from "./cdp"; +import { type BootTime, type Hold, type Holds, noHolds } from "./holds"; import { type ClientMessage, encodeFrame, @@ -21,8 +15,10 @@ import { /** The view service's hold on the Environment browser (decision F39): every * page target with one DevTools session each, the people who view which tab, * and what the view does for them (frames, input, pop-ups, dialogs, file - * choosers, closing an unused tab). One hub per service; it reconnects - * whenever the browser goes away. */ + * choosers). It alone decides when a remote tab ends (F47 point 5): a tab + * that a strip holds or an agent works in stays; one that nothing holds or + * views ends after a grace. One hub per service; it reconnects whenever the + * browser goes away. */ /** One person's socket on one tab. */ export type Viewer = { @@ -38,19 +34,68 @@ export type Viewer = { export type HubSeams = Readonly<{ /** Opens a DevTools connection to the browser target on loopback. */ connect: () => Promise; - /** The target ids agent-browser sessions are bound to right now. */ - boundTargets: () => Promise>; + /** Who holds which tab right now: the holder files and the agents that + * work in a tab (F47 points 4 and 5). */ + holds: () => Promise; + /** Calls `changed` when the holds may have changed. `retry` is called on + * every periodic read, for a directory that did not exist before. */ + watchHolds?: ( + changed: () => void, + ) => Readonly<{ retry: () => void; stop: () => void }>; + /** Writes the list of the browser's live page targets that the apps + * reconcile their holds with (F47 point 4): one snapshot, `takenAt` being + * the boot-clock time the hub began it. */ + writeTabs?: (ids: readonly string[], takenAt: BootTime) => Promise; + /** The Environment's boot clock, read before each snapshot; null where + * there is none, and then no list is written. */ + bootClock?: () => Promise; /** Where uploaded files wait until the page reads them. */ uploadDirectory: string; /** The virtual screen; no window grows past it. */ screen: Readonly<{ width: number; height: number }>; log: (line: string) => void; - /** Overridable for tests. */ + /** Overridable for tests: after the last viewer of a tab nothing holds, + * for a tab nobody has viewed or held yet, after the last hold or a close + * for everyone (the window of Vrátit), between reads of the holds. */ graceMs?: number; unviewedGraceMs?: number; + releaseGraceMs?: number; + holdsEveryMs?: number; reconnectMs?: number; }>; +/** Who opened a tab, as far as the hub knows: the people's view (`/`), Nová + * karta (through the loopback request), a page (its new tab or pop-up), an + * agent (a session bound to it), or nobody it knows of (a tab from before + * the service started). */ +export type TabOpener = + | Readonly<{ kind: "view" }> + | Readonly<{ kind: "new-tab"; strip: string | null }> + | Readonly<{ kind: "page"; openerId: string }> + | Readonly<{ kind: "agent"; session: string }> + | Readonly<{ kind: "unknown" }>; + +/** Why a tab nothing holds or views is counting down (F47 point 5). */ +export type GraceKind = "release" | "viewer" | "unviewed"; + +/** One remote tab as the loopback list reports it (F47 point 6). */ +export type TabFacts = Readonly<{ + id: string; + url: string; + title: string; + favicon: string | null; + openedAt: string; + usedAt: string; + opener: TabOpener; + /** The sessions whose agent works in it now. */ + agents: readonly string[]; + holders: readonly Hold[]; + viewers: number; + /** When a close for everyone ends it, unless restored. */ + closing: string | null; + grace: Readonly<{ kind: GraceKind; endsAt: string }> | null; +}>; + type Info = { url: string; title: string; @@ -80,6 +125,13 @@ type Tab = { resizeTimer: ReturnType | null; infoTimer: ReturnType | null; graceTimer: ReturnType | null; + grace: Readonly<{ kind: GraceKind; endsAt: number }> | null; + /** What kept the tab at the last review: a hold, a viewer, or nothing. */ + state: "held" | "viewed" | "idle"; + opener: TabOpener; + openedAt: number; + /** Last viewed, acted in or navigated. */ + usedAt: number; watched: boolean; attaching: boolean; /** The view's isolated world in the main frame, until it navigates. */ @@ -197,22 +249,49 @@ export class BrowserHub { private reconnectTimer: ReturnType | null = null; private readonly tabs = new Map(); private readonly bySession = new Map(); - /** Tabs the view opened or a page opened: the only tabs the view ever - * closes, and only while no agent-browser session is bound to them (F39 - * point 9). An agent's own window is never one of them. Lost on restart, - * on purpose. */ - private readonly ownTabs = new Set(); private readonly fileChoosers = new Map(); /** Page targets discovery reported on the current connection. */ private seen = new Set(); private discovered = false; + /** The last read of the holder files and the agents' bindings. A read + * that fails keeps the previous one, so a failure never lets a tab go. */ + private holds: Holds = noHolds; + private holdsTimer: ReturnType | null = null; + private holdsSoon: ReturnType | null = null; + private tabsSoon: ReturnType | null = null; + /** A snapshot of the targets is being taken; another is wanted after it. */ + private snapshotting = false; + private snapshotAgain = false; + private watcher: Readonly<{ retry: () => void; stop: () => void }> | null = + null; + /** Closes for everyone that wait for their grace (F47 point 6), kept apart + * from the tabs so a reconnect to the browser does not lose them. */ + private readonly closing = new Map< + string, + { endsAt: number; timer: ReturnType | null } + >(); + /** Who opened each tab and when, kept across a reconnect to the same + * browser. Lost when the service restarts: the tabs from before are then + * the opener's "unknown", or an agent's by its binding. */ + private readonly origins = new Map< + string, + Readonly<{ opener: TabOpener; openedAt: number }> + >(); + /** The hub's own blank window: Chrome ends with its last window and the + * unit starts it again, so one window always stays (F47 point 5). */ + private keeperId: string | null = null; + private keeperCreating = false; private readonly graceMs: number; private readonly unviewedGraceMs: number; + private readonly releaseGraceMs: number; + private readonly holdsEveryMs: number; private readonly reconnectMs: number; constructor(private readonly seams: HubSeams) { this.graceMs = seams.graceMs ?? 30_000; this.unviewedGraceMs = seams.unviewedGraceMs ?? 600_000; + this.releaseGraceMs = seams.releaseGraceMs ?? 8_000; + this.holdsEveryMs = seams.holdsEveryMs ?? 30_000; this.reconnectMs = seams.reconnectMs ?? 2_000; } @@ -228,39 +307,81 @@ export class BrowserHub { start(): void { this.stopped = false; + this.watcher = + this.seams.watchHolds?.(() => this.holdsChanged()) ?? this.watcher; + // The holds are read and the list of live tabs written again on this + // beat too: a record an app made after its tab ended meets a newer list + // (F47 point 4). + this.holdsTimer = setInterval(() => { + this.watcher?.retry(); + void this.refreshHolds(); + this.publishTabs(); + }, this.holdsEveryMs); void this.connect(); } stop(): void { this.stopped = true; if (this.reconnectTimer !== null) clearTimeout(this.reconnectTimer); + if (this.holdsTimer !== null) clearInterval(this.holdsTimer); + if (this.holdsSoon !== null) clearTimeout(this.holdsSoon); + if (this.tabsSoon !== null) clearTimeout(this.tabsSoon); + this.tabsSoon = null; + this.holdsTimer = null; + this.holdsSoon = null; + this.watcher?.stop(); + this.watcher = null; this.cdp?.close(); this.cdp = null; for (const tab of this.tabs.values()) this.clearTimers(tab); + for (const entry of this.closing.values()) + if (entry.timer !== null) clearTimeout(entry.timer); + this.closing.clear(); } - /** A new window of the default context, at the person's size when given. - * Null when the browser cannot be reached. */ + /** A new window of the default context, at the person's size when given: + * the people's view's new tab (F39 point 1). Null when the browser cannot + * be reached. */ async createTab(size: ViewSize | null): Promise { + const fitted = size === null ? null : this.windowSize(clampSize(size)); + return this.newWindow( + "about:blank", + fitted === null ? {} : { width: fitted.width, height: fitted.height }, + { kind: "view" }, + ); + } + + /** A new window at an address, for Nová karta (F47 points 3 and 6): the + * remote tab exists only from now. Null when the address is not one the + * address bar may open or the browser cannot be reached. */ + async openTab(url: string, strip: string | null): Promise { + const address = navigableAddress(url); + if (address === null) return null; + return this.newWindow(address, {}, { kind: "new-tab", strip }); + } + + private async newWindow( + url: string, + size: Readonly<{ width?: number; height?: number }>, + opener: TabOpener, + ): Promise { const cdp = this.cdp; if (cdp === null) return null; - const fitted = size === null ? null : this.windowSize(clampSize(size)); try { const result = await cdp.send("Target.createTarget", { - url: "about:blank", + url, newWindow: true, - ...(fitted === null - ? {} - : { width: fitted.width, height: fitted.height }), + ...size, }); const targetId = String(result.targetId); - this.ownTabs.add(targetId); const deadline = Date.now() + 2_000; while (!this.tabs.has(targetId) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 20)); const tab = this.tabs.get(targetId); if (tab === undefined) return null; - if (tab.viewers.size === 0) this.scheduleGrace(tab, this.unviewedGraceMs); + tab.opener = opener; + this.origins.set(targetId, { opener, openedAt: tab.openedAt }); + this.review(tab); return targetId; } catch (error) { this.seams.log(`create tab failed: ${String(error)}`); @@ -268,6 +389,77 @@ export class BrowserHub { } } + /** Every remote tab but the hub's own blank window, as the loopback list + * reports it (F47 point 6). */ + list(): TabFacts[] { + const iso = (ms: number) => new Date(ms).toISOString(); + const facts: TabFacts[] = []; + for (const tab of this.tabs.values()) { + if (tab.targetId === this.keeperId) continue; + const closing = this.closing.get(tab.targetId); + const session = this.holds.bound.get(tab.targetId)?.[0]; + facts.push({ + id: tab.targetId, + url: tab.info.url, + title: tab.info.title, + favicon: tab.info.favicon, + openedAt: iso(tab.openedAt), + usedAt: iso(tab.usedAt), + opener: + tab.opener.kind === "unknown" && session !== undefined + ? { kind: "agent", session } + : tab.opener, + agents: this.holds.working.get(tab.targetId) ?? [], + holders: this.holds.holders.get(tab.targetId) ?? [], + viewers: tab.viewers.size, + closing: closing === undefined ? null : iso(closing.endsAt), + grace: + tab.grace === null + ? null + : { kind: tab.grace.kind, endsAt: iso(tab.grace.endsAt) }, + }); + } + return facts; + } + + /** Closes a tab for everyone after the grace of Vrátit, held or not (F47 + * point 6). The time it ends; null for a tab the hub does not list. */ + closeForEveryone(targetId: string): number | null { + const tab = this.tabs.get(targetId); + if (tab === undefined || targetId === this.keeperId) return null; + const pending = this.closing.get(targetId); + if (pending !== undefined) return pending.endsAt; + this.clearGrace(tab); + const endsAt = Date.now() + this.releaseGraceMs; + this.closing.set(targetId, { + endsAt, + timer: setTimeout( + () => void this.finishClose(targetId), + this.releaseGraceMs, + ), + }); + this.seams.log(`closing tab ${targetId} for everyone`); + return endsAt; + } + + /** Vrátit: cancels a close for everyone or a grace. A tab that nothing + * holds or views then has as long as a new one to be viewed or held. False + * when the tab is gone or nothing was counting down. */ + restore(targetId: string): boolean { + const tab = this.tabs.get(targetId); + if (tab === undefined) return false; + const pending = this.closing.get(targetId); + if (pending !== undefined) { + if (pending.timer !== null) clearTimeout(pending.timer); + this.closing.delete(targetId); + } else if (tab.grace === null) return false; + this.clearGrace(tab); + tab.state = "idle"; + this.review(tab, "unviewed"); + this.seams.log(`restored tab ${targetId}`); + return true; + } + hasTab(targetId: string): boolean { return this.tabs.has(targetId); } @@ -277,10 +469,8 @@ export class BrowserHub { const tab = this.tabs.get(targetId); if (tab === undefined) return false; tab.viewers.add(viewer); - if (tab.graceTimer !== null) { - clearTimeout(tab.graceTimer); - tab.graceTimer = null; - } + tab.usedAt = Date.now(); + this.review(tab); viewer.send({ t: "browser", state: @@ -302,12 +492,13 @@ export class BrowserHub { if (tab === undefined) return; tab.viewers.delete(viewer); if (tab.lastActor === viewer) tab.lastActor = null; + tab.usedAt = Date.now(); if (tab.viewers.size > 0) { void this.updateScreencast(tab); return; } void this.unwatch(tab); - if (this.ownTabs.has(targetId)) this.scheduleGrace(tab, this.graceMs); + this.review(tab); } /** Called when a viewer's socket drained, to release a held frame. */ @@ -538,9 +729,32 @@ export class BrowserHub { // A tab kept across a reconnect whose page did not come back is gone. for (const id of [...this.tabs.keys()]) if (!this.seen.has(id)) this.gone(id); - for (const id of [...this.ownTabs]) - if (!this.seen.has(id)) this.ownTabs.delete(id); + for (const [id, entry] of [...this.closing]) + if (!this.seen.has(id)) { + if (entry.timer !== null) clearTimeout(entry.timer); + this.closing.delete(id); + } + // Ready once the holds are read too, so a tab is never judged by an + // empty read. + await this.refreshHolds(); + if (this.cdp !== cdp) return; this.discovered = true; + this.publishTabs(); + if (this.keeperId !== null && !this.tabs.has(this.keeperId)) + this.keeperId = null; + // A close for everyone whose grace ran out while the browser was away. + for (const [id, entry] of this.closing) + if (entry.timer === null) void this.finishClose(id); + if (this.keeperId === null) { + // The window Chrome started with, if nothing uses it. + for (const tab of this.tabs.values()) + if (this.blank(tab) && tab.viewers.size === 0) { + this.keeperId = tab.targetId; + this.clearGrace(tab); + break; + } + } + void this.ensureKeeper(); } private retry(): void { @@ -615,6 +829,17 @@ export class BrowserHub { const title = String(info.title ?? ""); if (tab === undefined) { const openerId = typeof info.openerId === "string" ? info.openerId : null; + // Kept across a reconnect to the same browser; a new target is new. + // An agent's window is named by its binding when the list is read. + const now = Date.now(); + const origin = this.origins.get(targetId) ?? { + opener: + openerId !== null + ? ({ kind: "page", openerId } as const) + : ({ kind: "unknown" } as const), + openedAt: now, + }; + this.origins.set(targetId, origin); tab = { targetId, sessionId: null, @@ -640,6 +865,11 @@ export class BrowserHub { resizeTimer: null, infoTimer: null, graceTimer: null, + grace: null, + state: "idle", + opener: origin.opener, + openedAt: origin.openedAt, + usedAt: now, watched: false, attaching: false, world: null, @@ -649,17 +879,28 @@ export class BrowserHub { metaTimer: null, }; this.tabs.set(targetId, tab); + this.publishTabs(); if (created && openerId !== null) this.opened(tab, openerId); - if (this.ownTabs.has(targetId) && tab.viewers.size === 0) - this.scheduleGrace(tab, this.unviewedGraceMs); + this.review(tab); } else { // Chrome's target title is only the address (see metaScript): it is // taken with a new address, and the page's own title follows. - if (url !== tab.info.url) tab.info.title = title; + if (url !== tab.info.url) { + tab.info.title = title; + tab.usedAt = Date.now(); + } tab.info.url = url; this.queueInfo(tab); if (tab.pendingOpen !== null && url !== "" && url !== "about:blank") this.announce(tab, tab.pendingOpen.openerId); + if (targetId === this.keeperId && !this.blank(tab)) { + // Someone used the hub's blank window: it is a tab like any other + // now, and the hub opens another. + this.keeperId = null; + this.review(tab); + this.publishTabs(); + void this.ensureKeeper(); + } } this.seen.add(targetId); if (tab.sessionId === null) void this.attachSession(tab); @@ -670,10 +911,6 @@ export class BrowserHub { * address, so it is announced at its first address, or after 1.5 s. */ private opened(tab: Tab, openerId: string): void { if (!this.tabs.has(openerId)) return; - // A page's new tab or pop-up is nobody's working window unless an agent - // binds a session to it: it closes when nobody views it (F39 point 9). - this.ownTabs.add(tab.targetId); - this.scheduleGrace(tab, this.unviewedGraceMs); if (tab.info.url !== "" && tab.info.url !== "about:blank") { this.announce(tab, openerId); return; @@ -1070,6 +1307,7 @@ export class BrowserHub { // ---- Window size --------------------------------------------------------- private acted(tab: Tab, viewer: Viewer): void { + tab.usedAt = Date.now(); if (!viewer.resize || tab.lastActor === viewer) return; tab.lastActor = viewer; this.scheduleResize(tab); @@ -1140,40 +1378,252 @@ export class BrowserHub { // ---- Lifecycle ----------------------------------------------------------- - private scheduleGrace(tab: Tab, ms: number): void { - if (tab.graceTimer !== null) clearTimeout(tab.graceTimer); + private held(targetId: string): boolean { + return this.holds.holders.has(targetId) || this.holds.working.has(targetId); + } + + /** An empty window nobody opened: the one Chrome starts with, or the hub's + * own. */ + private blank(tab: Tab): boolean { + return ( + (tab.info.url === "" || tab.info.url === "about:blank") && + tab.openerId === null && + tab.opener.kind !== "view" && + tab.opener.kind !== "new-tab" && + !this.holds.bound.has(tab.targetId) && + !this.held(tab.targetId) + ); + } + + /** F47 point 5. A tab a strip holds or an agent works in stays. One that + * nothing holds or views counts down: 8 s when its last hold went (the + * window of Vrátit), 30 s when its last viewer left (F39 point 9), and 10 + * minutes when nobody has viewed or held it yet. `idle` names the count + * for a tab with nothing before (Vrátit gives a new tab's time). */ + private review(tab: Tab, idle: GraceKind = "unviewed"): void { + if (tab.targetId === this.keeperId || this.closing.has(tab.targetId)) { + this.clearGrace(tab); + return; + } + const before = tab.state; + tab.state = this.held(tab.targetId) + ? "held" + : tab.viewers.size > 0 + ? "viewed" + : "idle"; + if (tab.state !== "idle") { + this.clearGrace(tab); + return; + } + if (tab.grace !== null) return; + this.scheduleGrace( + tab, + before === "held" ? "release" : before === "viewed" ? "viewer" : idle, + ); + } + + private scheduleGrace(tab: Tab, kind: GraceKind): void { + this.clearGrace(tab); + const ms = + kind === "release" + ? this.releaseGraceMs + : kind === "viewer" + ? this.graceMs + : this.unviewedGraceMs; + tab.grace = { kind, endsAt: Date.now() + ms }; tab.graceTimer = setTimeout(() => { tab.graceTimer = null; void this.closeIfUnused(tab.targetId); }, ms); } - /** F39 point 9: an own tab nobody views and no agent-browser session is - * bound to. */ + private clearGrace(tab: Tab): void { + if (tab.graceTimer !== null) clearTimeout(tab.graceTimer); + tab.graceTimer = null; + tab.grace = null; + } + + /** The end of a grace: the holds are read again first, and a tab that is + * held or viewed by now stays. A failed read keeps the tab. */ private async closeIfUnused(targetId: string): Promise { + let fresh: Holds; + try { + fresh = await this.seams.holds(); + } catch (error) { + this.seams.log(`reading the holds failed: ${String(error)}`); + const tab = this.tabs.get(targetId); + if (tab !== undefined) { + tab.grace = null; + this.review(tab); + } + return; + } + this.holds = fresh; const tab = this.tabs.get(targetId); const cdp = this.cdp; + if (tab === undefined || cdp === null) return; + tab.grace = null; if ( - tab === undefined || - cdp === null || - tab.viewers.size > 0 || - !this.ownTabs.has(targetId) - ) + targetId === this.keeperId || + this.closing.has(targetId) || + this.held(targetId) || + tab.viewers.size > 0 + ) { + this.review(tab); return; - const bound = await this.seams - .boundTargets() - .catch(() => new Set([targetId])); - if (bound.has(targetId) || tab.viewers.size > 0) return; + } this.seams.log(`closing unused tab ${targetId}`); await cdp.send("Target.closeTarget", { targetId }).catch(() => undefined); } + /** The end of a close for everyone. While the browser is away it waits + * for the reconnect, which finishes it. */ + private async finishClose(targetId: string): Promise { + const entry = this.closing.get(targetId); + if (entry === undefined) return; + entry.timer = null; + const cdp = this.cdp; + if (cdp === null || !this.discovered) return; + if (!this.tabs.has(targetId)) { + this.closing.delete(targetId); + return; + } + this.seams.log(`closing tab ${targetId} for everyone`); + await cdp.send("Target.closeTarget", { targetId }).catch(() => undefined); + } + + /** Keeps one blank window of the hub's own (F47 point 5). */ + private async ensureKeeper(): Promise { + const cdp = this.cdp; + if ( + this.stopped || + cdp === null || + !this.discovered || + this.keeperCreating || + (this.keeperId !== null && this.tabs.has(this.keeperId)) + ) + return; + this.keeperCreating = true; + try { + const result = await cdp.send("Target.createTarget", { + url: "about:blank", + newWindow: true, + }); + const keeperId = String(result.targetId); + this.keeperId = keeperId; + this.origins.set(keeperId, { + opener: { kind: "unknown" }, + openedAt: Date.now(), + }); + const tab = this.tabs.get(keeperId); + if (tab !== undefined) { + tab.opener = { kind: "unknown" }; + this.clearGrace(tab); + } + } catch (error) { + this.seams.log(`opening the blank window failed: ${String(error)}`); + } finally { + this.keeperCreating = false; + } + } + + /** Soon after a holder file or a binding changed: several changes at once + * are one read. */ + private holdsChanged(): void { + if (this.holdsSoon !== null || this.stopped) return; + this.holdsSoon = setTimeout(() => { + this.holdsSoon = null; + void this.refreshHolds(); + }, 100); + } + + private async refreshHolds(): Promise { + try { + this.holds = await this.seams.holds(); + } catch (error) { + this.seams.log(`reading the holds failed: ${String(error)}`); + return; + } + for (const tab of [...this.tabs.values()]) this.review(tab); + } + + /** Soon writes the list of live tabs (F47 point 4) from a fresh snapshot + * of the browser's targets, not from what the hub has heard: a target whose + * event comes late on the hub's connection is in the snapshot all the same. + * Only while connected; one snapshot at a time, so a slower one never + * overwrites a newer one. The hub's blank window is in no list. */ + private publishTabs(): void { + if ( + this.seams.writeTabs === undefined || + this.tabsSoon !== null || + this.stopped + ) + return; + this.tabsSoon = setTimeout(() => { + this.tabsSoon = null; + void this.snapshotTabs(); + }, 50); + } + + private async snapshotTabs(): Promise { + const write = this.seams.writeTabs; + const clock = this.seams.bootClock; + const cdp = this.cdp; + if ( + write === undefined || + clock === undefined || + cdp === null || + this.stopped + ) + return; + if (this.snapshotting) { + this.snapshotAgain = true; + return; + } + this.snapshotting = true; + try { + // The clock first: the snapshot then lists every target that exists + // at that time or comes later, never one that ended before it. + const takenAt = await clock(); + if (takenAt === null) return; + const answer = await cdp.send("Target.getTargets"); + const infos = Array.isArray(answer.targetInfos) + ? (answer.targetInfos as Record[]) + : []; + const ids = infos + .filter( + (info) => info.type === "page" && typeof info.targetId === "string", + ) + .map((info) => String(info.targetId)) + .filter((id) => id !== this.keeperId); + await write(ids, takenAt); + } catch (error) { + this.seams.log(`writing the list of tabs failed: ${String(error)}`); + } finally { + this.snapshotting = false; + if (this.snapshotAgain) { + this.snapshotAgain = false; + this.publishTabs(); + } + } + } + private gone(targetId: string): void { const tab = this.tabs.get(targetId); + const pending = this.closing.get(targetId); + if (pending !== undefined) { + if (pending.timer !== null) clearTimeout(pending.timer); + this.closing.delete(targetId); + } + this.origins.delete(targetId); + if (targetId === this.keeperId) { + this.keeperId = null; + void this.ensureKeeper(); + } if (tab === undefined) return; this.clearTimers(tab); this.tabs.delete(targetId); - this.ownTabs.delete(targetId); + this.publishTabs(); if (tab.sessionId !== null) this.bySession.delete(tab.sessionId); for (const [token, chooser] of this.fileChoosers) if (chooser.targetId === targetId) this.fileChoosers.delete(token); @@ -1193,6 +1643,7 @@ export class BrowserHub { ]) if (timer !== null) clearTimeout(timer); tab.graceTimer = null; + tab.grace = null; tab.resizeTimer = null; tab.infoTimer = null; tab.selectionTimer = null; @@ -1233,24 +1684,3 @@ export class BrowserHub { } } } - -/** The targets agent-browser sessions are bound to: its binding files - * (`/.target`, `{"targetId": …}`), as `lazurio browser - * window` reads them (F38 point 3). */ -export async function agentBoundTargets( - socketDirectory: string | undefined, -): Promise> { - const bound = new Set(); - if (socketDirectory === undefined) return bound; - const names = await readdir(socketDirectory).catch(() => [] as string[]); - for (const name of names) { - if (!name.endsWith(".target")) continue; - try { - const binding = JSON.parse( - await readFile(join(socketDirectory, name), "utf8"), - ) as { targetId?: unknown }; - if (typeof binding.targetId === "string") bound.add(binding.targetId); - } catch {} - } - return bound; -} diff --git a/src/browser/people/serve.ts b/src/browser/people/serve.ts index a23476f5..d253516e 100644 --- a/src/browser/people/serve.ts +++ b/src/browser/people/serve.ts @@ -1,9 +1,17 @@ +import { mkdir } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { parseArgs } from "node:util"; import { browserCdpPort, browserScreenSize } from "../units"; import { agentBrowserSocketDirectory } from "../window"; -import { agentBoundTargets } from "./hub"; +import { + holderDirectory, + liveTabsPath, + readBootClock, + readHolds, + watchHolds, + writeLiveTabs, +} from "./holds"; import { loopbackBrowser, startViewService } from "./service"; /** `lazurio browser serve --port --origin `: the people's view @@ -48,13 +56,29 @@ export async function runViewService( } const log = (line: string) => console.error(line); const runtime = env.XDG_RUNTIME_DIR; + // The apps' servers write their holds here (decision F47 point 4); the + // directory exists from the start, so it is watched from the start. + const holders = holderDirectory(env); + if (holders !== undefined) + await mkdir(holders, { recursive: true, mode: 0o700 }).catch( + () => undefined, + ); + const sockets = agentBrowserSocketDirectory(env); + const liveTabs = liveTabsPath(env); const service = startViewService({ origin: options.origin, port: options.port, log, hub: { connect: loopbackBrowser(browserCdpPort), - boundTargets: () => agentBoundTargets(agentBrowserSocketDirectory(env)), + holds: () => readHolds({ holders, sockets }), + watchHolds: (changed) => watchHolds([holders, sockets], changed), + ...(liveTabs === undefined + ? {} + : { + writeTabs: (ids, takenAt) => writeLiveTabs(liveTabs, ids, takenAt), + bootClock: () => readBootClock(), + }), uploadDirectory: join( runtime?.startsWith("/") ? runtime : tmpdir(), "lazurio-browser-uploads", diff --git a/src/browser/people/service.ts b/src/browser/people/service.ts index cdd294ac..008b1342 100644 --- a/src/browser/people/service.ts +++ b/src/browser/people/service.ts @@ -1,7 +1,13 @@ import type { Server, ServerWebSocket } from "bun"; import { viewScript } from "./bundle" with { type: "macro" }; import { browserSocketUrl, type CdpConnection, connectCdp } from "./cdp"; -import { BrowserHub, type HubSeams, type Viewer } from "./hub"; +import { isStrip } from "./holds"; +import { + BrowserHub, + type HubSeams, + navigableAddress, + type Viewer, +} from "./hub"; import { viewPage } from "./page"; import { parseClientMessage } from "./protocol"; @@ -10,7 +16,9 @@ import { parseClientMessage } from "./protocol"; * and carries one WebSocket per person's tab. The gateway's session is the * only admission (F39 point 8): this service checks that every request names * its own host and that every socket and every change comes from its own - * origin, and it listens on loopback only. */ + * origin, and it listens on loopback only. On loopback alone it also answers + * the Launchpad's requests about the Environment's tabs (decision F47 point + * 6), which no browser may make. */ export type ViewServiceOptions = Readonly<{ /** The view's public origin, `https://browser.<…>`, from the handover. */ @@ -103,6 +111,8 @@ const json = (body: unknown, status = 200) => const livePath = /^\/t\/([0-9A-Fa-f]{32})\/live$/; const filesPath = /^\/t\/([0-9A-Fa-f]{32})\/files$/; const viewPath = /^\/t\/([0-9A-Fa-f]{32})$/; +const tabsPath = /^\/\.lazurio\/tabs(?:\/([0-9A-Fa-f]{32})\/(close|restore))?$/; +const maxTabsBodyBytes = 8 * 1024; export type RequestDecision = | Readonly<{ kind: "health" }> @@ -111,6 +121,10 @@ export type RequestDecision = | Readonly<{ kind: "create" }> | Readonly<{ kind: "files"; targetId: string }> | Readonly<{ kind: "live"; targetId: string }> + | Readonly<{ kind: "tabs" }> + | Readonly<{ kind: "open" }> + | Readonly<{ kind: "close"; targetId: string }> + | Readonly<{ kind: "restore"; targetId: string }> | Readonly<{ kind: "refused"; status: number; error: string }>; /** What a request is, after the Host and Origin checks (F39 point 8). @@ -122,6 +136,8 @@ export function classifyRequest( host: string | null; origin: string | null; upgrade: boolean; + /** `Sec-Fetch-Site`: a browser sends it with every request. */ + fetchSite?: string | null; }>, viewOrigin: string, port: number, @@ -135,6 +151,27 @@ export function classifyRequest( return request.host === own.host || loopback ? { kind: "health" } : { kind: "refused", status: 421, error: "wrong-host" }; + const tabs = tabsPath.exec(path); + if (tabs !== null) { + // The Launchpad's requests (F47 point 6): through the gateway, which + // sends the view's own host, they do not exist. Pages in the + // Environment's own browser reach the loopback too, and a browser always + // names where its request comes from. + if (!loopback) return { kind: "refused", status: 404, error: "not-found" }; + if (request.origin !== null || (request.fetchSite ?? null) !== null) + return { kind: "refused", status: 403, error: "browser-request" }; + const targetId = tabs[1]?.toUpperCase(); + if (targetId === undefined) { + if (request.method === "GET") return { kind: "tabs" }; + if (request.method === "POST") return { kind: "open" }; + return { kind: "refused", status: 405, error: "method-not-allowed" }; + } + if (request.method !== "POST") + return { kind: "refused", status: 405, error: "method-not-allowed" }; + return tabs[2] === "close" + ? { kind: "close", targetId } + : { kind: "restore", targetId }; + } if (request.host !== own.host) return { kind: "refused", status: 421, error: "wrong-host" }; const sameOrigin = request.origin === own.origin; @@ -190,6 +227,7 @@ export function startViewService(options: ViewServiceOptions): ViewService { origin: request.headers.get("origin"), upgrade: request.headers.get("upgrade")?.toLowerCase() === "websocket", + fetchSite: request.headers.get("sec-fetch-site"), }, options.origin, server.port ?? options.port, @@ -262,6 +300,41 @@ export function startViewService(options: ViewServiceOptions): ViewService { ? new Response(null, { status: 204 }) : json({ error: "not-accepted" }, 409); } + case "tabs": + return hub.ready + ? json({ tabs: hub.list() }) + : json({ error: "browser-unreachable" }, 503); + case "open": { + const body = await smallJson(request); + const url = body?.url; + const strip = body?.strip ?? null; + if ( + typeof url !== "string" || + navigableAddress(url) === null || + (strip !== null && !isStrip(strip)) + ) + return json({ error: "invalid-request" }, 400); + const id = await hub.openTab(url, strip); + return id === null + ? json({ error: "browser-unreachable" }, 503) + : json({ id, view: `${options.origin}/t/${id}` }, 201); + } + case "close": { + // While the hub does not know the browser's targets it changes + // nothing (F47's failure modes); a close accepted before goes on. + if (!hub.ready) return json({ error: "browser-unreachable" }, 503); + const endsAt = hub.closeForEveryone(decision.targetId); + return endsAt === null + ? json({ error: "not-found" }, 404) + : json({ closing: new Date(endsAt).toISOString() }, 202); + } + case "restore": + if (!hub.ready) return json({ error: "browser-unreachable" }, 503); + if (!hub.hasTab(decision.targetId)) + return json({ error: "not-found" }, 404); + return hub.restore(decision.targetId) + ? json({ restored: true }) + : json({ error: "nothing-to-restore" }, 409); case "live": { // Upgraded even for a tab that is gone: the page can read a // message, not the status of a refused handshake (`open`). @@ -335,6 +408,22 @@ export function startViewService(options: ViewServiceOptions): ViewService { }); } +/** A small JSON object body, or null. */ +async function smallJson( + request: Request, +): Promise | null> { + try { + const text = await request.text(); + if (text.length > maxTabsBodyBytes) return null; + const value: unknown = JSON.parse(text); + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : null; + } catch { + return null; + } +} + /** The production connection: the browser target on the loopback DevTools * port, asked again on every reconnect (a restarted browser has a new id). */ export const loopbackBrowser = diff --git a/tests/environment-browser-holds.test.ts b/tests/environment-browser-holds.test.ts new file mode 100644 index 00000000..cc45a67f --- /dev/null +++ b/tests/environment-browser-holds.test.ts @@ -0,0 +1,384 @@ +import { afterEach, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + rm, + stat, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + holderDirectory, + isStrip, + keptTabs, + liveTabsPath, + parseBootTime, + parseHolder, + parseLiveTabs, + processAlive, + readBootClock, + readHolds, + watchHolds, + writeLiveTabs, +} from "../src/browser/people/holds"; + +// Who keeps a remote tab of the Environment browser (decision F47 points 4 +// and 5): the holder files the apps' servers write and the agent-browser +// sessions whose daemon runs. + +const id = (n: number) => n.toString(16).toUpperCase().padStart(32, "0"); + +let root: string | undefined; +afterEach(async () => { + if (root) await rm(root, { recursive: true, force: true }); + root = undefined; +}); + +const settle = (ms = 20) => new Promise((resolve) => setTimeout(resolve, ms)); +test("a strip is a file name in agent-browser's session alphabet", () => { + for (const name of ["launchpad", "t3-abc_DEF-123", "x".repeat(64)]) + expect(isStrip(name)).toBe(true); + for (const name of ["", "../x", "a/b", "a.b", "x".repeat(65), 7, null]) + expect(isStrip(name)).toBe(false); +}); + +test("the holder directory lives in the runtime directory, never elsewhere", () => { + expect(holderDirectory({ XDG_RUNTIME_DIR: "/run/user/1000" })).toBe( + "/run/user/1000/lazurio-browser/holders", + ); + expect(holderDirectory({ XDG_RUNTIME_DIR: "run/user" })).toBeUndefined(); + expect(holderDirectory({})).toBeUndefined(); +}); + +test("a holder file holds only target ids, under its own strip name", () => { + const text = (value: unknown) => JSON.stringify(value); + expect( + parseHolder( + "t3-a.json", + text({ + strip: "t3-a", + label: " Vlákno o webu ", + tabs: [id(1), id(1).toLowerCase(), "nope", 5, id(2)], + }), + ), + ).toEqual({ strip: "t3-a", label: "Vlákno o webu", tabs: [id(1), id(2)] }); + // The label is optional and cut; the strip must be the file's name. + expect( + parseHolder("launchpad.json", text({ strip: "launchpad", tabs: [] })), + ).toEqual({ strip: "launchpad", label: "", tabs: [] }); + expect( + parseHolder( + "launchpad.json", + text({ strip: "launchpad", label: "x".repeat(500), tabs: [] }), + )?.label.length, + ).toBe(200); + for (const [name, body] of [ + ["t3-a.json", text({ strip: "t3-b", tabs: [] })], + ["t3-a.tmp", text({ strip: "t3-a", tabs: [] })], + ["..json", text({ strip: ".", tabs: [] })], + ["t3-a.json", text({ strip: "t3-a", tabs: "x" })], + ["t3-a.json", text([id(1)])], + ["t3-a.json", "{"], + ["t3-a.json", text({ strip: "t3-a", tabs: [], pad: "x".repeat(70_000) })], + ] as const) + expect(parseHolder(name, body)).toBeNull(); + // At most 500 tabs per holder. + expect( + parseHolder( + "t3-a.json", + text({ + strip: "t3-a", + tabs: Array.from({ length: 600 }, (_, n) => id(n + 1)), + }), + )?.tabs.length, + ).toBe(500); +}); + +test("the holds join every holder file and the sessions whose daemon runs", async () => { + root = await mkdtemp(join(tmpdir(), "holds-")); + const holders = join(root, "holders"); + const sockets = join(root, "agent-browser"); + await mkdir(holders); + await mkdir(sockets); + const write = (directory: string, name: string, value: unknown) => + writeFile( + join(directory, name), + typeof value === "string" ? value : JSON.stringify(value), + ); + await write(holders, "t3-a.json", { + strip: "t3-a", + label: "A", + tabs: [id(1), id(2)], + }); + await write(holders, "launchpad.json", { + strip: "launchpad", + label: "Launchpad", + tabs: [id(2)], + }); + // An app's temporary file is no strip's file. + await write(holders, "t3-a.json.tmp-1", { strip: "t3-a", tabs: [id(9)] }); + // A live daemon, an ended one (its binding stays), a binding without a + // daemon file, a daemon file that cannot be understood (working, so its + // tab stays), and files that are no binding. + await write(sockets, "codex-1.target", { targetId: id(3) }); + await write(sockets, "codex-1.pid", "4242\n"); + await write(sockets, "t3-old.target", { targetId: id(4).toLowerCase() }); + await write(sockets, "t3-old.pid", "4343"); + await write(sockets, "claude-2.target", { targetId: id(3) }); + await write(sockets, "odd-3.target", { targetId: id(5) }); + await write(sockets, "odd-3.pid", "not a pid"); + await write(sockets, "codex-1.sock", ""); + await write(sockets, "short.target", { targetId: "abc" }); + const holds = await readHolds({ holders, sockets }, (pid) => pid === 4242); + expect(Object.fromEntries(holds.holders)).toEqual({ + [id(1)]: [{ strip: "t3-a", label: "A" }], + [id(2)]: [ + { strip: "launchpad", label: "Launchpad" }, + { strip: "t3-a", label: "A" }, + ], + }); + expect(Object.fromEntries(holds.working)).toEqual({ + [id(3)]: ["codex-1"], + [id(5)]: ["odd-3"], + }); + expect(Object.fromEntries(holds.bound)).toEqual({ + [id(3)]: ["claude-2", "codex-1"], + [id(4)]: ["t3-old"], + [id(5)]: ["odd-3"], + }); +}); + +test("a read that fails is thrown, never read as holding nothing", async () => { + root = await mkdtemp(join(tmpdir(), "holds-fail-")); + const holders = join(root, "holders"); + const sockets = join(root, "agent-browser"); + await mkdir(holders); + await mkdir(sockets); + await writeFile( + join(holders, "t3-a.json"), + JSON.stringify({ strip: "t3-a", label: "A", tabs: [id(1)] }), + ); + expect((await readHolds({ holders, sockets })).holders.size).toBe(1); + // A directory that cannot be read. + await chmod(holders, 0o000); + try { + await expect(readHolds({ holders, sockets })).rejects.toThrow(); + } finally { + await chmod(holders, 0o700); + } + // A holder file that cannot be read. + await chmod(join(holders, "t3-a.json"), 0o000); + try { + await expect(readHolds({ holders, sockets })).rejects.toThrow(); + } finally { + await chmod(join(holders, "t3-a.json"), 0o600); + } + // A holder file that is not one. + await writeFile(join(holders, "launchpad.json"), "{"); + await expect(readHolds({ holders, sockets })).rejects.toThrow( + "unreadable holder file launchpad.json", + ); + await rm(join(holders, "launchpad.json")); + // A binding file that is not one. + await writeFile(join(sockets, "codex-1.target"), "{"); + await expect(readHolds({ holders, sockets })).rejects.toThrow( + "unreadable binding file codex-1.target", + ); + await rm(join(sockets, "codex-1.target")); + expect((await readHolds({ holders, sockets })).holders.size).toBe(1); +}); + +test("a .pid releases a tab only when it names a well-formed process id that ended", async () => { + root = await mkdtemp(join(tmpdir(), "holds-pid-")); + const sockets = join(root, "agent-browser"); + await mkdir(sockets); + await writeFile( + join(sockets, "codex-1.target"), + JSON.stringify({ targetId: id(3) }), + ); + const working = async (pid: string | null, alive = false) => { + if (pid === null) await rm(join(sockets, "codex-1.pid"), { force: true }); + else await writeFile(join(sockets, "codex-1.pid"), pid); + const holds = await readHolds({ sockets }, () => alive); + return holds.working.has(id(3)); + }; + // Unclear: empty, blank, zero, signed, pid 1, too long, not a number. + for (const unclear of [ + "", + " ", + "\n", + "0", + "-1", + "1", + "+5", + "4.2", + "99999999999", + "x", + ]) + expect(await working(unclear)).toBe(true); + // A well-formed id: working while it runs, ended once it does not. + expect(await working("4242\n", true)).toBe(true); + expect(await working("4242")).toBe(false); + // No .pid: the daemon ended (agent-browser removes it on exit). + expect(await working(null)).toBe(false); +}); + +test("directories that do not exist hold nothing", async () => { + const holds = await readHolds({ + holders: join(tmpdir(), "no-such-holders-dir-f47"), + sockets: undefined, + }); + expect(holds.holders.size + holds.working.size + holds.bound.size).toBe(0); +}); + +test("a process is alive while kill -0 reaches it", () => { + expect(processAlive(process.pid)).toBe(true); + for (const pid of [0, 1, -5, Number.NaN, 1.5]) + expect(processAlive(pid)).toBe(false); + expect(processAlive(2 ** 22 + 12_345)).toBe(false); +}); + +test("a change in a watched directory is reported, and a directory made later is watched on retry", async () => { + root = await mkdtemp(join(tmpdir(), "holds-watch-")); + const holders = join(root, "holders"); + const later = join(root, "agent-browser"); + await mkdir(holders); + let changes = 0; + const watcher = watchHolds([holders, later, undefined], () => { + changes++; + }); + // A watch may miss a change made the instant it starts (macOS), which the + // hub's periodic read covers; the test writes until the watch sees one. + const seen = async (path: string, after: number) => { + const deadline = Date.now() + 5_000; + for (let n = 0; changes <= after; n++) { + if (Date.now() > deadline) throw new Error("no change seen"); + await writeFile(path, `{"n":${n}}`); + await settle(50); + } + }; + try { + await seen(join(holders, "launchpad.json"), 0); + const before = changes; + await mkdir(later); + watcher.retry(); + await seen(join(later, "codex-1.target"), before); + } finally { + watcher.stop(); + } +}); + +const boot = "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f"; +const earlierBoot = "0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d"; +const at = (seconds: number, ofBoot: string = boot) => ({ + boot: ofBoot, + at: seconds, +}); + +test("the boot clock reads the boot's id and the seconds since it", async () => { + const files: Record = { + "/proc/sys/kernel/random/boot_id": `${boot}\n`, + "/proc/uptime": "12345.67 54321.00\n", + }; + const read = async (path: string) => { + const text = files[path]; + if (text === undefined) throw new Error("ENOENT"); + return text; + }; + expect(await readBootClock(read)).toEqual(at(12345.67)); + files["/proc/uptime"] = "x y"; + expect(await readBootClock(read)).toBeNull(); + // Not Linux: no boot clock. + expect( + await readBootClock(async () => { + throw new Error("ENOENT"); + }), + ).toBeNull(); + expect(parseBootTime(at(1))).toEqual(at(1)); + for (const value of [ + null, + {}, + { boot, at: -1 }, + { boot: "x", at: 1 }, + { boot, at: "1" }, + ]) + expect(parseBootTime(value)).toBeNull(); +}); + +test("the list of live tabs is written atomically, owner-only, and read back", async () => { + root = await mkdtemp(join(tmpdir(), "live-tabs-")); + expect(liveTabsPath({ XDG_RUNTIME_DIR: root })).toBe( + join(root, "lazurio-browser", "tabs.json"), + ); + expect(liveTabsPath({})).toBeUndefined(); + const path = join(root, "lazurio-browser", "tabs.json"); + await writeLiveTabs(path, [id(1), id(2)], at(100)); + await writeLiveTabs(path, [id(3)], at(105.5)); + expect(parseLiveTabs(await readFile(path, "utf8"))).toEqual({ + tabs: [id(3)], + takenAt: at(105.5), + }); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readdir(join(root, "lazurio-browser"))).toEqual(["tabs.json"]); + for (const text of [ + "{", + "[]", + JSON.stringify({ tabs: [id(1)] }), + JSON.stringify({ tabs: [id(1)], takenAt: "2026-10-11T12:00:00Z" }), + JSON.stringify({ tabs: ["x"], takenAt: at(1) }), + JSON.stringify({ tabs: id(1), takenAt: at(1) }), + ]) + expect(parseLiveTabs(text)).toBeNull(); +}); + +test("an app drops a stored tab only when a list of this boot taken after its record lacks it", () => { + const list = { tabs: [id(1)], takenAt: at(100) }; + const stored = [ + { id: id(1), recordedAt: at(10) }, + { id: id(2), recordedAt: at(90) }, + { id: id(3), recordedAt: at(100) }, + { id: id(4).toLowerCase(), recordedAt: at(100.5) }, + { id: id(5), recordedAt: null }, + { id: id(6), recordedAt: at(5_000, earlierBoot) }, + ]; + // id(2) ended before the snapshot began. id(5) has no time and id(6) is + // from an earlier boot, so both count as older than every list of this + // boot. id(3) and id(4) were recorded when or after it began. + expect(keptTabs(stored, list, boot)).toEqual([ + id(1), + id(3), + id(4).toLowerCase(), + ]); + // A list of another boot, no list, or no boot clock: nothing is dropped. + const all = stored.map((tab) => tab.id); + expect( + keptTabs(stored, { tabs: [], takenAt: at(100, earlierBoot) }, boot), + ).toEqual(all); + expect(keptTabs(stored, null, boot)).toEqual(all); + expect(keptTabs(stored, list, null)).toEqual(all); +}); + +test("a record of a tab that had already ended goes with the hub's next periodic snapshot", () => { + // The tab ended at 199 s and the hub's snapshot then lacks it; the app + // recorded it only at 200 s, so that snapshot cannot drop it. + const record = [{ id: id(6), recordedAt: at(200) }]; + expect(keptTabs(record, { tabs: [], takenAt: at(199) }, boot)).toEqual([ + id(6), + ]); + // The periodic snapshot 30 s later began after the record and lacks it. + expect(keptTabs(record, { tabs: [], takenAt: at(229) }, boot)).toEqual([]); +}); + +test("a step of the wall clock changes nothing: a record made after a snapshot began is kept", () => { + // The snapshot began at boot second 100; the wall clock then stepped back + // ten seconds; the app recorded a live tab, which the boot clock reads as + // 100.4. The record is later than the snapshot, so the tab stays. + const record = [{ id: id(7), recordedAt: at(100.4) }]; + expect(keptTabs(record, { tabs: [], takenAt: at(100) }, boot)).toEqual([ + id(7), + ]); +}); diff --git a/tests/environment-browser-view.test.ts b/tests/environment-browser-view.test.ts index 9126ea6d..27c1f5f1 100644 --- a/tests/environment-browser-view.test.ts +++ b/tests/environment-browser-view.test.ts @@ -1,5 +1,13 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdtemp, readFile, realpath, rm } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readFile, + realpath, + rm, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { CdpConnection, CdpEvent } from "../src/browser/people/cdp"; @@ -7,10 +15,17 @@ import { browserExtensionFiles, writeBrowserExtension, } from "../src/browser/people/extension"; +import { + type Hold, + type Holds, + noHolds, + readHolds, +} from "../src/browser/people/holds"; import { BrowserHub, jpegSize, navigableAddress, + type TabFacts, type Viewer, } from "../src/browser/people/hub"; import { @@ -60,6 +75,9 @@ type Target = { }; const id = (n: number) => n.toString(16).toUpperCase().padStart(32, "0"); +const firstWindow = id(0xb1a4c); +const testBoot = "6f2c1d0e-1a2b-4c3d-8e4f-5a6b7c8d9e0f"; +const blankWindow = { targetId: firstWindow, url: "about:blank", title: "" }; /** A browser target that answers like Chrome for what the hub asks. */ class FakeBrowser { @@ -76,7 +94,10 @@ class FakeBrowser { gate: Promise | null = null; pageTitle = "Stránka z dokumentu"; - constructor(initial: Target[] = []) { + /** Chrome's first window (the unit starts it with `about:blank`), which + * the hub keeps as its own blank window unless `blank` is false. */ + constructor(initial: Target[] = [], blank = true) { + if (blank) this.targets.set(firstWindow, { ...blankWindow }); for (const target of initial) this.targets.set(target.targetId, target); } @@ -155,6 +176,14 @@ class FakeBrowser { sessionId?: string, ): Record { switch (method) { + case "Target.getTargets": + return { + targetInfos: [...this.targets.values()].map((target) => ({ + type: "page", + attached: false, + ...target, + })), + }; case "Target.setDiscoverTargets": for (const target of this.targets.values()) this.emit("Target.targetCreated", { @@ -226,32 +255,135 @@ afterEach(async () => { root = undefined; }); +type HoldsState = { + /** Targets an agent works in (a bound session whose daemon runs). */ + working?: readonly string[]; + /** Targets bound to a session whose daemon ended. */ + idle?: readonly string[]; + /** Holder strips and the targets each holds. */ + holders?: Readonly>; +}; + +/** The holds a state names, as `readHolds` answers them. */ +function holdsOf(state: HoldsState): Holds { + const holders = new Map(); + for (const [strip, ids] of Object.entries(state.holders ?? {})) + for (const target of ids) + holders.set(target, [ + ...(holders.get(target) ?? []), + { strip, label: `Strip ${strip}` }, + ]); + const working = new Map(); + const bound = new Map(); + for (const target of state.working ?? []) { + working.set(target, [`codex-${target.slice(-4)}`]); + bound.set(target, [`codex-${target.slice(-4)}`]); + } + for (const target of state.idle ?? []) + bound.set(target, [`codex-${target.slice(-4)}`]); + return { holders, working, bound }; +} + async function serve( browser: FakeBrowser, - options: { bound?: string[]; graceMs?: number; reconnectMs?: number } = {}, + options: HoldsState & { + graceMs?: number; + unviewedGraceMs?: number; + releaseGraceMs?: number; + holdsEveryMs?: number; + reconnectMs?: number; + /** The real reader instead of the state above. */ + holds?: () => Promise; + } = {}, ) { root = await realpath(await mkdtemp(join(tmpdir(), "people-view-"))); const logs: string[] = []; + let state: Holds = holdsOf(options); + let failing = false; + let changed: (() => void) | null = null; + const writes: string[][] = []; + const takenAts: number[] = []; + // The boot clock: seconds since a fixed boot, later at each reading. + let uptime = 1_000; service = startViewService({ origin, port: 0, log: (line) => logs.push(line), hub: { connect: browser.connect, - boundTargets: async () => new Set(options.bound ?? []), + holds: + options.holds ?? + (async () => { + if (failing) throw new Error("unreadable"); + return state; + }), + watchHolds: (callback) => { + changed = callback; + return { retry: () => {}, stop: () => {} }; + }, + writeTabs: async (ids, takenAt) => { + writes.push([...ids].sort()); + takenAts.push(takenAt.at); + }, + bootClock: async () => { + uptime += 0.01; + return { boot: testBoot, at: uptime }; + }, uploadDirectory: join(root, "uploads"), screen: { width: 1920, height: 1080 }, log: (line) => logs.push(line), graceMs: options.graceMs ?? 60_000, - unviewedGraceMs: options.graceMs ?? 60_000, + unviewedGraceMs: options.unviewedGraceMs ?? options.graceMs ?? 60_000, + releaseGraceMs: options.releaseGraceMs ?? options.graceMs ?? 60_000, + holdsEveryMs: options.holdsEveryMs ?? 60_000, reconnectMs: options.reconnectMs ?? 20, }, }); - await until(() => service?.hub.connected === true); + await until(() => service?.hub.ready === true); const base = `http://127.0.0.1:${service.server.port}`; - return { base, logs, root }; + /** New holds, as when an app rewrites its holder file. */ + const setHolds = (next: HoldsState) => { + state = holdsOf(next); + changed?.(); + }; + const failHolds = (fail: boolean) => { + failing = fail; + }; + /** As when a watched directory changed. */ + const refresh = () => changed?.(); + return { base, logs, root, setHolds, failHolds, refresh, writes, takenAts }; } +/** The Launchpad's request to the hub on loopback (F47 point 6): no Origin, + * no Sec-Fetch-Site, the loopback host Bun's fetch sends by itself. */ +async function loopback( + base: string, + path: string, + init: { + method?: string; + body?: unknown; + headers?: Record; + } = {}, +) { + const response = await fetch(`${base}${path}`, { + method: init.method ?? "GET", + headers: { + ...(init.body === undefined + ? {} + : { "content-type": "application/json" }), + ...init.headers, + }, + ...(init.body === undefined ? {} : { body: JSON.stringify(init.body) }), + }); + return { + status: response.status, + body: (await response.json()) as Record, + }; +} + +const closedTargets = (browser: FakeBrowser) => + browser.called("Target.closeTarget").map((c) => c.params.targetId); + /** A person's socket, as the view page opens it. */ async function person( base: string, @@ -901,11 +1033,11 @@ test("a file chooser is answered with the person's files; without a viewer it is view.socket.close(); }); -// ---- Closing (F39 point 9) --------------------------------------------------- +// ---- Closing (F39 point 9, F47 point 5) ----------------------------------------- -test("a closed person's tab closes its own remote tab after the grace, unless an agent is bound to it", async () => { +test("a closed person's tab closes its remote tab after the grace; a tab an agent works in stays", async () => { const browser = new FakeBrowser([tab(1)]); - const { base } = await serve(browser, { graceMs: 60, bound: [] }); + const { base } = await serve(browser, { graceMs: 60, working: [id(1)] }); const make = async () => { const response = await fetch(`${base}/api/tabs`, { method: "POST", @@ -922,26 +1054,62 @@ test("a closed person's tab closes its own remote tab after the grace, unless an const view = await person(base, own); await until(() => view.of("info").length > 0); view.socket.close(); - await until(() => - browser.called("Target.closeTarget").some((c) => c.params.targetId === own), - ); - // An agent's tab the view did not open is never closed by it. + await until(() => closedTargets(browser).includes(own)); + // An agent's window it works in stays after its viewer leaves. const agent = await person(base, id(1)); await until(() => agent.of("info").length > 0); agent.socket.close(); await settle(150); - expect( - browser - .called("Target.closeTarget") - .some((c) => c.params.targetId === id(1)), - ).toBe(false); + expect(closedTargets(browser)).not.toContain(id(1)); +}); + +test("a tab an agent works in stays; once the agent stops, it ends after the grace of Vrátit", async () => { + const browser = new FakeBrowser([tab(1), tab(2)]); + const { setHolds } = await serve(browser, { + graceMs: 60_000, + releaseGraceMs: 40, + working: [id(1), id(2)], + }); + await settle(80); + expect(closedTargets(browser)).toEqual([]); + // The agent of id(1) stopped: its daemon ended, its binding stays. + setHolds({ working: [id(2)], idle: [id(1)] }); + await until(() => closedTargets(browser).includes(id(1))); + await settle(80); + expect(closedTargets(browser)).not.toContain(id(2)); }); -test("an own tab an agent bound itself to stays open; an own tab nobody opened closes", async () => { +test("a tab a strip holds stays without viewers; after its last hold it ends unless held again within the grace", async () => { + const browser = new FakeBrowser([tab(1), tab(2)]); + const { setHolds } = await serve(browser, { + unviewedGraceMs: 60_000, + releaseGraceMs: 300, + holders: { "t3-a": [id(1), id(2)], "t3-b": [id(2)] }, + }); + const graceOf = (target: string) => + service?.hub.list().find((t) => t.id === target)?.grace ?? null; + await settle(120); + expect(closedTargets(browser)).toEqual([]); + // Thread a lets go of both; thread b still holds id(2). + setHolds({ holders: { "t3-b": [id(2)] } }); + await until(() => graceOf(id(1))?.kind === "release"); + expect(graceOf(id(2))).toBeNull(); + // Vrátit in thread a within the grace: held again. + setHolds({ holders: { "t3-a": [id(1)], "t3-b": [id(2)] } }); + await until(() => graceOf(id(1)) === null); + await settle(400); + expect(closedTargets(browser)).toEqual([]); + // Both threads let go of id(2): it ends; id(1) stays held. + setHolds({ holders: { "t3-a": [id(1)] } }); + await until(() => closedTargets(browser).includes(id(2))); + expect(closedTargets(browser)).not.toContain(id(1)); +}); + +test("an own tab nobody views or holds closes; an agent's tab stays while it works", async () => { const browser = new FakeBrowser(); - // The fake's first two new tabs are id(100) and id(101); an agent-browser - // session is bound to the first. - const { base } = await serve(browser, { graceMs: 40, bound: [id(100)] }); + // The fake's first two new tabs are id(100) and id(101); an agent works + // in the first. + const { base } = await serve(browser, { graceMs: 40, working: [id(100)] }); const make = async () => { const response = await fetch(`${base}/api/tabs`, { method: "POST", @@ -960,24 +1128,16 @@ test("an own tab an agent bound itself to stays open; an own tab nobody opened c await until(() => view.of("info").length > 0); view.socket.close(); const unopened = await make(); - await until(() => - browser - .called("Target.closeTarget") - .some((c) => c.params.targetId === unopened), - ); + await until(() => closedTargets(browser).includes(unopened)); await settle(100); - expect( - browser - .called("Target.closeTarget") - .some((c) => c.params.targetId === bound), - ).toBe(false); + expect(closedTargets(browser)).not.toContain(bound); }); -test("a tab a page opened closes when nobody views it, unless an agent is bound to it; the agent's own tab stays", async () => { - // id(1) is an agent's window (not opened by the view); its page opens two - // tabs, and an agent-browser session is bound to the second. +test("a tab a page opened closes when nobody views or holds it; the tabs agents work in stay", async () => { + // id(1) is an agent's window; its page opens two tabs, and an agent works + // in the second. const browser = new FakeBrowser([tab(1)]); - await serve(browser, { graceMs: 40, bound: [id(3)] }); + await serve(browser, { graceMs: 40, working: [id(1), id(3)] }); browser.created( tab(2, { url: "https://example.com/a", @@ -992,17 +1152,459 @@ test("a tab a page opened closes when nobody views it, unless an agent is bound canAccessOpener: false, }), ); - await until(() => - browser - .called("Target.closeTarget") - .some((c) => c.params.targetId === id(2)), + await until(() => closedTargets(browser).includes(id(2))); + await settle(120); + expect(closedTargets(browser)).not.toContain(id(3)); + expect(closedTargets(browser)).not.toContain(id(1)); +}); + +test("after a start, a tab nothing holds gets the time of a new tab; before, the view never closed it", async () => { + const browser = new FakeBrowser([tab(1), tab(2)]); + await serve(browser, { + unviewedGraceMs: 60, + holders: { launchpad: [id(2)] }, + }); + await until(() => closedTargets(browser).includes(id(1))); + await settle(100); + expect(closedTargets(browser)).not.toContain(id(2)); +}); + +test("a failed read of the holds lets no tab go", async () => { + // id(2) is held by nothing: its grace ends, but the read before closing + // fails, so it stays. + const browser = new FakeBrowser([tab(1), tab(2)]); + const { failHolds, setHolds } = await serve(browser, { + working: [id(1)], + releaseGraceMs: 30, + unviewedGraceMs: 100, + }); + failHolds(true); + setHolds({}); + await settle(300); + expect(closedTargets(browser)).toEqual([]); +}); + +// ---- The list of live tabs (F47 point 4) ----------------------------------------- + +test("the hub writes the list of live tabs whenever one appears or ends, never while it does not know the browser's targets", async () => { + const browser = new FakeBrowser([tab(1)]); + const { base, writes } = await serve(browser, { + releaseGraceMs: 30, + reconnectMs: 250, + holders: { launchpad: [id(1)] }, + }); + const last = () => writes.at(-1) ?? null; + // After discovery: every page but the hub's blank window. + await until(() => last() !== null); + expect(last()).toEqual([id(1)]); + const opened = await loopback(base, "/.lazurio/tabs", { + method: "POST", + body: { url: "https://example.com/n" }, + }); + const fresh = String(opened.body.id); + await until(() => last()?.includes(fresh) === true); + await loopback(base, `/.lazurio/tabs/${fresh}/close`, { method: "POST" }); + await until(() => last()?.includes(fresh) === false); + // The browser goes away and comes back restarted, with new ids: nothing is + // written while the hub does not know its targets, then the new list. + const before = writes.length; + browser.drop(); + browser.targets.delete(id(1)); + browser.targets.set(id(5), tab(5)); + await settle(150); + expect(service?.hub.connected).toBe(false); + expect(writes.length).toBe(before); + await until(() => JSON.stringify(last()) === JSON.stringify([id(5)])); +}); + +test("the hub takes a snapshot again on its beat, so a record made after its tab ended meets a newer list", async () => { + const browser = new FakeBrowser([tab(1)]); + const { writes, takenAts } = await serve(browser, { + holdsEveryMs: 60, + holders: { launchpad: [id(1)] }, + }); + await until(() => writes.length > 0); + const before = writes.length; + await settle(300); + expect(writes.length).toBeGreaterThanOrEqual(before + 3); + expect(writes.at(-1)).toEqual([id(1)]); + // Each snapshot is stamped with the boot clock when it began, later each + // time. + for (let n = 1; n < takenAts.length; n++) + expect(takenAts[n] as number).toBeGreaterThan(takenAts[n - 1] as number); +}); + +test("snapshots are stamped by the boot clock, whatever the wall clock does", async () => { + const browser = new FakeBrowser([tab(1)]); + const { writes, takenAts } = await serve(browser, { + holdsEveryMs: 50, + holders: { launchpad: [id(1)] }, + }); + await until(() => writes.length > 0); + // The wall clock steps back an hour. + const realNow = Date.now; + const back = realNow() - 3_600_000; + Date.now = () => back; + try { + const before = takenAts.length; + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(takenAts.length).toBeGreaterThan(before); + for (let n = 1; n < takenAts.length; n++) + expect(takenAts[n] as number).toBeGreaterThan(takenAts[n - 1] as number); + } finally { + Date.now = realNow; + } +}); + +test("a live tab whose event reaches the hub late is in the list, because the list is a snapshot of the browser", async () => { + const browser = new FakeBrowser([tab(1)]); + const { writes } = await serve(browser, { + holdsEveryMs: 60, + holders: { launchpad: [id(1)] }, + }); + // Chrome has the target, but its targetCreated has not reached the hub. + browser.targets.set(id(7), tab(7)); + const before = writes.length; + await until(() => writes.length > before + 1); + expect(service?.hub.list().some((t) => t.id === id(7))).toBe(false); + expect(writes.at(-1)).toEqual([id(1), id(7)]); +}); + +test("a holder directory that cannot be read lets no tab go until a read succeeds and shows the release", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "people-holders-"))); + const holders = join(dir, "holders"); + await mkdir(holders, { mode: 0o700 }); + const hold = (tabs: string[]) => + writeFile( + join(holders, "launchpad.json"), + JSON.stringify({ strip: "launchpad", label: "Launchpad", tabs }), + ); + await hold([id(1)]); + const browser = new FakeBrowser([tab(1)]); + const { refresh } = await serve(browser, { + releaseGraceMs: 40, + unviewedGraceMs: 60_000, + holds: () => readHolds({ holders }), + }); + try { + expect( + service?.hub.list().find((t) => t.id === id(1))?.holders, + ).toHaveLength(1); + // The directory becomes unreadable: the hub keeps its last reading. + await chmod(holders, 0o000); + refresh(); + await settle(300); + expect(closedTargets(browser)).toEqual([]); + expect( + service?.hub.list().find((t) => t.id === id(1))?.holders, + ).toHaveLength(1); + // Readable again, and the Launchpad let go: now the release counts. + await chmod(holders, 0o700); + await hold([]); + refresh(); + await until(() => closedTargets(browser).includes(id(1))); + } finally { + await chmod(holders, 0o700).catch(() => undefined); + await rm(dir, { recursive: true, force: true }); + } +}); + +test("an agent whose .pid cannot be understood keeps its tab; a .pid of an ended process lets it go", async () => { + const dir = await realpath(await mkdtemp(join(tmpdir(), "people-sockets-"))); + const sockets = join(dir, "agent-browser"); + await mkdir(sockets, { mode: 0o700 }); + await writeFile( + join(sockets, "codex-1.target"), + JSON.stringify({ targetId: id(1) }), + ); + await writeFile(join(sockets, "codex-1.pid"), ""); + const browser = new FakeBrowser([tab(1)]); + const { refresh } = await serve(browser, { + releaseGraceMs: 40, + unviewedGraceMs: 60, + holds: () => readHolds({ sockets }), + }); + try { + await settle(300); + expect(closedTargets(browser)).toEqual([]); + // A well-formed id of a process that does not run: the agent ended. + await writeFile(join(sockets, "codex-1.pid"), "4194303"); + refresh(); + await until(() => closedTargets(browser).includes(id(1))); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test("while the browser is away, close and restore change nothing and answer 503", async () => { + const browser = new FakeBrowser([tab(1)]); + const { base } = await serve(browser, { + reconnectMs: 60_000, + holders: { launchpad: [id(1)] }, + }); + // A viewer keeps the tab known to the hub across the lost connection. + const view = await person(base, id(1)); + await until(() => view.of("info").length > 0); + browser.drop(); + await until(() => service?.hub.connected === false); + expect(service?.hub.hasTab(id(1))).toBe(true); + for (const action of ["close", "restore"]) + expect( + ( + await loopback(base, `/.lazurio/tabs/${id(1)}/${action}`, { + method: "POST", + }) + ).status, + ).toBe(503); + expect(service?.hub.list().find((t) => t.id === id(1))?.closing).toBeNull(); + view.socket.close(); +}); + +// ---- The hub's blank window (F47 point 5) ----------------------------------------- + +test("the hub keeps Chrome's first window as its own, never closes or lists it, and opens another when someone uses it", async () => { + const browser = new FakeBrowser([tab(1)]); + const { base } = await serve(browser, { graceMs: 30, working: [id(1)] }); + await settle(100); + expect(closedTargets(browser)).toEqual([]); + expect(browser.called("Target.createTarget")).toEqual([]); + const listed = (await loopback(base, "/.lazurio/tabs")).body + .tabs as TabFacts[]; + expect(listed.map((t) => t.id)).toEqual([id(1)]); + // Someone navigated it: a tab like any other now, and a new blank one. + browser.emit("Target.targetInfoChanged", { + targetInfo: { + type: "page", + targetId: firstWindow, + url: "https://example.com/x", + title: "", + }, + }); + await until(() => browser.called("Target.createTarget").length === 1); + expect(browser.called("Target.createTarget")[0]?.params).toEqual({ + url: "about:blank", + newWindow: true, + }); + // The used window nobody views or holds ends; the new one stays. + await until(() => closedTargets(browser).includes(firstWindow)); + await settle(100); + expect(closedTargets(browser)).toEqual([firstWindow]); +}); + +test("a browser without a blank window gets one from the hub, and another when it closes", async () => { + const browser = new FakeBrowser([tab(1)], false); + await serve(browser, { working: [id(1)] }); + await until(() => browser.called("Target.createTarget").length === 1); + const keeper = id(100); + browser.emit("Target.targetDestroyed", { targetId: keeper }); + await until(() => browser.called("Target.createTarget").length === 2); +}); + +// ---- The Launchpad's requests (F47 point 6) --------------------------------------- + +test("the tab requests exist only on loopback and never for a browser", () => { + const ask = ( + method: string, + path: string, + headers: { + host?: string; + origin?: string | null; + fetchSite?: string | null; + } = {}, + ) => + classifyRequest( + { + method, + url: `http://x${path}`, + host: headers.host ?? "127.0.0.1:4100", + origin: headers.origin ?? null, + upgrade: false, + fetchSite: headers.fetchSite ?? null, + }, + origin, + 4100, + ); + const target = id(5); + expect(ask("GET", "/.lazurio/tabs")).toEqual({ kind: "tabs" }); + expect(ask("GET", "/.lazurio/tabs", { host: "localhost:4100" })).toEqual({ + kind: "tabs", + }); + expect(ask("POST", "/.lazurio/tabs")).toEqual({ kind: "open" }); + expect(ask("POST", `/.lazurio/tabs/${target}/close`)).toEqual({ + kind: "close", + targetId: target, + }); + expect(ask("POST", `/.lazurio/tabs/${target.toLowerCase()}/restore`)).toEqual( + { + kind: "restore", + targetId: target, + }, + ); + // Through the gateway, which sends the view's own host, they do not exist. + expect(ask("GET", "/.lazurio/tabs", { host })).toEqual({ + kind: "refused", + status: 404, + error: "not-found", + }); + expect( + ask("POST", `/.lazurio/tabs/${target}/close`, { host, origin }), + ).toMatchObject({ status: 404 }); + // A page in the Environment's own browser reaches the loopback too. + for (const headers of [ + { origin: "https://evil.example" }, + { origin: "null" }, + { fetchSite: "cross-site" }, + { fetchSite: "same-origin" }, + { fetchSite: "none" }, + ]) + expect(ask("POST", `/.lazurio/tabs/${target}/close`, headers)).toEqual({ + kind: "refused", + status: 403, + error: "browser-request", + }); + expect(ask("GET", `/.lazurio/tabs/${target}/close`)).toMatchObject({ + status: 405, + }); + expect(ask("DELETE", "/.lazurio/tabs")).toMatchObject({ status: 405 }); + expect( + ask("GET", "/.lazurio/tabs/nope/close", { host: "127.0.0.1:4100" }), + ).toMatchObject({ + status: 421, + }); +}); + +test("the list names each tab's opener, holders, agents and countdown", async () => { + const browser = new FakeBrowser([ + tab(1), + tab(2, { url: "https://example.com/p" }), + ]); + const { base } = await serve(browser, { + working: [id(1)], + holders: { "t3-x": [id(2)], launchpad: [id(2)] }, + unviewedGraceMs: 60_000, + }); + const opened = await loopback(base, "/.lazurio/tabs", { + method: "POST", + body: { url: "https://example.com/new", strip: "t3-x" }, + }); + expect(opened.status).toBe(201); + expect(opened.body.view).toBe(`${origin}/t/${opened.body.id}`); + expect(browser.called("Target.createTarget")[0]?.params).toEqual({ + url: "https://example.com/new", + newWindow: true, + }); + const listed = (await loopback(base, "/.lazurio/tabs")).body + .tabs as TabFacts[]; + const byId = new Map(listed.map((t) => [t.id, t])); + expect(byId.get(id(1))).toMatchObject({ + opener: { kind: "agent", session: `codex-${id(1).slice(-4)}` }, + agents: [`codex-${id(1).slice(-4)}`], + holders: [], + grace: null, + closing: null, + }); + expect( + byId + .get(id(2)) + ?.holders.map((h) => h.strip) + .sort(), + ).toEqual(["launchpad", "t3-x"]); + const fresh = byId.get(String(opened.body.id)); + expect(fresh?.opener).toEqual({ kind: "new-tab", strip: "t3-x" }); + expect(fresh?.grace?.kind).toBe("unviewed"); + expect(Date.parse(String(fresh?.openedAt))).toBeGreaterThan(0); + expect(byId.has(firstWindow)).toBe(false); + // Not an address the bar may open, or a strip that is no file name. + for (const body of [ + { url: "javascript:alert(1)" }, + { url: "file:///etc/passwd" }, + { url: "https://example.com", strip: "../x" }, + { url: 42 }, + ]) + expect( + (await loopback(base, "/.lazurio/tabs", { method: "POST", body })).status, + ).toBe(400); + // A browser's request is refused even on loopback. + const refused = await loopback(base, "/.lazurio/tabs", { + headers: { Origin: "https://evil.example" }, + }); + expect(refused.status).toBe(403); +}); + +test("a close for everyone ends a held tab after the grace, and Vrátit within it keeps the tab", async () => { + const browser = new FakeBrowser([tab(1), tab(2)]); + const { base } = await serve(browser, { + releaseGraceMs: 80, + working: [id(2)], + holders: { "t3-a": [id(1)], launchpad: [id(2)] }, + }); + const closing = await loopback(base, `/.lazurio/tabs/${id(1)}/close`, { + method: "POST", + }); + expect(closing.status).toBe(202); + expect(Date.parse(String(closing.body.closing))).toBeGreaterThan( + Date.now() - 1_000, ); + const restored = await loopback(base, `/.lazurio/tabs/${id(1)}/restore`, { + method: "POST", + }); + expect(restored).toEqual({ status: 200, body: { restored: true } }); + await settle(160); + expect(closedTargets(browser)).toEqual([]); + // Held and with an agent working in it: closed for everyone all the same. + expect( + (await loopback(base, `/.lazurio/tabs/${id(2)}/close`, { method: "POST" })) + .status, + ).toBe(202); + const listed = (await loopback(base, "/.lazurio/tabs")).body + .tabs as TabFacts[]; + expect(listed.find((t) => t.id === id(2))?.closing).not.toBeNull(); + await until(() => closedTargets(browser).includes(id(2))); + // Unknown tabs and nothing to restore. + expect( + (await loopback(base, `/.lazurio/tabs/${id(77)}/close`, { method: "POST" })) + .status, + ).toBe(404); + expect( + ( + await loopback(base, `/.lazurio/tabs/${id(77)}/restore`, { + method: "POST", + }) + ).status, + ).toBe(404); + expect( + ( + await loopback(base, `/.lazurio/tabs/${id(1)}/restore`, { + method: "POST", + }) + ).status, + ).toBe(409); + // The hub's blank window cannot be closed this way. + expect( + ( + await loopback(base, `/.lazurio/tabs/${firstWindow}/close`, { + method: "POST", + }) + ).status, + ).toBe(404); +}); + +test("a close for everyone whose grace ends while the browser is away finishes after the reconnect", async () => { + const browser = new FakeBrowser([tab(1)]); + const { base } = await serve(browser, { + releaseGraceMs: 40, + reconnectMs: 250, + holders: { launchpad: [id(1)] }, + }); + expect( + (await loopback(base, `/.lazurio/tabs/${id(1)}/close`, { method: "POST" })) + .status, + ).toBe(202); + browser.drop(); await settle(120); - const closed = browser - .called("Target.closeTarget") - .map((c) => c.params.targetId); - expect(closed).not.toContain(id(3)); - expect(closed).not.toContain(id(1)); + expect(service?.hub.connected).toBe(false); + expect(closedTargets(browser)).toEqual([]); + await until(() => closedTargets(browser).includes(id(1))); }); test("a tab that goes away tells its viewers", async () => { @@ -1058,7 +1660,7 @@ test("a slow viewer holds Chrome's next frame instead of the service's memory", const browser = new FakeBrowser([tab(1)]); const hub = new BrowserHub({ connect: browser.connect, - boundTargets: async () => new Set(), + holds: async () => noHolds, uploadDirectory: tmpdir(), screen: { width: 1920, height: 1080 }, log: () => {},