From 90b305cfd78ad2ced2eef1091142ad64bce17265 Mon Sep 17 00:00:00 2001 From: Yarden Date: Thu, 23 Jul 2026 18:10:12 +0300 Subject: [PATCH 01/14] feat(browse): frontend adapter schema + loader/validator (agent-browser U9) Frontend-agnostic adapter contract so each consuming frontend declares its envs/realms/auth-state-machine/token-contract in one config; the browse engine carries no app-specific auth knowledge. Enables isolated per-session browser + realm-cached auto-login for agent sessions. Co-Authored-By: Claude Opus 4.8 --- browse/src/frontend-adapter.ts | 183 +++++++++++++++++++++++++++ browse/test/frontend-adapter.test.ts | 137 ++++++++++++++++++++ 2 files changed, 320 insertions(+) create mode 100644 browse/src/frontend-adapter.ts create mode 100644 browse/test/frontend-adapter.test.ts 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/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' } }); + }); +}); From 744ab61d218dd4712a0416a7be01ed65cce97d70 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 09:42:29 +0300 Subject: [PATCH 02/14] =?UTF-8?q?feat(browse):=20token-manager=20=E2=80=94?= =?UTF-8?q?=20adapter-driven=20acquire=20+=20(realm,user)=20cache=20(agent?= =?UTF-8?q?-browser=20U1/U2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct-POST auth state machine driven by the frontend adapter (no form-driving, no in-browser biometric). Caches the validated auth object under the frontend repo's .auth/ keyed by (realm,userName), chmod 600; realm-sharing envs reuse one token. Expiry from the JWT exp claim, falling back to defaultTtlMs. Credentials and TOTP flow through injectable providers so the state machine is unit-tested without live calls or real secrets. Never logs password/OTP/bearer. Co-Authored-By: Claude Opus 4.8 --- browse/src/token-manager.ts | 280 ++++++++++++++++++++++++++++++ browse/test/token-acquire.test.ts | 125 +++++++++++++ browse/test/token-cache.test.ts | 149 ++++++++++++++++ 3 files changed, 554 insertions(+) create mode 100644 browse/src/token-manager.ts create mode 100644 browse/test/token-acquire.test.ts create mode 100644 browse/test/token-cache.test.ts 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/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(); + }); +}); From 57840aea79a07be62695ca3265a335830ddeab21 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 09:42:29 +0300 Subject: [PATCH 03/14] feat(browse): `agent-token` CLI command (agent-browser U1) Pre-server subcommand that 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. Emits a machine-parseable, secret-free summary (ENV/REALM/APPORIGIN/EXPIRES/CACHED). Co-Authored-By: Claude Opus 4.8 --- browse/src/cli.ts | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 59327b7923..9408b49ac2 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,37 @@ 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. NEVER prints the bearer. + if (command === 'agent-token') { + const envName = commandArgs.find((arg) => !arg.startsWith('-')); + const forceRefresh = commandArgs.includes('--refresh'); + 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. + console.log(`ENV=${token.env}`); + console.log(`REALM=${token.realm}`); + console.log(`APPORIGIN=${token.appOrigin}`); + console.log(`EXPIRES=${new Date(token.expiresAt).toISOString()}`); + console.log(`CACHED=${cached ? 'true' : 'false'}`); + process.exit(0); + } catch (err) { + console.error(`[browse] agent-token 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. From 2b3bb8a4cae2e366d9238abc20f30fadf6842aa1 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 09:52:49 +0300 Subject: [PATCH 04/14] test(browse): characterize single-session behavior before ContextSession extraction (agent-browser U4) Pins tab-ownership + global-buffer semantics that the default/legacy session must reproduce unchanged after the isolation refactor. Co-Authored-By: Claude Opus 4.8 --- browse/test/session-characterization.test.ts | 73 ++++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 browse/test/session-characterization.test.ts 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([]); + }); +}); From e8cf0ac3736a417b64f1c96360b0eb8900e27748 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:36:59 +0300 Subject: [PATCH 05/14] feat(browse): per-session isolated BrowserContext via SessionState facade (agent-browser U4) BrowserManager holds Map + the shared Browser. Per-tab / per-context fields (context, pages, tabs, headers, UA, viewport, dialog policy, ownership, guardrails, and the three observability buffers) become private accessors into the current session's bundle, so existing method bodies operate on the active session unchanged. A lazily-created default session reproduces pre-U4 single-session behavior for callers that send no session. wirePageEvents binds each page's async listeners to the session that OWNED the page at wire-time, so console/network/dialog events never cross-contaminate when the active session switches. Adds ensureSession/closeSession/getBuffers/ getAllSessions. read-commands resolves buffers via bm.getBuffers(). Co-Authored-By: Claude Opus 4.8 --- browse/src/browser-manager.ts | 245 ++++++++++++++++++++++++++++------ browse/src/read-commands.ts | 6 +- 2 files changed, 206 insertions(+), 45 deletions(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index f9f3317b51..ef74311122 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,121 @@ 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; +} + +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), + }; +} + +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 +286,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 +1015,70 @@ 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(); + } + + /** 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 { @@ -1683,22 +1825,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 +1861,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 +1877,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 +1894,7 @@ export class BrowserManager { }); page.on('console', (msg) => { - addConsoleEntry({ + owner.consoleBuffer.push({ timestamp: Date.now(), level: msg.type(), text: msg.text(), @@ -1747,7 +1902,7 @@ export class BrowserManager { }); page.on('request', (req) => { - addNetworkEntry({ + owner.networkBuffer.push({ timestamp: Date.now(), method: req.method(), url: req.url(), @@ -1758,6 +1913,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 +1943,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/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.'; From aca4ea04216fa4828dd36df7ffde33428cce20a6 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:36:59 +0300 Subject: [PATCH 06/14] feat(browse): session-aware command routing + per-session log flush (agent-browser U4) handleCommandInternal (the choke point every caller passes through) resolves an optional `session` field to an isolated context via ensureSession before the command runs and restores the prior session in a finally, so a subsequent no-session command lands on the default. Chain subcommands inherit the pinned session; batch forwards session per subcommand. flushBuffers iterates every session with per-session cursors, tagging non-default lines by session id. Co-Authored-By: Claude Opus 4.8 --- browse/src/server.ts | 109 ++++++++++++++++++++++++++++--------------- 1 file changed, 71 insertions(+), 38 deletions(-) diff --git a/browse/src/server.ts b/browse/src/server.ts index 301781acce..e93ed71464 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; + } - // 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; + 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; + } + + 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 { @@ -1307,12 +1316,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 +2599,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 }, ); From 76ff424b613124404a1b4f1050aaa81682855614 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:36:59 +0300 Subject: [PATCH 07/14] =?UTF-8?q?test(browse):=20session=20isolation=20?= =?UTF-8?q?=E2=80=94=20owner-bound=20buffers=20+=20registry=20(agent-brows?= =?UTF-8?q?er=20U4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- browse/test/session-isolation.test.ts | 96 +++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 browse/test/session-isolation.test.ts 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); + }); +}); From a16dae9c335a6a72c62ae817e8274e86ea0f4983 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:51:41 +0300 Subject: [PATCH 08/14] feat(browse): origin-scoped localStorage injection primitive + inject-auth command (agent-browser U5) injectOriginScopedStorage adds a per-session addInitScript that writes localStorage[key] ONLY when location.origin === appOrigin, so a bearer never leaks to a non-app origin (R5). Injections are recorded on the session and re-applied on context recreation (viewport/scale) so auth isn't silently dropped. Exposed as the `inject-auth ` write command, running within the routed session. Payload must be a non-empty JSON object; adapter-level shape validation already happened at acquire. Co-Authored-By: Claude Opus 4.8 --- browse/src/browser-manager.ts | 54 +++++++++++++++++++++++++++++++++++ browse/src/commands.ts | 2 ++ browse/src/server.ts | 14 ++++++++- 3 files changed, 69 insertions(+), 1 deletion(-) diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index ef74311122..a7bc910c71 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -174,6 +174,11 @@ export interface SessionState { 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 }>; } const SESSION_BUFFER_CAP = 50_000; @@ -197,9 +202,23 @@ function createSessionState(): SessionState { consoleBuffer: new CircularBuffer(SESSION_BUFFER_CAP), networkBuffer: new CircularBuffer(SESSION_BUFFER_CAP), dialogBuffer: new CircularBuffer(SESSION_BUFFER_CAP), + authInitScripts: [], }; } +/** 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 { @@ -1065,6 +1084,31 @@ export class BrowserManager { 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 }); + } + /** 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.'); @@ -1573,6 +1617,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); } @@ -1599,6 +1650,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 { diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 73bc9ab1bf..453836ba60 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', ]); export const META_COMMANDS = new Set([ @@ -139,6 +140,7 @@ 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 ' }, // 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/server.ts b/browse/src/server.ts index e93ed71464..5766a3b4ca 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1109,7 +1109,19 @@ async function handleCommandInternalImpl( // phase into the centralized wrap block below. let hiddenContentWarnings: string[] = []; - if (READ_COMMANDS.has(command)) { + 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, From 567de3bed6d7e7a7fd44ab06a6cd23c141b84f5c Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:51:41 +0300 Subject: [PATCH 09/14] =?UTF-8?q?test(browse):=20origin-scoped=20injection?= =?UTF-8?q?=20=E2=80=94=20R5=20no-leak=20guard=20+=20validation=20(agent-b?= =?UTF-8?q?rowser=20U5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 --- browse/test/inject-auth.test.ts | 80 +++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 browse/test/inject-auth.test.ts 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":[]}' }]); + }); +}); From 7f90f3525e0bf10c967c6dafbd891b1e1e3ff6c0 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:51:41 +0300 Subject: [PATCH 10/14] test(browse): pass BrowserManager to read-command wrapper for per-session buffers (agent-browser U4) The shared test wrapper dropped the browserManager arg; console/network/dialog reads now resolve buffers per-session via bm.getBuffers(), so the wrapper must forward it. Buffer-seeding in the console-errors suite targets the session buffer. Co-Authored-By: Claude Opus 4.8 --- browse/test/commands.test.ts | 28 ++++++++++++++++------------ 1 file changed, 16 insertions(+), 12 deletions(-) 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(); }); }); From ff68ff7e9fd522101f84d346da20fb9266a7fc3d Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:54:03 +0300 Subject: [PATCH 11/14] feat(browse): agent-open end-to-end entrypoint (agent-browser U6) env [path] -> isolated headless session with a cached-or-acquired token injected origin-scoped, then navigated. Acquires CLI-side (op/Touch-ID in the terminal), auto-generates a hidden session id (--; --session overrides), ensures the daemon, then POSTs inject-auth + goto with the session. Emits a parseable, secret-free summary (SESSION/ENV/REALM/URL/EXPIRES/CACHED). --headed is deferred to Phase 2. Co-Authored-By: Claude Opus 4.8 --- browse/src/cli.ts | 62 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 9408b49ac2..69b36e02cd 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1072,6 +1072,68 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: } } + // ─── 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 positional = commandArgs.filter((arg) => !arg.startsWith('-')); + const envName = positional[0]; + const navPath = positional[1] ?? '/'; + const forceRefresh = commandArgs.includes('--refresh'); + const sessionFlagIdx = commandArgs.indexOf('--session'); + const explicitSession = sessionFlagIdx >= 0 ? commandArgs[sessionFlagIdx + 1] : undefined; + 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. From 47bd8578e111abd3a7eb72ec4718320fd3c93083 Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:57:14 +0300 Subject: [PATCH 12/14] fix(browse): isProcessAlive treats EPERM as alive, not dead (agent-browser U7) The zombie-lock bug: a live Chromium holding the profile SingletonLock, owned such that process.kill(pid,0) returns EPERM, was misread as dead so cleanup skipped it. EPERM = exists-but-unsignalable = alive; only ESRCH is dead. The full headed profile-per-session + reaper rig remains Phase-2 follow-up. Co-Authored-By: Claude Opus 4.8 --- browse/src/error-handling.ts | 8 +++++- browse/test/is-process-alive-eperm.test.ts | 32 ++++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 browse/test/is-process-alive-eperm.test.ts 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/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); + }); +}); From 51184b8129b50d60feea15c75c4054fb75f0e5ef Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 10:58:58 +0300 Subject: [PATCH 13/14] feat(browse): per-session Playwright tracing + show-trace viewer (agent-browser U8) trace start / trace stop record a per-session Playwright trace on the session's context; show-trace opens it in Playwright's viewer (CLI-side, no daemon). Completes the mode-map's 'watch what happened' path without a live headed browser. Co-Authored-By: Claude Opus 4.8 --- browse/src/browser-manager.ts | 24 +++++++++++++++++ browse/src/cli.ts | 18 +++++++++++++ browse/src/commands.ts | 3 ++- browse/src/server.ts | 17 +++++++++++- browse/test/tracing.test.ts | 50 +++++++++++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 2 deletions(-) create mode 100644 browse/test/tracing.test.ts diff --git a/browse/src/browser-manager.ts b/browse/src/browser-manager.ts index a7bc910c71..24e1e6bd1a 100644 --- a/browse/src/browser-manager.ts +++ b/browse/src/browser-manager.ts @@ -179,6 +179,8 @@ export interface SessionState { * 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; @@ -203,6 +205,7 @@ function createSessionState(): SessionState { networkBuffer: new CircularBuffer(SESSION_BUFFER_CAP), dialogBuffer: new CircularBuffer(SESSION_BUFFER_CAP), authInitScripts: [], + tracing: false, }; } @@ -1109,6 +1112,27 @@ export class BrowserManager { 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.'); diff --git a/browse/src/cli.ts b/browse/src/cli.ts index 69b36e02cd..a444e2ed43 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1072,6 +1072,24 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: } } + // ─── 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 diff --git a/browse/src/commands.ts b/browse/src/commands.ts index 453836ba60..71b7ebf498 100644 --- a/browse/src/commands.ts +++ b/browse/src/commands.ts @@ -27,7 +27,7 @@ export const WRITE_COMMANDS = new Set([ 'upload', 'dialog-accept', 'dialog-dismiss', 'style', 'cleanup', 'prettyscreenshot', 'download', 'scrape', 'archive', - 'inject-auth', + 'inject-auth', 'trace', ]); export const META_COMMANDS = new Set([ @@ -141,6 +141,7 @@ export const COMMAND_DESCRIPTIONS: Record [--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/server.ts b/browse/src/server.ts index 5766a3b4ca..11a639a388 100644 --- a/browse/src/server.ts +++ b/browse/src/server.ts @@ -1109,7 +1109,22 @@ async function handleCommandInternalImpl( // phase into the centralized wrap block below. let hiddenContentWarnings: string[] = []; - if (command === 'inject-auth') { + 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; 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); + }); +}); From 484a11a218d49433a227c955df22f86275f1294a Mon Sep 17 00:00:00 2001 From: Yarden Date: Sun, 26 Jul 2026 14:10:03 +0300 Subject: [PATCH 14/14] fix(browse): agent-token --print mode + agent-open --session value parsing --print emits the bare bearer on stdout (secret-free summary moves to stderr) so shell capture like BACKEND_JWT=$(browse agent-token --print) works; default output stays summary-only with no bearer. agent-open no longer treats the --session flag's value as a positional nav path. Co-Authored-By: Claude Opus 4.8 --- browse/src/cli.ts | 33 +++++++++++++++++++++++---------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/browse/src/cli.ts b/browse/src/cli.ts index a444e2ed43..091969d0e0 100644 --- a/browse/src/cli.ts +++ b/browse/src/cli.ts @@ -1045,10 +1045,13 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // 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. NEVER prints the bearer. + // 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); @@ -1059,12 +1062,17 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: frontendRoot: process.cwd(), forceRefresh, }); - // Machine-parseable, secret-free summary. - console.log(`ENV=${token.env}`); - console.log(`REALM=${token.realm}`); - console.log(`APPORIGIN=${token.appOrigin}`); - console.log(`EXPIRES=${new Date(token.expiresAt).toISOString()}`); - console.log(`CACHED=${cached ? 'true' : 'false'}`); + // 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}`); @@ -1096,12 +1104,17 @@ Refs: After 'snapshot', use @e1, @e2... as selectors: // parseable, secret-free summary. --session overrides the id; --headed is // deferred to Phase 2 (headless only for now). if (command === 'agent-open') { - const positional = commandArgs.filter((arg) => !arg.startsWith('-')); - const envName = positional[0]; - const navPath = positional[1] ?? '/'; 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.'); }