diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b51..24e1e6bd1a 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -17,7 +17,7 @@ import { chromium, type Browser, type BrowserContext, type BrowserContextOptions, type Page, type Locator, type Cookie } from 'playwright'; import { writeSecureFile, mkdirSecure } from './file-permissions'; -import { addConsoleEntry, addNetworkEntry, addDialogEntry, networkBuffer, type DialogEntry } from './buffers'; +import { CircularBuffer, type LogEntry, type NetworkEntry, type DialogEntry } from './buffers'; import { emitActivity } from './activity'; import { validateNavigationUrl } from './url-validation'; import { TabSession, type RefEntry } from './tab-session'; @@ -146,43 +146,143 @@ export interface BrowserState { }>; } +/** + * Per-session isolated browser state (agent-browser U4). + * + * Each session owns its own Playwright BrowserContext (isolated cookies / + * localStorage / storage), its own tab set, its own request headers / UA / + * viewport, and its own observability buffers. The shared Browser and the + * global tab-ID allocator live on BrowserManager; everything a concurrent + * session must NOT share lives here. A default session ("default") reproduces + * the pre-U4 single-session behavior for callers that send no `session`. + */ +export interface SessionState { + context: BrowserContext | null; + pages: Map; + tabSessions: Map; + activeTabId: number; + extraHeaders: Record; + customUserAgent: string | null; + deviceScaleFactor: number; + currentViewport: { width: number; height: number }; + tabOwnership: Map; + dialogAutoAccept: boolean; + dialogPromptText: string | null; + cookieImportedDomains: Set; + tabGuardrailSoftHit: boolean; + tabGuardrailHardHit: boolean; + consoleBuffer: CircularBuffer; + networkBuffer: CircularBuffer; + dialogBuffer: CircularBuffer; + /** + * Origin-scoped storage injections (agent-browser U5). Re-applied on context + * recreation so a viewport/scale change doesn't silently drop injected auth. + */ + authInitScripts: Array<{ origin: string; key: string; val: string }>; + /** Whether Playwright tracing is currently recording for this session (U8). */ + tracing: boolean; +} + +const SESSION_BUFFER_CAP = 50_000; + +function createSessionState(): SessionState { + return { + context: null, + pages: new Map(), + tabSessions: new Map(), + activeTabId: 0, + extraHeaders: {}, + customUserAgent: null, + deviceScaleFactor: 1, + currentViewport: { width: 1280, height: 720 }, + tabOwnership: new Map(), + dialogAutoAccept: true, + dialogPromptText: null, + cookieImportedDomains: new Set(), + tabGuardrailSoftHit: false, + tabGuardrailHardHit: false, + consoleBuffer: new CircularBuffer(SESSION_BUFFER_CAP), + networkBuffer: new CircularBuffer(SESSION_BUFFER_CAP), + dialogBuffer: new CircularBuffer(SESSION_BUFFER_CAP), + authInitScripts: [], + tracing: false, + }; +} + +/** The origin-scoped localStorage-injection init script (runs in the page). */ +export function originScopedStorageInit(arg: { origin: string; key: string; val: string }): void { + // Only write when the page's origin matches — the bearer must never leak to a + // non-app origin (agent-browser R5). + if (location.origin === arg.origin) { + try { + window.localStorage.setItem(arg.key, arg.val); + } catch { + // localStorage can be unavailable (sandboxed/opaque origin); best-effort. + } + } +} + +export const DEFAULT_SESSION_ID = 'default'; + export class BrowserManager { private browser: Browser | null = null; - private context: BrowserContext | null = null; // Proxy config applied to chromium.launch() when set (D8). Set by server.ts // at startup based on BROWSE_PROXY_URL. For SOCKS5 with auth, server.ts // points this at the local bridge (socks5://127.0.0.1:); for // HTTP/HTTPS or unauth SOCKS5, it's the upstream URL directly. private proxyConfig: { server: string; username?: string; password?: string } | null = null; - private pages: Map = new Map(); - private tabSessions: Map = new Map(); - private activeTabId: number = 0; - private nextTabId: number = 1; - private extraHeaders: Record = {}; - private customUserAgent: string | null = null; - // ─── Viewport + deviceScaleFactor (context options) ────────── - // Tracked at the manager level so recreateContext() preserves them. - // deviceScaleFactor is a *context* option, not a page-level setter — changes - // require recreateContext(). Viewport width/height can change on-page, but we - // track the latest so context recreation restores it instead of hardcoding 1280x720. - private deviceScaleFactor: number = 1; - private currentViewport: { width: number; height: number } = { width: 1280, height: 720 }; + // ─── Per-session isolated state (agent-browser U4) ────────── + // The daemon holds one shared Browser and a map of isolated sessions. Every + // per-tab / per-context field below is a private accessor into the CURRENT + // session's bundle, so existing method bodies (this.context, this.pages, …) + // operate on the active session unchanged. Session routing (server.ts) swaps + // currentSessionId around each command, mirroring the activeTabId save/restore. + private sessions: Map = new Map(); + private currentSessionId: string = DEFAULT_SESSION_ID; + + /** Current session bundle, lazily created (so `new BrowserManager()` works). */ + private get cur(): SessionState { + let state = this.sessions.get(this.currentSessionId); + if (!state) { + state = createSessionState(); + this.sessions.set(this.currentSessionId, state); + } + return state; + } + + private get context(): BrowserContext | null { return this.cur.context; } + private set context(value: BrowserContext | null) { this.cur.context = value; } + private get pages(): Map { return this.cur.pages; } + private get tabSessions(): Map { return this.cur.tabSessions; } + private get activeTabId(): number { return this.cur.activeTabId; } + private set activeTabId(value: number) { this.cur.activeTabId = value; } + private get extraHeaders(): Record { return this.cur.extraHeaders; } + private set extraHeaders(value: Record) { this.cur.extraHeaders = value; } + private get customUserAgent(): string | null { return this.cur.customUserAgent; } + private set customUserAgent(value: string | null) { this.cur.customUserAgent = value; } + private get deviceScaleFactor(): number { return this.cur.deviceScaleFactor; } + private set deviceScaleFactor(value: number) { this.cur.deviceScaleFactor = value; } + private get currentViewport(): { width: number; height: number } { return this.cur.currentViewport; } + private set currentViewport(value: { width: number; height: number }) { this.cur.currentViewport = value; } + private get tabOwnership(): Map { return this.cur.tabOwnership; } + private get dialogAutoAccept(): boolean { return this.cur.dialogAutoAccept; } + private set dialogAutoAccept(value: boolean) { this.cur.dialogAutoAccept = value; } + private get dialogPromptText(): string | null { return this.cur.dialogPromptText; } + private set dialogPromptText(value: string | null) { this.cur.dialogPromptText = value; } + private get cookieImportedDomains(): Set { return this.cur.cookieImportedDomains; } + private get tabGuardrailSoftHit(): boolean { return this.cur.tabGuardrailSoftHit; } + private set tabGuardrailSoftHit(value: boolean) { this.cur.tabGuardrailSoftHit = value; } + private get tabGuardrailHardHit(): boolean { return this.cur.tabGuardrailHardHit; } + private set tabGuardrailHardHit(value: boolean) { this.cur.tabGuardrailHardHit = value; } + + // Tab-ID allocator is daemon-wide so tab ids stay globally unique across + // sessions (routing by tabId remains unambiguous). + private nextTabId: number = 1; /** Server port — set after server starts, used by cookie-import-browser command */ public serverPort: number = 0; - // ─── Tab Ownership (multi-agent isolation) ────────────── - // Maps tabId → clientId. Unowned tabs (not in this map) are root-only for writes. - private tabOwnership: Map = new Map(); - - // ─── Dialog Handling (global, not per-tab) ────────────────── - private dialogAutoAccept: boolean = true; - private dialogPromptText: string | null = null; - - // ─── Cookie Origin Tracking ──────────────────────────────── - private cookieImportedDomains: Set = new Set(); - // ─── Handoff State ───────────────────────────────────────── private isHeaded: boolean = false; private consecutiveFailures: number = 0; @@ -208,8 +308,8 @@ export class BrowserManager { // appears in the activity feed even when the sidebar is closed. private static readonly TAB_GUARDRAIL_SOFT = 50; private static readonly TAB_GUARDRAIL_HARD = 200; - private tabGuardrailSoftHit = false; - private tabGuardrailHardHit = false; + // tabGuardrailSoftHit / tabGuardrailHardHit are per-session (SessionState), + // exposed via accessors above so checkTabGuardrails() stays unchanged. /** * Called from context.on('page') after a new tab is tracked. Emits at @@ -937,6 +1037,116 @@ export class BrowserManager { return tabs; } + // ─── Isolated session management (agent-browser U4) ──────── + /** The current session id commands resolve against. */ + getCurrentSessionId(): string { return this.currentSessionId; } + + /** All known session ids (default is created lazily on first access). */ + listSessionIds(): string[] { return [...this.sessions.keys()]; } + + hasSession(id: string): boolean { return this.sessions.has(id); } + + /** Switch the active session. Bundle is created lazily; context is not. */ + setCurrentSession(id: string): void { this.currentSessionId = id; } + + /** The current session's observability buffers (read/flush paths). */ + getBuffers(): { consoleBuffer: CircularBuffer; networkBuffer: CircularBuffer; dialogBuffer: CircularBuffer } { + const state = this.cur; + return { consoleBuffer: state.consoleBuffer, networkBuffer: state.networkBuffer, dialogBuffer: state.dialogBuffer }; + } + + /** Every session's bundle (server flush iterates all so no session's logs are lost). */ + getAllSessions(): Array<{ id: string; state: SessionState }> { + return [...this.sessions.entries()].map(([id, state]) => ({ id, state })); + } + + /** + * Ensure a session has its own isolated BrowserContext + first tab, creating + * it on the shared Browser if absent. Switches the active session to `id`. + * Requires the browser to be launched (launch() creates the default session). + */ + async ensureSession(id: string): Promise { + if (!this.browser) { + throw new Error('Browser not launched. ensureSession requires an active browser.'); + } + this.currentSessionId = id; + const state = this.cur; + if (state.context) return; + + const contextOptions: BrowserContextOptions = { + viewport: { width: state.currentViewport.width, height: state.currentViewport.height }, + deviceScaleFactor: state.deviceScaleFactor, + }; + if (state.customUserAgent) contextOptions.userAgent = state.customUserAgent; + state.context = await this.browser.newContext(contextOptions); + if (Object.keys(state.extraHeaders).length > 0) { + await state.context.setExtraHTTPHeaders(state.extraHeaders); + } + const { applyStealth } = await import('./stealth'); + await applyStealth(state.context); + await this.newTab(); + } + + /** + * Inject an origin-scoped localStorage value into the current session's + * context (agent-browser U5). The value is written by an init script ONLY on + * pages whose origin === appOrigin, so a bearer token can never leak to a + * non-app origin. Applies to future navigations (inject before navigating); + * recorded so context recreation re-applies it. Payload must be a non-empty + * JSON object (adapter-level shape validation already happened at acquire). + */ + async injectOriginScopedStorage(appOrigin: string, storageKey: string, valueJson: string): Promise { + const context = this.cur.context; + if (!context) throw new Error('No browser context for the current session; open a session first.'); + if (!appOrigin || !storageKey) throw new Error('inject requires both an appOrigin and a storageKey.'); + let parsed: unknown; + try { + parsed = JSON.parse(valueJson); + } catch { + throw new Error('inject value is not valid JSON.'); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('inject value must be a non-empty JSON object.'); + } + this.cur.authInitScripts.push({ origin: appOrigin, key: storageKey, val: valueJson }); + await context.addInitScript(originScopedStorageInit, { origin: appOrigin, key: storageKey, val: valueJson }); + } + + // ─── Playwright tracing (agent-browser U8) ───────────────── + isTracing(): boolean { return this.cur.tracing; } + + /** Start recording a Playwright trace for the current session's context. */ + async startTracing(): Promise { + const context = this.cur.context; + if (!context) throw new Error('No browser context for the current session; open a session first.'); + if (this.cur.tracing) return; + await context.tracing.start({ screenshots: true, snapshots: true }); + this.cur.tracing = true; + } + + /** Stop tracing and write the trace zip to outPath (view with `browse show-trace`). */ + async stopTracing(outPath: string): Promise { + const context = this.cur.context; + if (!context) throw new Error('No browser context for the current session.'); + if (!this.cur.tracing) throw new Error('Tracing is not active for this session; run `trace start` first.'); + await context.tracing.stop({ path: outPath }); + this.cur.tracing = false; + } + + /** Close a session's context + tabs and drop it. Cannot close the default. */ + async closeSession(id: string): Promise { + if (id === DEFAULT_SESSION_ID) throw new Error('Cannot close the default session.'); + const state = this.sessions.get(id); + if (!state) return; + try { + await state.context?.close(); + } catch { + // Best-effort: context may already be gone if the browser disconnected. + } + this.sessions.delete(id); + if (this.currentSessionId === id) this.currentSessionId = DEFAULT_SESSION_ID; + } + // ─── Session Access ──────────────────────────────────────── /** Get the TabSession for the active tab. */ getActiveSession(): TabSession { @@ -1431,6 +1641,13 @@ export class BrowserManager { const { applyStealth } = await import('./stealth'); await applyStealth(this.context); + // Re-apply origin-scoped storage injections (agent-browser U5): a fresh + // context has no init scripts, so a viewport/scale rebuild would silently + // drop injected auth without this. + for (const inject of this.cur.authInitScripts) { + await this.context.addInitScript(originScopedStorageInit, inject); + } + if (Object.keys(this.extraHeaders).length > 0) { await this.context.setExtraHTTPHeaders(this.extraHeaders); } @@ -1457,6 +1674,9 @@ export class BrowserManager { // Stealth applies to the fallback blank context too. const { applyStealth } = await import('./stealth'); await applyStealth(this.context); + for (const inject of this.cur.authInitScripts) { + await this.context.addInitScript(originScopedStorageInit, inject); + } await this.newTab(); this.clearRefs(); } catch { @@ -1683,22 +1903,35 @@ export class BrowserManager { // ─── Console/Network/Dialog/Ref Wiring ──────────────────── private wirePageEvents(page: Page) { + // Bind every listener to the session that OWNS this page at wire-time. + // These events fire asynchronously — after the daemon may have switched the + // active session for another command — so resolving state via the current + // session accessors here would cross-contaminate sessions. Capture the + // owner now (wirePageEvents runs synchronously inside the owning session's + // newTab) and mutate its bundle directly. (agent-browser U4) + const owner = this.cur; // Track tab close — remove from pages and sessions maps, switch to another tab page.on('close', () => { - for (const [id, p] of this.pages) { + for (const [id, p] of owner.pages) { if (p === page) { - this.pages.delete(id); - this.tabSessions.delete(id); - console.log(`[browse] Tab closed (id=${id}, remaining=${this.pages.size})`); + owner.pages.delete(id); + owner.tabSessions.delete(id); + console.log(`[browse] Tab closed (id=${id}, remaining=${owner.pages.size})`); // If the closed tab was active, switch to another - if (this.activeTabId === id) { - const remaining = [...this.pages.keys()]; - this.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : 0; + if (owner.activeTabId === id) { + const remaining = [...owner.pages.keys()]; + owner.activeTabId = remaining.length > 0 ? remaining[remaining.length - 1] : 0; } break; } } - this.recheckTabGuardrailsOnClose(); + // Re-arm this session's tab guardrails. + if (owner.tabGuardrailSoftHit && owner.pages.size < BrowserManager.TAB_GUARDRAIL_SOFT) { + owner.tabGuardrailSoftHit = false; + } + if (owner.tabGuardrailHardHit && owner.pages.size < BrowserManager.TAB_GUARDRAIL_HARD) { + owner.tabGuardrailHardHit = false; + } }); // Clear ref map on navigation — refs point to stale elements after page change @@ -1706,7 +1939,7 @@ export class BrowserManager { page.on('framenavigated', (frame) => { if (frame === page.mainFrame()) { // Find the TabSession for this page and clear its per-tab state - for (const session of this.tabSessions.values()) { + for (const session of owner.tabSessions.values()) { if (session.page === page) { session.onMainFrameNavigated(); break; @@ -1722,14 +1955,14 @@ export class BrowserManager { type: dialog.type(), message: dialog.message(), defaultValue: dialog.defaultValue() || undefined, - action: this.dialogAutoAccept ? 'accepted' : 'dismissed', - response: this.dialogAutoAccept ? (this.dialogPromptText ?? undefined) : undefined, + action: owner.dialogAutoAccept ? 'accepted' : 'dismissed', + response: owner.dialogAutoAccept ? (owner.dialogPromptText ?? undefined) : undefined, }; - addDialogEntry(entry); + owner.dialogBuffer.push(entry); try { - if (this.dialogAutoAccept) { - await dialog.accept(this.dialogPromptText ?? undefined); + if (owner.dialogAutoAccept) { + await dialog.accept(owner.dialogPromptText ?? undefined); } else { await dialog.dismiss(); } @@ -1739,7 +1972,7 @@ export class BrowserManager { }); page.on('console', (msg) => { - addConsoleEntry({ + owner.consoleBuffer.push({ timestamp: Date.now(), level: msg.type(), text: msg.text(), @@ -1747,7 +1980,7 @@ export class BrowserManager { }); page.on('request', (req) => { - addNetworkEntry({ + owner.networkBuffer.push({ timestamp: Date.now(), method: req.method(), url: req.url(), @@ -1758,6 +1991,7 @@ export class BrowserManager { // Find matching request entry and update it (backward scan) const url = res.url(); const status = res.status(); + const networkBuffer = owner.networkBuffer; for (let i = networkBuffer.length - 1; i >= 0; i--) { const entry = networkBuffer.get(i); if (entry && entry.url === url && !entry.status) { @@ -1787,6 +2021,7 @@ export class BrowserManager { if (!sizes) return; const url = req.url(); const size = sizes.responseBodySize ?? 0; + const networkBuffer = owner.networkBuffer; for (let i = networkBuffer.length - 1; i >= 0; i--) { const entry = networkBuffer.get(i); if (entry && entry.url === url && !entry.size) { diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b7923..091969d0e0 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -18,6 +18,8 @@ import { resolveConfig, ensureStateDir, readVersionHash } from './config'; import { parseProxyConfig, computeConfigHash, ProxyConfigError } from './proxy-config'; import { redactProxyUrl } from './proxy-redact'; import { spawnTerminalAgent } from './terminal-agent-control'; +import { loadAdapter } from './frontend-adapter'; +import { acquireOrLoad } from './token-manager'; const config = resolveConfig(); const IS_WINDOWS = process.platform === 'win32'; @@ -1039,6 +1041,130 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: const command = args[0]; const commandArgs = args.slice(1); + // ─── Agent auto-login token (pre-server command) ──────────── + // agent-token acquires (or reuses a cached) bearer for a frontend env via + // the repo's .agent-browser.config.mjs adapter. Runs CLI-side so `op` / + // Touch-ID prompt in the user's terminal, not the daemon. Pure HTTP + op; + // no browser needed. Default output is a secret-free summary; --print emits + // the bare bearer on stdout (summary moves to stderr) for shell capture like + // BACKEND_JWT=$(browse agent-token --print). + if (command === 'agent-token') { + const envName = commandArgs.find((arg) => !arg.startsWith('-')); + const forceRefresh = commandArgs.includes('--refresh'); + const printBearer = commandArgs.includes('--print'); + if (!envName) { + console.error('[browse] error: agent-token needs an env, e.g. `browse agent-token demo`'); + process.exit(1); + } + try { + const adapter = await loadAdapter(process.cwd()); + const { token, cached } = await acquireOrLoad(adapter, envName, { + frontendRoot: process.cwd(), + forceRefresh, + }); + // Machine-parseable, secret-free summary. With --print it goes to stderr + // so stdout carries exactly the bearer and nothing else. + const summary = printBearer ? console.error : console.log; + summary(`ENV=${token.env}`); + summary(`REALM=${token.realm}`); + summary(`APPORIGIN=${token.appOrigin}`); + summary(`EXPIRES=${new Date(token.expiresAt).toISOString()}`); + summary(`CACHED=${cached ? 'true' : 'false'}`); + if (printBearer) { + process.stdout.write(adapter.token.bearer(token.auth) + '\n'); + } + process.exit(0); + } catch (err) { + console.error(`[browse] agent-token failed: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } + + // ─── Trace viewer (agent-browser U8, pre-server) ──────────── + // Opens a trace zip produced by `trace stop` in Playwright's trace viewer. + // Runs CLI-side (no daemon) — it launches its own viewer UI. + if (command === 'show-trace') { + const traceFile = commandArgs.find((arg) => !arg.startsWith('-')); + if (!traceFile) { + console.error('[browse] error: show-trace needs a trace file, e.g. `browse show-trace trace.zip`'); + process.exit(1); + } + const viewer = nodeSpawn('npx', ['playwright', 'show-trace', traceFile], { stdio: 'inherit' }); + viewer.on('error', (err) => { + console.error(`[browse] could not launch the Playwright trace viewer: ${err.message}`); + process.exit(1); + }); + viewer.on('exit', (code) => process.exit(code ?? 0)); + return; + } + + // ─── Agent-browser end-to-end entrypoint (agent-browser U6) ── + // env [path] → isolated headless session, cached-or-acquired token injected + // origin-scoped, navigated. Hides the session id (auto-generated) and emits a + // parseable, secret-free summary. --session overrides the id; --headed is + // deferred to Phase 2 (headless only for now). + if (command === 'agent-open') { + const forceRefresh = commandArgs.includes('--refresh'); + const sessionFlagIdx = commandArgs.indexOf('--session'); + const explicitSession = sessionFlagIdx >= 0 ? commandArgs[sessionFlagIdx + 1] : undefined; + // Positionals = args that are neither flags nor a flag's value (--session + // takes one); naive startsWith('-') filtering leaked the session id into + // the nav path. + const positional = commandArgs.filter( + (arg, idx) => !arg.startsWith('-') && (sessionFlagIdx < 0 || idx !== sessionFlagIdx + 1), + ); + const envName = positional[0]; + const navPath = positional[1] ?? '/'; + if (commandArgs.includes('--headed')) { + console.error('[browse] --headed is deferred to a later phase; opening headless.'); + } + if (!envName) { + console.error('[browse] error: agent-open needs an env, e.g. `browse agent-open demo /profiles`'); + process.exit(1); + } + try { + const adapter = await loadAdapter(process.cwd()); + const { token, cached } = await acquireOrLoad(adapter, envName, { + frontendRoot: process.cwd(), + forceRefresh, + }); + const sessionId = + explicitSession ?? + `${process.env.USER ?? 'agent'}-${path.basename(process.cwd())}-${Math.random().toString(36).slice(2, 8)}`; + const url = token.appOrigin.replace(/\/$/, '') + (navPath.startsWith('/') ? navPath : `/${navPath}`); + + const state = await ensureServer(globalFlags); + const post = async (cmd: string, args: string[]) => { + const resp = await fetch(`http://127.0.0.1:${state.port}/command`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${state.token}` }, + body: JSON.stringify({ command: cmd, args, session: sessionId }), + signal: AbortSignal.timeout(30000), + }); + if (!resp.ok) { + throw new Error(`${cmd} failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`); + } + }; + + // inject-auth creates the session's isolated context, then registers the + // origin-scoped init script; the subsequent goto navigates with auth live. + await post('inject-auth', [token.appOrigin, adapter.token.storageKey, JSON.stringify(token.auth)]); + await post('goto', [url]); + + // Secret-free, parseable summary. + console.log(`SESSION=${sessionId}`); + console.log(`ENV=${token.env}`); + console.log(`REALM=${token.realm}`); + console.log(`URL=${url}`); + console.log(`EXPIRES=${new Date(token.expiresAt).toISOString()}`); + console.log(`CACHED=${cached ? 'true' : 'false'}`); + process.exit(0); + } catch (err) { + console.error(`[browse] agent-open failed: ${err instanceof Error ? err.message : err}`); + process.exit(1); + } + } + // ─── Headed Connect (pre-server command) ──────────────────── // connect must be handled BEFORE ensureServer() because it needs // to restart the server in headed mode with the Chrome extension. diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 73bc9ab1bf..71b7ebf498 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -27,6 +27,7 @@ export const WRITE_COMMANDS = new Set([ 'upload', 'dialog-accept', 'dialog-dismiss', 'style', 'cleanup', 'prettyscreenshot', 'download', 'scrape', 'archive', + 'inject-auth', 'trace', ]); export const META_COMMANDS = new Set([ @@ -139,6 +140,8 @@ export const COMMAND_DESCRIPTIONS: Record [path] [--base64] [--navigate]' }, 'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape [--selector sel] [--dir path] [--limit N]' }, 'archive': { category: 'Extraction', description: 'Save complete page as MHTML via CDP', usage: 'archive [path]' }, + 'inject-auth': { category: 'Interaction', description: 'Inject an origin-scoped localStorage value into the current session (agent-browser auto-login). Written only when the page origin matches appOrigin.', usage: 'inject-auth ' }, + 'trace': { category: 'Interaction', description: 'Record a Playwright trace for the current session; view a stopped trace with show-trace.', usage: 'trace start | trace stop ' }, // Visual 'screenshot': { category: 'Visual', description: 'Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work.', usage: 'screenshot [--selector ] [--viewport] [--clip x,y,w,h] [--base64] [selector|@ref] [path]' }, 'pdf': { category: 'Visual', description: 'Save the current page as PDF. Supports page layout (--format, --width, --height, --margins, --margin-*), structure (--toc waits for Paged.js), branding (--header-template, --footer-template, --page-numbers), accessibility (--tagged, --outline), and --from-file for large payloads. Use --tab-id to target a specific tab.', usage: 'pdf [path] [--format letter|a4|legal] [--width --height ] [--margins ] [--margin-top --margin-right --margin-bottom --margin-left ] [--header-template ] [--footer-template ] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id ] | pdf --from-file [--tab-id ]' }, diff --git a/browse/src/error-handling.ts b/browse/src/error-handling.ts index 2c4e271e87..9d07882d34 100644 --- a/browse/src/error-handling.ts +++ b/browse/src/error-handling.ts @@ -52,7 +52,13 @@ export function isProcessAlive(pid: number): boolean { try { process.kill(pid, 0); return true; - } catch { + } catch (err: any) { + // EPERM = the process EXISTS but we lack permission to signal it (owned by + // another user, or a reparented zombie). It is alive-but-unmanageable, NOT + // dead — treating it as dead is the zombie-lock bug (a stale "Chrome for + // Testing" holding SingletonLock that cleanup then skips). Only ESRCH (no + // such process) means dead. (agent-browser U7) + if (err?.code === 'EPERM') return true; return false; } } diff --git a/browse/src/frontend-adapter.ts b/browse/src/frontend-adapter.ts new file mode 100644 index 0000000000..b724c1f682 --- /dev/null +++ b/browse/src/frontend-adapter.ts @@ -0,0 +1,183 @@ +/** + * Frontend adapter loader + validator (engine-side, frontend-agnostic). + * + * Each consuming frontend repo ships a `.agent-browser.config.mjs` (symlinked + * from agent-tools/consumers//agent-browser.config.mjs). It declares that + * frontend's environments, realm groupings, auth HTTP state machine, and token + * localStorage contract. The engine (token-manager, injection, entrypoint) reads + * ONLY through this adapter — no xplor-specific knowledge lives in the engine. + * + * Adding a new frontend = author one config; zero engine change (R7). + * + * Schema (see docs/agent-browser-adapter.md for the authoring guide): + * + * export default { + * envs: { : { appOrigin, apiOrigin, opItem: { vault, item } } }, + * realm(envName) -> realmId, + * auth: { + * endpoint(apiOrigin) -> url, + * firstStep(creds) -> requestBody, + * afterFirstStep(data) -> { need: 'totp' } | { done: authObj } | { error: msg }, + * secondStep?(creds, totp) -> requestBody, + * afterSecondStep?(data) -> { done: authObj } | { error: msg }, + * }, + * token: { + * storageKey, // localStorage key the SPA reads on boot + * validate(obj) -> void, // throws if the auth object is unusable + * bearer(obj) -> string, // extract the JWT for expiry decoding + * }, + * defaultTtlMs?: number, // fallback lifetime if the JWT has no exp claim + * } + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { pathToFileURL } from 'url'; + +export interface OpItemRef { + vault: string; + item: string; +} + +export interface EnvConfig { + appOrigin: string; + apiOrigin: string; + opItem?: OpItemRef; +} + +export interface Creds { + userName: string; + password: string; +} + +export type FirstStepOutcome = + | { need: 'totp' } + | { done: unknown } + | { error: string }; + +export type SecondStepOutcome = { done: unknown } | { error: string }; + +export interface AuthContract { + endpoint(apiOrigin: string): string; + firstStep(creds: Creds): unknown; + afterFirstStep(data: unknown): FirstStepOutcome; + secondStep?(creds: Creds, totp: string): unknown; + afterSecondStep?(data: unknown): SecondStepOutcome; +} + +export interface TokenContract { + storageKey: string; + validate(obj: unknown): void; + bearer(obj: unknown): string; +} + +export interface FrontendAdapter { + envs: Record; + /** + * Optional fallback for pattern-based env names the static `envs` map cannot + * enumerate (e.g. xplor's open-ended `release-X-Y-Z` branches). Consulted only + * when the name is absent from `envs`. Return null for an unknown name. + */ + resolveEnvConfig?(envName: string): EnvConfig | null; + realm(envName: string): string; + auth: AuthContract; + token: TokenContract; + defaultTtlMs?: number; +} + +export const ADAPTER_FILENAME = '.agent-browser.config.mjs'; + +class AdapterError extends Error {} + +function assert(condition: unknown, message: string): asserts condition { + if (!condition) throw new AdapterError(message); +} + +/** + * Validate a loaded module's default export against the adapter contract. + * Fails fast with an actionable message rather than crashing later mid-flow. + */ +export function validateAdapter(mod: unknown, source: string): FrontendAdapter { + const where = `adapter ${source}`; + assert(mod && typeof mod === 'object', `${where}: config has no default export object`); + const adapter = mod as Record; + + assert( + adapter.envs && typeof adapter.envs === 'object', + `${where}: missing \`envs\` map (name -> { appOrigin, apiOrigin, opItem })`, + ); + for (const [name, raw] of Object.entries(adapter.envs as Record)) { + const env = raw as Record; + assert( + env && typeof env.appOrigin === 'string' && env.appOrigin.length > 0, + `${where}: env "${name}" missing \`appOrigin\``, + ); + assert( + typeof env.apiOrigin === 'string' && env.apiOrigin.length > 0, + `${where}: env "${name}" missing \`apiOrigin\``, + ); + } + + assert(typeof adapter.realm === 'function', `${where}: missing \`realm(envName)\` function`); + assert( + adapter.resolveEnvConfig === undefined || typeof adapter.resolveEnvConfig === 'function', + `${where}: \`resolveEnvConfig\` must be a function when present`, + ); + + const auth = adapter.auth as Record | undefined; + assert(auth && typeof auth === 'object', `${where}: missing \`auth\` block`); + assert(typeof auth.endpoint === 'function', `${where}: missing \`auth.endpoint(apiOrigin)\``); + assert(typeof auth.firstStep === 'function', `${where}: missing \`auth.firstStep(creds)\``); + assert( + typeof auth.afterFirstStep === 'function', + `${where}: missing \`auth.afterFirstStep(data)\``, + ); + + const token = adapter.token as Record | undefined; + assert(token && typeof token === 'object', `${where}: missing \`token\` block`); + assert( + typeof token.storageKey === 'string' && token.storageKey.length > 0, + `${where}: missing \`token.storageKey\``, + ); + assert(typeof token.validate === 'function', `${where}: missing \`token.validate(obj)\``); + assert(typeof token.bearer === 'function', `${where}: missing \`token.bearer(obj)\``); + + return adapter as unknown as FrontendAdapter; +} + +/** Resolve the adapter file path for a frontend repo root. */ +export function adapterPath(frontendRoot: string): string { + return path.join(frontendRoot, ADAPTER_FILENAME); +} + +/** + * Load + validate a frontend's adapter config from its repo root. + * `frontendRoot` defaults to the current working directory (the daemon runs + * from the frontend's worktree). Throws an actionable error naming the expected + * path when the file is missing. + */ +export async function loadAdapter(frontendRoot: string = process.cwd()): Promise { + const file = adapterPath(frontendRoot); + if (!fs.existsSync(file)) { + throw new AdapterError( + `No agent-browser adapter found at ${file}. ` + + `Author one at agent-tools/consumers//agent-browser.config.mjs ` + + `(symlinked here as ${ADAPTER_FILENAME}). See docs/agent-browser-adapter.md.`, + ); + } + const mod = await import(pathToFileURL(file).href); + return validateAdapter(mod.default ?? mod, file); +} + +/** Resolve an env by name from an adapter, attaching its computed realm. */ +export function resolveEnv( + adapter: FrontendAdapter, + envName: string, +): EnvConfig & { name: string; realm: string } { + const env = adapter.envs[envName] ?? adapter.resolveEnvConfig?.(envName) ?? null; + if (!env) { + const valid = Object.keys(adapter.envs).join(', '); + throw new AdapterError(`Unknown env "${envName}". Valid envs: ${valid}`); + } + return { ...env, name: envName, realm: adapter.realm(envName) }; +} diff --git a/browse/src/read-commands.ts b/browse/src/read-commands.ts index 4e1371a17c..b1361dd349 100644 --- a/browse/src/read-commands.ts +++ b/browse/src/read-commands.ts @@ -7,7 +7,8 @@ import type { TabSession } from './tab-session'; import type { BrowserManager } from './browser-manager'; -import { consoleBuffer, networkBuffer, dialogBuffer } from './buffers'; +// Observability buffers are per-session (agent-browser U4); resolved via +// bm.getBuffers() inside each handler so reads/clears hit the caller's session. import type { Page, Frame } from 'playwright'; import * as fs from 'fs'; import * as path from 'path'; @@ -371,6 +372,7 @@ export async function handleReadCommand( } case 'console': { + const { consoleBuffer } = bm.getBuffers(); if (args[0] === '--clear') { consoleBuffer.clear(); return 'Console buffer cleared.'; @@ -385,6 +387,7 @@ export async function handleReadCommand( } case 'network': { + const { networkBuffer } = bm.getBuffers(); if (args[0] === '--clear') { networkBuffer.clear(); return 'Network buffer cleared.'; @@ -440,6 +443,7 @@ export async function handleReadCommand( } case 'dialog': { + const { dialogBuffer } = bm.getBuffers(); if (args[0] === '--clear') { dialogBuffer.clear(); return 'Dialog buffer cleared.'; diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acce..11a639a388 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -13,7 +13,7 @@ * Port: random 10000-60000 (or BROWSE_PORT env for debug override) */ -import { BrowserManager } from './browser-manager'; +import { BrowserManager, DEFAULT_SESSION_ID } from './browser-manager'; import { handleReadCommand, hasOutArg } from './read-commands'; import { handleWriteCommand } from './write-commands'; import { handleMetaCommand } from './meta-commands'; @@ -571,9 +571,11 @@ function tmpStatePath(): string { // terminal-agent.ts; chat queue + per-tab agent multiplexing are no // longer needed. -let lastConsoleFlushed = 0; -let lastNetworkFlushed = 0; -let lastDialogFlushed = 0; +// Per-session flush cursors (agent-browser U4): buffers are now per-session, so +// each session tracks its own console/network/dialog flush position. Log lines +// are tagged with the session id when it is not the default, so a single log +// file stays readable across concurrent isolated sessions. +const flushCursors = new Map(); let flushInProgress = false; async function flushBuffers() { @@ -581,37 +583,44 @@ async function flushBuffers() { flushInProgress = true; try { - // Console buffer - const newConsoleCount = consoleBuffer.totalAdded - lastConsoleFlushed; - if (newConsoleCount > 0) { - const entries = consoleBuffer.last(Math.min(newConsoleCount, consoleBuffer.length)); - const lines = entries.map(e => - `[${new Date(e.timestamp).toISOString()}] [${e.level}] ${e.text}` - ).join('\n') + '\n'; - fs.appendFileSync(CONSOLE_LOG_PATH, lines); - lastConsoleFlushed = consoleBuffer.totalAdded; - } + const manager = activeBrowserManager ?? browserManager; + if (!manager) return; + const tag = (id: string) => (id === DEFAULT_SESSION_ID ? '' : `[session:${id}] `); + + for (const { id, state } of manager.getAllSessions()) { + const cursor = flushCursors.get(id) ?? { console: 0, network: 0, dialog: 0 }; + + const newConsole = state.consoleBuffer.totalAdded - cursor.console; + if (newConsole > 0) { + const entries = state.consoleBuffer.last(Math.min(newConsole, state.consoleBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${tag(id)}[${e.level}] ${e.text}` + ).join('\n') + '\n'; + fs.appendFileSync(CONSOLE_LOG_PATH, lines); + cursor.console = state.consoleBuffer.totalAdded; + } - // Network buffer - const newNetworkCount = networkBuffer.totalAdded - lastNetworkFlushed; - if (newNetworkCount > 0) { - const entries = networkBuffer.last(Math.min(newNetworkCount, networkBuffer.length)); - const lines = entries.map(e => - `[${new Date(e.timestamp).toISOString()}] ${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` - ).join('\n') + '\n'; - fs.appendFileSync(NETWORK_LOG_PATH, lines); - lastNetworkFlushed = networkBuffer.totalAdded; - } + const newNetwork = state.networkBuffer.totalAdded - cursor.network; + if (newNetwork > 0) { + const entries = state.networkBuffer.last(Math.min(newNetwork, state.networkBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${tag(id)}${e.method} ${e.url} → ${e.status || 'pending'} (${e.duration || '?'}ms, ${e.size || '?'}B)` + ).join('\n') + '\n'; + fs.appendFileSync(NETWORK_LOG_PATH, lines); + cursor.network = state.networkBuffer.totalAdded; + } + + const newDialog = state.dialogBuffer.totalAdded - cursor.dialog; + if (newDialog > 0) { + const entries = state.dialogBuffer.last(Math.min(newDialog, state.dialogBuffer.length)); + const lines = entries.map(e => + `[${new Date(e.timestamp).toISOString()}] ${tag(id)}[${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` + ).join('\n') + '\n'; + fs.appendFileSync(DIALOG_LOG_PATH, lines); + cursor.dialog = state.dialogBuffer.totalAdded; + } - // Dialog buffer - const newDialogCount = dialogBuffer.totalAdded - lastDialogFlushed; - if (newDialogCount > 0) { - const entries = dialogBuffer.last(Math.min(newDialogCount, dialogBuffer.length)); - const lines = entries.map(e => - `[${new Date(e.timestamp).toISOString()}] [${e.type}] "${e.message}" → ${e.action}${e.response ? ` "${e.response}"` : ''}` - ).join('\n') + '\n'; - fs.appendFileSync(DIALOG_LOG_PATH, lines); - lastDialogFlushed = dialogBuffer.totalAdded; + flushCursors.set(id, cursor); } } catch (err: any) { console.error('[browse] Buffer flush failed:', err.message); @@ -940,7 +949,7 @@ interface CommandResult { * chainDepth: recursion guard — reject nested chains (depth > 0 means inside a chain) */ async function handleCommandInternalImpl( - body: { command: string; args?: string[]; tabId?: number }, + body: { command: string; args?: string[]; tabId?: number; session?: string }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { @@ -1100,7 +1109,34 @@ async function handleCommandInternalImpl( // phase into the centralized wrap block below. let hiddenContentWarnings: string[] = []; - if (READ_COMMANDS.has(command)) { + if (command === 'trace') { + // agent-browser U8: per-session Playwright tracing. `trace start` / + // `trace stop `. View a stopped trace with `browse show-trace `. + const sub = args[0]; + if (sub === 'start') { + await browserManager.startTracing(); + result = JSON.stringify({ tracing: true, session: browserManager.getCurrentSessionId() }); + } else if (sub === 'stop') { + const outPath = args[1]; + if (!outPath) throw new Error('Usage: trace stop '); + await browserManager.stopTracing(outPath); + result = JSON.stringify({ tracing: false, path: outPath, session: browserManager.getCurrentSessionId() }); + } else { + throw new Error('Usage: trace start | trace stop '); + } + } else if (command === 'inject-auth') { + // agent-browser U5: origin-scoped localStorage injection into the routed + // session's context. args: . + const [appOrigin, storageKey, ...rest] = args; + const valueJson = rest.join(' '); + await browserManager.injectOriginScopedStorage(appOrigin, storageKey, valueJson); + result = JSON.stringify({ + injected: true, + appOrigin, + key: storageKey, + session: browserManager.getCurrentSessionId(), + }); + } else if (READ_COMMANDS.has(command)) { const isScoped = tokenInfo && tokenInfo.clientId !== 'root'; // Hidden-element / ARIA-injection detection for every scoped // DOM-reading channel (text, html, links, forms, accessibility, @@ -1307,12 +1343,36 @@ async function handleCommandInternalImpl( * Do not bypass this by calling handleCommandInternalImpl directly. */ async function handleCommandInternal( - body: { command: string; args?: string[]; tabId?: number }, + body: { command: string; args?: string[]; tabId?: number; session?: string }, tokenInfo?: TokenInfo | null, opts?: { skipRateCheck?: boolean; skipActivity?: boolean; chainDepth?: number }, ): Promise { - const cr = await handleCommandInternalImpl(body, tokenInfo, opts); - return { ...cr, result: sanitizeLoneSurrogates(cr.result) }; + // ─── Session pinning (agent-browser U4) ───────────────────── + // Resolve an optional `session` to an isolated BrowserContext before the + // command runs, restoring the prior session afterward so a subsequent + // command with no `session` field lands on the default session. This is the + // single choke point every caller (HTTP, batch, chain) passes through, so + // restoring here in a finally covers all early returns inside Impl. Safe + // because Bun's event loop is single-threaded — no concurrent handleCommand. + const requestedSession = body.session; + let savedSessionId: string | null = null; + if (requestedSession && requestedSession !== browserManager.getCurrentSessionId()) { + savedSessionId = browserManager.getCurrentSessionId(); + try { + await browserManager.ensureSession(requestedSession); + } catch (err: any) { + return { + status: 400, json: true, + result: JSON.stringify({ error: `Cannot open session "${requestedSession}": ${err.message}` }), + }; + } + } + try { + const cr = await handleCommandInternalImpl(body, tokenInfo, opts); + return { ...cr, result: sanitizeLoneSurrogates(cr.result) }; + } finally { + if (savedSessionId !== null) browserManager.setCurrentSession(savedSessionId); + } } /** @@ -2566,7 +2626,7 @@ export function buildFetchHandler(cfg: ServerConfig): ServerHandle { continue; } const cr = await handleCommandInternal( - { command: cmd.command, args: cmd.args, tabId: cmd.tabId }, + { command: cmd.command, args: cmd.args, tabId: cmd.tabId, session: cmd.session }, tokenInfo, { skipRateCheck: true, skipActivity: true }, ); diff --git a/browse/src/token-manager.ts b/browse/src/token-manager.ts new file mode 100644 index 0000000000..2a6551d090 --- /dev/null +++ b/browse/src/token-manager.ts @@ -0,0 +1,280 @@ +/** + * Token manager — acquire a bearer token via a frontend adapter's HTTP auth + * state machine, then cache it keyed by (realm, userName) so repeat sessions and + * realm-sharing envs skip re-acquisition until expiry. + * + * The engine is frontend-agnostic: it drives the adapter's request builders and + * response classifiers (frontend-adapter.ts) and never hardcodes an app's auth + * shape. Credentials and TOTP are pulled through injectable providers so the + * state machine is unit-testable without live calls or real secrets. + * + * SECURITY: password / OTP / bearer are never logged. Callers get the validated + * auth object back; what they print is their responsibility. + */ + +import { execFileSync } from 'child_process'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { + Creds, + EnvConfig, + FrontendAdapter, + OpItemRef, +} from './frontend-adapter'; +import { resolveEnv } from './frontend-adapter'; + +export interface AcquiredToken { + /** The adapter-validated auth object (e.g. { bearerToken, roles }). */ + auth: unknown; + realm: string; + appOrigin: string; + env: string; + userName: string; + /** Epoch ms. Prefer the JWT `exp` claim; fall back to acquiredAt + defaultTtlMs. */ + acquiredAt: number; + expiresAt: number; +} + +/** Resolve username/password for an env. Default impl reads 1Password via `op`. */ +export type CredsProvider = ( + env: EnvConfig & { name: string; realm: string }, +) => Promise | Creds; + +/** Resolve a fresh TOTP for an env. Called late to maximize the 30s window. */ +export type TotpProvider = ( + env: EnvConfig & { name: string; realm: string }, +) => Promise | string; + +export interface AcquireDeps { + credsProvider?: CredsProvider; + totpProvider?: TotpProvider; + fetchImpl?: typeof fetch; + now?: () => number; +} + +class TokenError extends Error {} + +function op(args: string[]): string { + return execFileSync('op', args, { encoding: 'utf8' }).trim(); +} + +function requireOpItem(env: EnvConfig & { name: string }): OpItemRef { + if (!env.opItem) { + throw new TokenError( + `env "${env.name}" declares no \`opItem\` and no credsProvider was supplied; ` + + `cannot resolve credentials`, + ); + } + return env.opItem; +} + +/** Default 1Password-backed credential provider. */ +export const defaultCredsProvider: CredsProvider = (env) => { + const item = requireOpItem(env); + const field = (label: string) => + op(['item', 'get', item.item, '--vault', item.vault, '--fields', `label=${label}`, '--reveal']); + return { userName: field('username'), password: field('password') }; +}; + +/** Default 1Password-backed TOTP provider. */ +export const defaultTotpProvider: TotpProvider = (env) => { + const item = requireOpItem(env); + return op(['item', 'get', item.item, '--vault', item.vault, '--otp']); +}; + +/** + * Decode the `exp` claim (epoch ms) from a JWT, or null if absent/undecodable. + * Best-effort: a token without a parseable exp is not an error — callers fall + * back to a configured TTL (KTD6: do not assume a JWT exp claim exists). + */ +export function decodeJwtExpMs(bearer: string): number | null { + const parts = bearer.split('.'); + if (parts.length < 2) return null; + try { + const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + return typeof payload.exp === 'number' ? payload.exp * 1000 : null; + } catch { + return null; + } +} + +async function postJson( + fetchImpl: typeof fetch, + url: string, + body: unknown, +): Promise { + const res = await fetchImpl(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + }); + const text = await res.text(); + let data: unknown; + try { + data = JSON.parse(text); + } catch { + data = text; + } + if (res.status >= 500) { + throw new TokenError(`auth endpoint returned ${res.status}`); + } + return data; +} + +/** + * Run the adapter's auth state machine and return a validated, expiry-stamped + * token. Does NOT touch the cache — see acquireOrLoad (U2) for the cached path. + */ +export async function acquireToken( + adapter: FrontendAdapter, + envName: string, + deps: AcquireDeps = {}, +): Promise { + const credsProvider = deps.credsProvider ?? defaultCredsProvider; + const totpProvider = deps.totpProvider ?? defaultTotpProvider; + const fetchImpl = deps.fetchImpl ?? fetch; + const now = deps.now ?? Date.now; + + const env = resolveEnv(adapter, envName); + const creds = await credsProvider(env); + const url = adapter.auth.endpoint(env.apiOrigin); + + const first = await postJson(fetchImpl, url, adapter.auth.firstStep(creds)); + const firstOutcome = adapter.auth.afterFirstStep(first); + + let authObj: unknown; + if ('error' in firstOutcome) { + throw new TokenError(`auth failed at step 1 for "${envName}": ${firstOutcome.error}`); + } else if ('done' in firstOutcome) { + authObj = firstOutcome.done; + } else { + // need a second factor + if (!adapter.auth.secondStep || !adapter.auth.afterSecondStep) { + throw new TokenError( + `adapter requires a second factor but defines no secondStep/afterSecondStep`, + ); + } + const totp = await totpProvider(env); + const second = await postJson(fetchImpl, url, adapter.auth.secondStep(creds, totp)); + const secondOutcome = adapter.auth.afterSecondStep(second); + if ('error' in secondOutcome) { + throw new TokenError(`auth failed at step 2 for "${envName}": ${secondOutcome.error}`); + } + authObj = secondOutcome.done; + } + + adapter.token.validate(authObj); + + const acquiredAt = now(); + const bearer = adapter.token.bearer(authObj); + const jwtExp = decodeJwtExpMs(bearer); + const ttl = adapter.defaultTtlMs ?? 8 * 60 * 60 * 1000; + const expiresAt = jwtExp ?? acquiredAt + ttl; + + return { + auth: authObj, + realm: env.realm, + appOrigin: env.appOrigin, + env: envName, + userName: creds.userName, + acquiredAt, + expiresAt, + }; +} + +// ─── Cache (U2): keyed by (realm, userName), stored per-frontend-repo ───────── + +export const CACHE_DIRNAME = '.auth'; + +/** Clock skew: treat a token expiring within this window as already stale. */ +const EXPIRY_SKEW_MS = 60_000; + +export interface CacheOptions { + /** Frontend repo root; cache lives at /.auth/. Defaults to cwd. */ + frontendRoot?: string; + now?: () => number; +} + +/** Filesystem-safe fragment (no separators / traversal). */ +function safeFragment(value: string): string { + return value.replace(/[^A-Za-z0-9._-]/g, '_'); +} + +function cacheDir(opts: CacheOptions): string { + return path.join(opts.frontendRoot ?? process.cwd(), CACHE_DIRNAME); +} + +export function cacheFilePath(opts: CacheOptions, realm: string, userName: string): string { + return path.join(cacheDir(opts), `${safeFragment(realm)}-${safeFragment(userName)}.json`); +} + +function isFresh(token: AcquiredToken, now: number): boolean { + return typeof token.expiresAt === 'number' && now < token.expiresAt - EXPIRY_SKEW_MS; +} + +/** + * Return the freshest non-expired cached token for a realm WITHOUT resolving + * credentials (so the cached path triggers zero interactive prompts). Files are + * namespaced by userName; a single dev identity per realm yields one match. + * Returns null on miss / all-expired / unreadable. + */ +export function readCachedToken(opts: CacheOptions, realm: string): AcquiredToken | null { + const now = (opts.now ?? Date.now)(); + const dir = cacheDir(opts); + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + return null; + } + const prefix = `${safeFragment(realm)}-`; + const candidates: AcquiredToken[] = []; + for (const name of entries) { + if (!name.startsWith(prefix) || !name.endsWith('.json')) continue; + try { + const token = JSON.parse(fs.readFileSync(path.join(dir, name), 'utf8')) as AcquiredToken; + if (token.realm === realm && isFresh(token, now)) candidates.push(token); + } catch { + // ignore corrupt / partially written entries + } + } + if (candidates.length === 0) return null; + candidates.sort((a, b) => b.expiresAt - a.expiresAt); + return candidates[0]; +} + +/** Persist a token as chmod-600 JSON under the frontend repo's .auth/ dir. */ +export function writeCachedToken(opts: CacheOptions, token: AcquiredToken): string { + const dir = cacheDir(opts); + fs.mkdirSync(dir, { recursive: true }); + const file = cacheFilePath(opts, token.realm, token.userName); + fs.writeFileSync(file, JSON.stringify(token, null, 2), { mode: 0o600 }); + fs.chmodSync(file, 0o600); + return file; +} + +export interface AcquireOrLoadDeps extends AcquireDeps, CacheOptions { + /** Force re-acquisition even on a cache hit (e.g. after a 401). */ + forceRefresh?: boolean; +} + +/** + * Return a cached-or-freshly-acquired token for an env. On a warm cache this + * path performs no network call and no credential prompt (R1/R3). A cold cache, + * expired entry, or `forceRefresh` acquires and rewrites the cache. + */ +export async function acquireOrLoad( + adapter: FrontendAdapter, + envName: string, + deps: AcquireOrLoadDeps = {}, +): Promise<{ token: AcquiredToken; cached: boolean }> { + const realm = adapter.realm(envName); + if (!deps.forceRefresh) { + const hit = readCachedToken(deps, realm); + if (hit) return { token: hit, cached: true }; + } + const token = await acquireToken(adapter, envName, deps); + writeCachedToken(deps, token); + return { token, cached: false }; +} diff --git a/browse/test/commands.test.ts b/browse/test/commands.test.ts index 9382cb27ed..721a67093e 100644 --- a/browse/test/commands.test.ts +++ b/browse/test/commands.test.ts @@ -19,7 +19,7 @@ import * as path from 'path'; // Thin wrappers that bridge old test calls (bm as 3rd arg) to new signatures (session + bm) const handleReadCommand = (cmd: string, args: string[], b: BrowserManager) => - _handleReadCommand(cmd, args, b.getActiveSession()); + _handleReadCommand(cmd, args, b.getActiveSession(), b); const handleWriteCommand = (cmd: string, args: string[], b: BrowserManager) => _handleWriteCommand(cmd, args, b.getActiveSession(), b); @@ -1650,14 +1650,18 @@ describe('Wait load states', () => { // ─── Console --errors ────────────────────────────────────────── describe('Console --errors', () => { + // Buffers are per-session (agent-browser U4); seed the session buffer the + // read command actually reads, not the legacy module singleton. + const sessionConsole = () => bm.getBuffers().consoleBuffer; + test('console --errors filters to error and warning only', async () => { // Clear existing entries await handleReadCommand('console', ['--clear'], bm); // Add mixed entries - addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'info message' }); - addConsoleEntry({ timestamp: Date.now(), level: 'warning', text: 'warn message' }); - addConsoleEntry({ timestamp: Date.now(), level: 'error', text: 'error message' }); + sessionConsole().push({ timestamp: Date.now(), level: 'log', text: 'info message' }); + sessionConsole().push({ timestamp: Date.now(), level: 'warning', text: 'warn message' }); + sessionConsole().push({ timestamp: Date.now(), level: 'error', text: 'error message' }); const result = await handleReadCommand('console', ['--errors'], bm); expect(result).toContain('warn message'); @@ -1665,33 +1669,33 @@ describe('Console --errors', () => { expect(result).not.toContain('info message'); // Cleanup - consoleBuffer.clear(); + sessionConsole().clear(); }); test('console --errors returns empty message when no errors', async () => { - consoleBuffer.clear(); - addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'just a log' }); + sessionConsole().clear(); + sessionConsole().push({ timestamp: Date.now(), level: 'log', text: 'just a log' }); const result = await handleReadCommand('console', ['--errors'], bm); expect(result).toBe('(no console errors)'); - consoleBuffer.clear(); + sessionConsole().clear(); }); test('console --errors on empty buffer', async () => { - consoleBuffer.clear(); + sessionConsole().clear(); const result = await handleReadCommand('console', ['--errors'], bm); expect(result).toBe('(no console errors)'); }); test('console without flag still returns all messages', async () => { - consoleBuffer.clear(); - addConsoleEntry({ timestamp: Date.now(), level: 'log', text: 'all messages test' }); + sessionConsole().clear(); + sessionConsole().push({ timestamp: Date.now(), level: 'log', text: 'all messages test' }); const result = await handleReadCommand('console', [], bm); expect(result).toContain('all messages test'); - consoleBuffer.clear(); + sessionConsole().clear(); }); }); diff --git a/browse/test/frontend-adapter.test.ts b/browse/test/frontend-adapter.test.ts new file mode 100644 index 0000000000..f4c9458cb8 --- /dev/null +++ b/browse/test/frontend-adapter.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + validateAdapter, + loadAdapter, + resolveEnv, + adapterPath, + ADAPTER_FILENAME, +} from '../src/frontend-adapter'; + +const XPLOR_CONFIG = path.join( + import.meta.dir, + '../../../../consumers/xplor-client/agent-browser.config.mjs', +); + +async function importDefault(file: string): Promise { + const mod = await import(file); + return mod.default ?? mod; +} + +describe('U9 frontend adapter — xplor config', () => { + it('loads and validates the xplor adapter', async () => { + const mod = await importDefault(XPLOR_CONFIG); + const adapter = validateAdapter(mod, XPLOR_CONFIG); + expect(typeof adapter.realm).toBe('function'); + expect(adapter.token.storageKey).toBe('auth'); + }); + + it('resolves release-6-6-0 and release-6-7-0 to realm "release"', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + expect(resolveEnv(adapter, 'release-6-6-0').realm).toBe('release'); + expect(resolveEnv(adapter, 'release-6-7-0').realm).toBe('release'); + // both point at their own origin but share the cache realm + expect(resolveEnv(adapter, 'release-6-6-0').appOrigin).toBe('https://release-6-6-0.exlabs.cloud'); + }); + + it('keeps demo and superman in their own realms', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + expect(resolveEnv(adapter, 'demo').realm).toBe('demo'); + expect(resolveEnv(adapter, 'superman').realm).toBe('superman'); + }); + + it('runs the locked auth state machine: mfaRequired -> totp -> bearer', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + expect(adapter.auth.afterFirstStep({ mfaRequired: true })).toEqual({ need: 'totp' }); + const second = adapter.auth.afterSecondStep!({ bearerToken: 'x.y.z', roles: ['SUPER'] }); + expect(second).toEqual({ done: { bearerToken: 'x.y.z', roles: ['SUPER'] } }); + }); + + it('surfaces QR-enrollment as an actionable error, not a hang', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + const outcome = adapter.auth.afterFirstStep({ authKey: 'k', qr: 'data:...' }); + expect('error' in outcome).toBe(true); + }); + + it('validates the token object shape', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + expect(() => adapter.token.validate({ bearerToken: 'x', roles: [] })).not.toThrow(); + expect(() => adapter.token.validate({ bearerToken: 'x' })).toThrow(/roles/); + expect(() => adapter.token.validate({ roles: [] })).toThrow(/bearerToken/); + }); + + it('rejects an unknown env name with the valid list', async () => { + const adapter = validateAdapter(await importDefault(XPLOR_CONFIG), XPLOR_CONFIG); + expect(() => resolveEnv(adapter, 'nope')).toThrow(/Unknown env "nope"/); + }); +}); + +describe('U9 frontend adapter — validation errors', () => { + it('rejects a config with no default export object', () => { + expect(() => validateAdapter(null, 'test')).toThrow(/default export/); + }); + + it('rejects a malformed adapter missing auth.endpoint', () => { + const bad = { + envs: { demo: { appOrigin: 'https://d', apiOrigin: 'https://d' } }, + realm: () => 'demo', + auth: { firstStep: () => ({}), afterFirstStep: () => ({ need: 'totp' }) }, + token: { storageKey: 'auth', validate: () => {}, bearer: () => '' }, + }; + expect(() => validateAdapter(bad, 'test')).toThrow(/auth\.endpoint/); + }); + + it('rejects an env missing apiOrigin', () => { + const bad = { + envs: { demo: { appOrigin: 'https://d' } }, + realm: () => 'demo', + auth: { endpoint: () => '', firstStep: () => ({}), afterFirstStep: () => ({ need: 'totp' }) }, + token: { storageKey: 'auth', validate: () => {}, bearer: () => '' }, + }; + expect(() => validateAdapter(bad, 'test')).toThrow(/apiOrigin/); + }); +}); + +describe('U9 frontend adapter — loader + portability (R7/R8)', () => { + let tmp: string; + + beforeAll(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'adapter-fixture-')); + }); + afterAll(() => { + fs.rmSync(tmp, { recursive: true, force: true }); + }); + + it('errors with the expected path when no adapter exists', async () => { + await expect(loadAdapter(tmp)).rejects.toThrow(new RegExp(ADAPTER_FILENAME)); + expect(adapterPath(tmp)).toBe(path.join(tmp, ADAPTER_FILENAME)); + }); + + it('loads a second (fixture) frontend adapter with no engine change', async () => { + // A wholly different frontend: different envs, single-step auth, different key. + const fixture = ` + export default { + envs: { staging: { appOrigin: 'https://s.example', apiOrigin: 'https://api.s.example' } }, + realm: (n) => n, + auth: { + endpoint: (api) => api + '/login', + firstStep: (c) => c, + afterFirstStep: (d) => (d && d.jwt ? { done: d } : { error: 'no jwt' }), + }, + token: { + storageKey: 'session', + validate: (o) => { if (!o || !o.jwt) throw new Error('missing jwt'); }, + bearer: (o) => o.jwt, + }, + }; + `; + fs.writeFileSync(adapterPath(tmp), fixture); + const adapter = await loadAdapter(tmp); + expect(adapter.token.storageKey).toBe('session'); + expect(resolveEnv(adapter, 'staging').realm).toBe('staging'); + expect(adapter.auth.afterFirstStep({ jwt: 'abc' })).toEqual({ done: { jwt: 'abc' } }); + }); +}); diff --git a/browse/test/inject-auth.test.ts b/browse/test/inject-auth.test.ts new file mode 100644 index 0000000000..773d9eab35 --- /dev/null +++ b/browse/test/inject-auth.test.ts @@ -0,0 +1,80 @@ +/** + * Origin-scoped injection tests (agent-browser U5). + * + * Covers the R5 security invariant (the bearer is written ONLY at the app + * origin) via the exported init-script guard, plus the daemon primitive's + * validation — all without launching Chromium. + */ + +import { describe, it, expect } from 'bun:test'; + +import { BrowserManager, originScopedStorageInit } from '../src/browser-manager'; + +function withFakeDom(origin: string, run: () => void): Record { + const store: Record = {}; + const g = globalThis as any; + const prevLocation = g.location; + const prevWindow = g.window; + g.location = { origin }; + g.window = { localStorage: { setItem: (k: string, v: string) => { store[k] = v; } } }; + try { + run(); + } finally { + g.location = prevLocation; + g.window = prevWindow; + } + return store; +} + +describe('U5 origin-scoped init script (R5 security)', () => { + const payload = { origin: 'https://app.example', key: 'auth', val: '{"bearerToken":"x","roles":[]}' }; + + it('writes localStorage when the page origin matches appOrigin', () => { + const store = withFakeDom('https://app.example', () => originScopedStorageInit(payload)); + expect(store.auth).toBe('{"bearerToken":"x","roles":[]}'); + }); + + it('does NOT write when the page origin differs (no bearer leak)', () => { + const store = withFakeDom('https://evil.example', () => originScopedStorageInit(payload)); + expect(store.auth).toBeUndefined(); + }); +}); + +describe('U5 injectOriginScopedStorage validation', () => { + function bmWithFakeContext() { + const bm = new BrowserManager(); + const calls: Array = []; + (bm as any).cur.context = { addInitScript: (_fn: unknown, arg: unknown) => { calls.push(arg); } }; + return { bm, calls }; + } + + it('throws when the session has no context', async () => { + const bm = new BrowserManager(); + await expect( + bm.injectOriginScopedStorage('https://app', 'auth', '{"a":1}'), + ).rejects.toThrow(/no browser context/i); + }); + + it('throws on invalid JSON', async () => { + const { bm } = bmWithFakeContext(); + await expect(bm.injectOriginScopedStorage('https://app', 'auth', 'not-json')).rejects.toThrow(/valid JSON/i); + }); + + it('throws on a non-object payload (array / primitive)', async () => { + const { bm } = bmWithFakeContext(); + await expect(bm.injectOriginScopedStorage('https://app', 'auth', '[1,2]')).rejects.toThrow(/non-empty JSON object/i); + await expect(bm.injectOriginScopedStorage('https://app', 'auth', '"x"')).rejects.toThrow(/non-empty JSON object/i); + }); + + it('throws when appOrigin or storageKey is missing', async () => { + const { bm } = bmWithFakeContext(); + await expect(bm.injectOriginScopedStorage('', 'auth', '{"a":1}')).rejects.toThrow(/appOrigin/i); + await expect(bm.injectOriginScopedStorage('https://app', '', '{"a":1}')).rejects.toThrow(/storageKey/i); + }); + + it('applies the init script and records it for context-recreation replay', async () => { + const { bm, calls } = bmWithFakeContext(); + await bm.injectOriginScopedStorage('https://app.example', 'auth', '{"bearerToken":"x","roles":[]}'); + expect(calls).toEqual([{ origin: 'https://app.example', key: 'auth', val: '{"bearerToken":"x","roles":[]}' }]); + }); +}); diff --git a/browse/test/is-process-alive-eperm.test.ts b/browse/test/is-process-alive-eperm.test.ts new file mode 100644 index 0000000000..10187a30f3 --- /dev/null +++ b/browse/test/is-process-alive-eperm.test.ts @@ -0,0 +1,32 @@ +/** + * isProcessAlive tri-state (agent-browser U7). + * + * The zombie-lock bug was isProcessAlive treating EPERM (process exists but we + * can't signal it) as dead, so cleanup skipped a live Chromium holding the + * profile SingletonLock. EPERM must read as ALIVE; only ESRCH is dead. + */ + +import { describe, it, expect } from 'bun:test'; + +import { isProcessAlive } from '../src/error-handling'; + +describe('isProcessAlive', () => { + it('reports the current process as alive', () => { + expect(isProcessAlive(process.pid)).toBe(true); + }); + + it('reports a non-existent pid (ESRCH) as dead', () => { + // A pid that will not exist; process.kill throws ESRCH. + expect(isProcessAlive(2_147_483_646)).toBe(false); + }); + + it('reports pid 1 (EPERM for non-root) as alive, not dead', () => { + // On Unix, pid 1 (launchd/init) exists but an unprivileged process cannot + // signal it → EPERM. Pre-fix this returned false (the zombie bug). Skip if + // somehow running as root, where kill(1,0) succeeds instead of EPERM. + if (process.platform === 'win32') return; + const isRoot = typeof process.getuid === 'function' && process.getuid() === 0; + if (isRoot) return; + expect(isProcessAlive(1)).toBe(true); + }); +}); diff --git a/browse/test/session-characterization.test.ts b/browse/test/session-characterization.test.ts new file mode 100644 index 0000000000..710b09df9b --- /dev/null +++ b/browse/test/session-characterization.test.ts @@ -0,0 +1,73 @@ +/** + * Characterization tests — pin CURRENT single-session behavior before the + * ContextSession extraction (agent-browser U4). These lock the observable + * contract that must not regress for existing callers (which send no `session` + * field). Launch-free: exercises the ownership map + buffer module directly. + * + * After the extraction, the default/legacy session must reproduce every + * assertion here unchanged. + */ + +import { describe, it, expect, beforeEach } from 'bun:test'; + +import { BrowserManager } from '../src/browser-manager'; +import { + consoleBuffer, + networkBuffer, + dialogBuffer, + addConsoleEntry, + addNetworkEntry, + addDialogEntry, +} from '../src/buffers'; + +describe('U4 characterization — tab ownership survives extraction', () => { + let bm: BrowserManager; + beforeEach(() => { + bm = new BrowserManager(); + }); + + it('unowned tab has no owner', () => { + expect(bm.getTabOwner(1)).toBeNull(); + }); + + it('root reads and writes any tab', () => { + expect(bm.checkTabAccess(1, 'root', { isWrite: false })).toBe(true); + expect(bm.checkTabAccess(1, 'root', { isWrite: true })).toBe(true); + }); + + it('shared scoped agent can write an unowned tab (skill ergonomics)', () => { + expect(bm.checkTabAccess(1, 'agent-1', { isWrite: true })).toBe(true); + }); +}); + +describe('U4 characterization — buffers are a shared global today', () => { + beforeEach(() => { + consoleBuffer.clear(); + networkBuffer.clear(); + dialogBuffer.clear(); + }); + + it('console entries land in the single global buffer', () => { + addConsoleEntry({ timestamp: 1, level: 'log', text: 'a' }); + addConsoleEntry({ timestamp: 2, level: 'error', text: 'b' }); + expect(consoleBuffer.toArray().map((entry) => entry.text)).toEqual(['a', 'b']); + }); + + it('network entries carry no session/tab identity (pre-extraction)', () => { + addNetworkEntry({ timestamp: 1, method: 'GET', url: 'https://x/1' }); + const entry = networkBuffer.toArray()[0]; + expect(entry).not.toHaveProperty('session'); + expect(entry).not.toHaveProperty('tabId'); + }); + + it('dialog entries land in the single global buffer', () => { + addDialogEntry({ timestamp: 1, type: 'alert', message: 'hi', action: 'accepted' }); + expect(dialogBuffer.toArray()).toHaveLength(1); + }); + + it('clear empties the global buffer', () => { + addConsoleEntry({ timestamp: 1, level: 'log', text: 'a' }); + consoleBuffer.clear(); + expect(consoleBuffer.toArray()).toEqual([]); + }); +}); diff --git a/browse/test/session-isolation.test.ts b/browse/test/session-isolation.test.ts new file mode 100644 index 0000000000..e26ec933e4 --- /dev/null +++ b/browse/test/session-isolation.test.ts @@ -0,0 +1,96 @@ +/** + * Session isolation tests (agent-browser U4). + * + * Fast, deterministic proof that per-session state does not cross-contaminate — + * driven with fake pages via the same private-method pattern the memory-leak + * reproducer uses, so no real Chromium launch is required. Covers the crux + * invariant: an async page event must write to the buffers of the session that + * OWNED the page at wire-time, even after the active session has switched. + */ + +import { describe, it, expect } from 'bun:test'; +import { EventEmitter } from 'events'; + +import { BrowserManager, DEFAULT_SESSION_ID } from '../src/browser-manager'; + +function makeFakePage(): any { + const page = new EventEmitter() as any; + page.url = () => 'https://example.invalid/'; + page.mainFrame = () => ({}); + return page; +} + +function wire(bm: BrowserManager, page: unknown): void { + (bm as unknown as { wirePageEvents: (p: unknown) => void }).wirePageEvents.bind(bm)(page); +} + +describe('U4 session isolation — buffers', () => { + it('console events land in the OWNING session, not the active one', () => { + const bm = new BrowserManager(); + + // Wire a page under the default session. + const defaultPage = makeFakePage(); + wire(bm, defaultPage); + + // Wire a second page under session "s2". + bm.setCurrentSession('s2'); + const s2Page = makeFakePage(); + wire(bm, s2Page); + + // Now, with s2 active, fire a console event on the DEFAULT-owned page. + // The owner was captured at wire-time, so it must land in default's buffer. + defaultPage.emit('console', { type: () => 'log', text: () => 'from-default' }); + s2Page.emit('console', { type: () => 'log', text: () => 'from-s2' }); + + bm.setCurrentSession(DEFAULT_SESSION_ID); + const defaultConsole = bm.getBuffers().consoleBuffer.toArray().map((entry) => entry.text); + bm.setCurrentSession('s2'); + const s2Console = bm.getBuffers().consoleBuffer.toArray().map((entry) => entry.text); + + expect(defaultConsole).toEqual(['from-default']); + expect(s2Console).toEqual(['from-s2']); + }); + + it('network events are isolated per owning session', () => { + const bm = new BrowserManager(); + const p1 = makeFakePage(); + wire(bm, p1); + bm.setCurrentSession('s2'); + const p2 = makeFakePage(); + wire(bm, p2); + + p1.emit('request', { url: () => 'https://a/1', method: () => 'GET' }); + p2.emit('request', { url: () => 'https://b/2', method: () => 'POST' }); + + bm.setCurrentSession(DEFAULT_SESSION_ID); + expect(bm.getBuffers().networkBuffer.toArray().map((entry) => entry.url)).toEqual(['https://a/1']); + bm.setCurrentSession('s2'); + expect(bm.getBuffers().networkBuffer.toArray().map((entry) => entry.url)).toEqual(['https://b/2']); + }); +}); + +describe('U4 session isolation — session registry', () => { + it('default session exists implicitly; getAllSessions surfaces created ones', () => { + const bm = new BrowserManager(); + bm.getBuffers(); // touch default -> lazily created + bm.setCurrentSession('alpha'); + bm.getBuffers(); + const ids = bm.getAllSessions().map((session) => session.id).sort(); + expect(ids).toContain(DEFAULT_SESSION_ID); + expect(ids).toContain('alpha'); + }); + + it('closeSession refuses to close the default session', async () => { + const bm = new BrowserManager(); + await expect(bm.closeSession(DEFAULT_SESSION_ID)).rejects.toThrow(/default/i); + }); + + it('closeSession drops a non-default session and resets current to default', async () => { + const bm = new BrowserManager(); + bm.setCurrentSession('temp'); + bm.getBuffers(); + await bm.closeSession('temp'); + expect(bm.hasSession('temp')).toBe(false); + expect(bm.getCurrentSessionId()).toBe(DEFAULT_SESSION_ID); + }); +}); diff --git a/browse/test/token-acquire.test.ts b/browse/test/token-acquire.test.ts new file mode 100644 index 0000000000..5a0dbe3555 --- /dev/null +++ b/browse/test/token-acquire.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from 'bun:test'; +import * as path from 'path'; + +import { acquireToken, decodeJwtExpMs } from '../src/token-manager'; +import { validateAdapter, type FrontendAdapter } from '../src/frontend-adapter'; + +const XPLOR_CONFIG = path.join( + import.meta.dir, + '../../../../consumers/xplor-client/agent-browser.config.mjs', +); + +async function xplorAdapter(): Promise { + const mod = await import(XPLOR_CONFIG); + return validateAdapter(mod.default ?? mod, XPLOR_CONFIG); +} + +/** Build a signed-looking JWT with the given exp (seconds). Signature is unused. */ +function jwt(exp: number | null): string { + const header = Buffer.from(JSON.stringify({ alg: 'HS256' })).toString('base64url'); + const claims: Record = { userName: 'WhisperingShadow', roles: ['SUPER'] }; + if (exp !== null) claims.exp = exp; + const payload = Buffer.from(JSON.stringify(claims)).toString('base64url'); + return `${header}.${payload}.sig`; +} + +/** A fetch that returns queued JSON bodies in order. */ +function scriptedFetch(responses: Array<{ status?: number; body: unknown }>) { + let call = 0; + const calls: unknown[] = []; + const impl = (async (_url: string, init: RequestInit) => { + calls.push(JSON.parse(init.body as string)); + const { status = 200, body } = responses[call++] ?? { body: '' }; + return { + status, + text: async () => (typeof body === 'string' ? body : JSON.stringify(body)), + } as unknown as Response; + }) as unknown as typeof fetch; + return { impl, calls: () => calls }; +} + +const providers = { + credsProvider: () => ({ userName: 'WhisperingShadow', password: 'pw' }), + totpProvider: () => '123456', + now: () => 1_000_000, +}; + +describe('U1 acquireToken — happy path (mfa -> totp -> bearer)', () => { + it('returns validated auth with realm and JWT-derived expiry', async () => { + const adapter = await xplorAdapter(); + const exp = 1_000 + 36_000; // seconds + const fetchMock = scriptedFetch([ + { body: { mfaRequired: true } }, + { body: { bearerToken: jwt(exp), roles: ['SUPER'] } }, + ]); + const token = await acquireToken(adapter, 'release-6-7-0', { + ...providers, + fetchImpl: fetchMock.impl, + }); + expect(token.realm).toBe('release'); + expect(token.appOrigin).toBe('https://release-6-7-0.exlabs.cloud'); + expect(token.userName).toBe('WhisperingShadow'); + expect(token.expiresAt).toBe(exp * 1000); + // two POSTs: step1 without authKey, step2 with it + expect(fetchMock.calls()).toEqual([ + { userName: 'WhisperingShadow', password: 'pw' }, + { userName: 'WhisperingShadow', password: 'pw', authKey: '123456' }, + ]); + }); + + it('falls back to defaultTtlMs when the JWT carries no exp', async () => { + const adapter = await xplorAdapter(); + const fetchMock = scriptedFetch([ + { body: { mfaRequired: true } }, + { body: { bearerToken: jwt(null), roles: [] } }, + ]); + const token = await acquireToken(adapter, 'demo', { ...providers, fetchImpl: fetchMock.impl }); + expect(token.expiresAt).toBe(1_000_000 + 8 * 60 * 60 * 1000); + }); +}); + +describe('U1 acquireToken — error branches', () => { + it('surfaces QR-enrollment as an actionable error (no hang)', async () => { + const adapter = await xplorAdapter(); + const fetchMock = scriptedFetch([{ body: { authKey: 'k', qr: 'data:...' } }]); + await expect( + acquireToken(adapter, 'demo', { ...providers, fetchImpl: fetchMock.impl }), + ).rejects.toThrow(/enroll/i); + }); + + it('errors clearly when step 2 returns no bearerToken (wrong TOTP)', async () => { + const adapter = await xplorAdapter(); + const fetchMock = scriptedFetch([ + { body: { mfaRequired: true } }, + { body: { message: 'invalid code' } }, + ]); + await expect( + acquireToken(adapter, 'demo', { ...providers, fetchImpl: fetchMock.impl }), + ).rejects.toThrow(/step 2/i); + }); + + it('rejects an unknown env before any network call', async () => { + const adapter = await xplorAdapter(); + let called = false; + const fetchImpl = (async () => { + called = true; + return { status: 200, text: async () => '' } as unknown as Response; + }) as unknown as typeof fetch; + await expect(acquireToken(adapter, 'bogus', { ...providers, fetchImpl })).rejects.toThrow( + /Unknown env "bogus"/, + ); + expect(called).toBe(false); + }); +}); + +describe('U1 decodeJwtExpMs', () => { + it('decodes exp in ms', () => { + expect(decodeJwtExpMs(jwt(1_784_854_757))).toBe(1_784_854_757 * 1000); + }); + it('returns null for a token without exp', () => { + expect(decodeJwtExpMs(jwt(null))).toBeNull(); + }); + it('returns null for a non-JWT string', () => { + expect(decodeJwtExpMs('not-a-jwt')).toBeNull(); + }); +}); diff --git a/browse/test/token-cache.test.ts b/browse/test/token-cache.test.ts new file mode 100644 index 0000000000..9c3b0c1ad4 --- /dev/null +++ b/browse/test/token-cache.test.ts @@ -0,0 +1,149 @@ +import { describe, it, expect, beforeEach, afterEach } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; + +import { + acquireOrLoad, + readCachedToken, + writeCachedToken, + cacheFilePath, + type AcquiredToken, +} from '../src/token-manager'; +import { validateAdapter, type FrontendAdapter } from '../src/frontend-adapter'; + +const XPLOR_CONFIG = path.join( + import.meta.dir, + '../../../../consumers/xplor-client/agent-browser.config.mjs', +); + +async function xplorAdapter(): Promise { + const mod = await import(XPLOR_CONFIG); + return validateAdapter(mod.default ?? mod, XPLOR_CONFIG); +} + +function jwt(expSeconds: number): string { + const payload = Buffer.from(JSON.stringify({ roles: ['SUPER'], exp: expSeconds })).toString( + 'base64url', + ); + return `h.${payload}.s`; +} + +const NOW = 1_000_000_000_000; +const future = (msFromNow: number) => Math.round((NOW + msFromNow) / 1000); + +let tmp: string; +beforeEach(() => { + tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'auth-cache-')); +}); +afterEach(() => { + fs.rmSync(tmp, { recursive: true, force: true }); +}); + +/** Scripted fetch counting how many acquisitions happen. */ +function acquirer(expSeconds: number) { + let acquires = 0; + const fetchImpl = (async (_url: string, init: RequestInit) => { + const body = JSON.parse(init.body as string); + const isStep2 = 'authKey' in body; + if (isStep2) acquires++; + return { + status: 200, + text: async () => + JSON.stringify(isStep2 ? { bearerToken: jwt(expSeconds), roles: ['SUPER'] } : { mfaRequired: true }), + } as unknown as Response; + }) as unknown as typeof fetch; + return { + deps: { + fetchImpl, + credsProvider: () => ({ userName: 'Dev1', password: 'pw' }), + totpProvider: () => '000000', + now: () => NOW, + frontendRoot: () => tmp, + }, + acquires: () => acquires, + }; +} + +function depsFor(a: ReturnType) { + return { ...a.deps, frontendRoot: tmp }; +} + +describe('U2 token cache', () => { + it('first call acquires + writes; second call returns cache with no acquire', async () => { + const adapter = await xplorAdapter(); + const a = acquirer(future(10 * 3600 * 1000) as unknown as number); + const first = await acquireOrLoad(adapter, 'demo', depsFor(a)); + expect(first.cached).toBe(false); + expect(a.acquires()).toBe(1); + const second = await acquireOrLoad(adapter, 'demo', depsFor(a)); + expect(second.cached).toBe(true); + expect(a.acquires()).toBe(1); + }); + + it('realm-share: release-6-6-0 then release-6-7-0 -> one acquire, one entry', async () => { + const adapter = await xplorAdapter(); + const a = acquirer(future(10 * 3600 * 1000) as unknown as number); + await acquireOrLoad(adapter, 'release-6-6-0', depsFor(a)); + const second = await acquireOrLoad(adapter, 'release-6-7-0', depsFor(a)); + expect(second.cached).toBe(true); + expect(a.acquires()).toBe(1); + const files = fs.readdirSync(path.join(tmp, '.auth')); + expect(files).toEqual(['release-Dev1.json']); + }); + + it('expired entry -> re-acquires and overwrites', async () => { + const adapter = await xplorAdapter(); + const a = acquirer(future(-1000) as unknown as number); // already expired + const first = await acquireOrLoad(adapter, 'demo', depsFor(a)); + expect(first.cached).toBe(false); + const second = await acquireOrLoad(adapter, 'demo', depsFor(a)); + expect(second.cached).toBe(false); // stale -> re-acquire + expect(a.acquires()).toBe(2); + }); + + it('forceRefresh bypasses a fresh cache entry', async () => { + const adapter = await xplorAdapter(); + const a = acquirer(future(10 * 3600 * 1000) as unknown as number); + await acquireOrLoad(adapter, 'demo', depsFor(a)); + const refreshed = await acquireOrLoad(adapter, 'demo', { ...depsFor(a), forceRefresh: true }); + expect(refreshed.cached).toBe(false); + expect(a.acquires()).toBe(2); + }); + + it('written cache file has mode 600', async () => { + const token: AcquiredToken = { + auth: { bearerToken: 'x', roles: [] }, + realm: 'demo', + appOrigin: 'https://demo', + env: 'demo', + userName: 'Dev1', + acquiredAt: NOW, + expiresAt: NOW + 3600_000, + }; + const file = writeCachedToken({ frontendRoot: tmp }, token); + const mode = fs.statSync(file).mode & 0o777; + expect(mode).toBe(0o600); + expect(file).toBe(cacheFilePath({ frontendRoot: tmp }, 'demo', 'Dev1')); + }); + + it('portability: distinct userName -> distinct entry, no collision', async () => { + writeCachedToken({ frontendRoot: tmp }, { + auth: {}, realm: 'release', appOrigin: 'a', env: 'release-6-6-0', + userName: 'Dev1', acquiredAt: NOW, expiresAt: NOW + 3600_000, + } as AcquiredToken); + writeCachedToken({ frontendRoot: tmp }, { + auth: {}, realm: 'release', appOrigin: 'a', env: 'release-6-6-0', + userName: 'Dev2', acquiredAt: NOW, expiresAt: NOW + 7200_000, + } as AcquiredToken); + const files = fs.readdirSync(path.join(tmp, '.auth')).sort(); + expect(files).toEqual(['release-Dev1.json', 'release-Dev2.json']); + // read returns the freshest (Dev2) + const hit = readCachedToken({ frontendRoot: tmp, now: () => NOW }, 'release'); + expect(hit?.userName).toBe('Dev2'); + }); + + it('read returns null on miss', async () => { + expect(readCachedToken({ frontendRoot: tmp, now: () => NOW }, 'release')).toBeNull(); + }); +}); diff --git a/browse/test/tracing.test.ts b/browse/test/tracing.test.ts new file mode 100644 index 0000000000..c5fd49106d --- /dev/null +++ b/browse/test/tracing.test.ts @@ -0,0 +1,50 @@ +/** + * Per-session tracing tests (agent-browser U8). Launch-free: drives a fake + * context's tracing API to assert state transitions and guard errors. + */ + +import { describe, it, expect } from 'bun:test'; + +import { BrowserManager } from '../src/browser-manager'; + +function bmWithTracingContext() { + const bm = new BrowserManager(); + const calls: { started: boolean; stopPath?: string } = { started: false }; + (bm as any).cur.context = { + tracing: { + start: async () => { calls.started = true; }, + stop: async (opts: { path: string }) => { calls.stopPath = opts.path; }, + }, + }; + return { bm, calls }; +} + +describe('U8 per-session tracing', () => { + it('starts and stops tracing, tracking session state', async () => { + const { bm, calls } = bmWithTracingContext(); + expect(bm.isTracing()).toBe(false); + await bm.startTracing(); + expect(bm.isTracing()).toBe(true); + expect(calls.started).toBe(true); + await bm.stopTracing('/tmp/t.zip'); + expect(bm.isTracing()).toBe(false); + expect(calls.stopPath).toBe('/tmp/t.zip'); + }); + + it('start is idempotent (no double-start)', async () => { + const { bm } = bmWithTracingContext(); + await bm.startTracing(); + await bm.startTracing(); // no throw + expect(bm.isTracing()).toBe(true); + }); + + it('throws when the session has no context', async () => { + const bm = new BrowserManager(); + await expect(bm.startTracing()).rejects.toThrow(/no browser context/i); + }); + + it('throws when stopping without starting', async () => { + const { bm } = bmWithTracingContext(); + await expect(bm.stopTracing('/tmp/x.zip')).rejects.toThrow(/not active/i); + }); +});