Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
323 changes: 279 additions & 44 deletions browse/src/browser-manager.ts

Large diffs are not rendered by default.

126 changes: 126 additions & 0 deletions browse/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1039,6 +1041,130 @@ Refs: After 'snapshot', use @e1, @e2... as selectors:
const command = args[0];
const commandArgs = args.slice(1);

// ─── Agent auto-login token (pre-server command) ────────────
// agent-token acquires (or reuses a cached) bearer for a frontend env via
// the repo's .agent-browser.config.mjs adapter. Runs CLI-side so `op` /
// Touch-ID prompt in the user's terminal, not the daemon. Pure HTTP + op;
// no browser needed. Default output is a secret-free summary; --print emits
// the bare bearer on stdout (summary moves to stderr) for shell capture like
// BACKEND_JWT=$(browse agent-token <env> --print).
if (command === 'agent-token') {
const envName = commandArgs.find((arg) => !arg.startsWith('-'));
const forceRefresh = commandArgs.includes('--refresh');
const printBearer = commandArgs.includes('--print');
if (!envName) {
console.error('[browse] error: agent-token needs an env, e.g. `browse agent-token demo`');
process.exit(1);
}
try {
const adapter = await loadAdapter(process.cwd());
const { token, cached } = await acquireOrLoad(adapter, envName, {
frontendRoot: process.cwd(),
forceRefresh,
});
// Machine-parseable, secret-free summary. With --print it goes to stderr
// so stdout carries exactly the bearer and nothing else.
const summary = printBearer ? console.error : console.log;
summary(`ENV=${token.env}`);
summary(`REALM=${token.realm}`);
summary(`APPORIGIN=${token.appOrigin}`);
summary(`EXPIRES=${new Date(token.expiresAt).toISOString()}`);
summary(`CACHED=${cached ? 'true' : 'false'}`);
if (printBearer) {
process.stdout.write(adapter.token.bearer(token.auth) + '\n');
}
process.exit(0);
} catch (err) {
console.error(`[browse] agent-token failed: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}

// ─── Trace viewer (agent-browser U8, pre-server) ────────────
// Opens a trace zip produced by `trace stop` in Playwright's trace viewer.
// Runs CLI-side (no daemon) — it launches its own viewer UI.
if (command === 'show-trace') {
const traceFile = commandArgs.find((arg) => !arg.startsWith('-'));
if (!traceFile) {
console.error('[browse] error: show-trace needs a trace file, e.g. `browse show-trace trace.zip`');
process.exit(1);
}
const viewer = nodeSpawn('npx', ['playwright', 'show-trace', traceFile], { stdio: 'inherit' });
viewer.on('error', (err) => {
console.error(`[browse] could not launch the Playwright trace viewer: ${err.message}`);
process.exit(1);
});
viewer.on('exit', (code) => process.exit(code ?? 0));
return;
}

// ─── Agent-browser end-to-end entrypoint (agent-browser U6) ──
// env [path] → isolated headless session, cached-or-acquired token injected
// origin-scoped, navigated. Hides the session id (auto-generated) and emits a
// parseable, secret-free summary. --session overrides the id; --headed is
// deferred to Phase 2 (headless only for now).
if (command === 'agent-open') {
const forceRefresh = commandArgs.includes('--refresh');
const sessionFlagIdx = commandArgs.indexOf('--session');
const explicitSession = sessionFlagIdx >= 0 ? commandArgs[sessionFlagIdx + 1] : undefined;
// Positionals = args that are neither flags nor a flag's value (--session
// takes one); naive startsWith('-') filtering leaked the session id into
// the nav path.
const positional = commandArgs.filter(
(arg, idx) => !arg.startsWith('-') && (sessionFlagIdx < 0 || idx !== sessionFlagIdx + 1),
);
const envName = positional[0];
const navPath = positional[1] ?? '/';
if (commandArgs.includes('--headed')) {
console.error('[browse] --headed is deferred to a later phase; opening headless.');
}
if (!envName) {
console.error('[browse] error: agent-open needs an env, e.g. `browse agent-open demo /profiles`');
process.exit(1);
}
try {
const adapter = await loadAdapter(process.cwd());
const { token, cached } = await acquireOrLoad(adapter, envName, {
frontendRoot: process.cwd(),
forceRefresh,
});
const sessionId =
explicitSession ??
`${process.env.USER ?? 'agent'}-${path.basename(process.cwd())}-${Math.random().toString(36).slice(2, 8)}`;
const url = token.appOrigin.replace(/\/$/, '') + (navPath.startsWith('/') ? navPath : `/${navPath}`);

const state = await ensureServer(globalFlags);
const post = async (cmd: string, args: string[]) => {
const resp = await fetch(`http://127.0.0.1:${state.port}/command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${state.token}` },
body: JSON.stringify({ command: cmd, args, session: sessionId }),
signal: AbortSignal.timeout(30000),
});
if (!resp.ok) {
throw new Error(`${cmd} failed (${resp.status}): ${(await resp.text()).slice(0, 200)}`);
}
};

// inject-auth creates the session's isolated context, then registers the
// origin-scoped init script; the subsequent goto navigates with auth live.
await post('inject-auth', [token.appOrigin, adapter.token.storageKey, JSON.stringify(token.auth)]);
await post('goto', [url]);

// Secret-free, parseable summary.
console.log(`SESSION=${sessionId}`);
console.log(`ENV=${token.env}`);
console.log(`REALM=${token.realm}`);
console.log(`URL=${url}`);
console.log(`EXPIRES=${new Date(token.expiresAt).toISOString()}`);
console.log(`CACHED=${cached ? 'true' : 'false'}`);
process.exit(0);
} catch (err) {
console.error(`[browse] agent-open failed: ${err instanceof Error ? err.message : err}`);
process.exit(1);
}
}

// ─── Headed Connect (pre-server command) ────────────────────
// connect must be handled BEFORE ensureServer() because it needs
// to restart the server in headed mode with the Chrome extension.
Expand Down
3 changes: 3 additions & 0 deletions browse/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const WRITE_COMMANDS = new Set([
'upload', 'dialog-accept', 'dialog-dismiss',
'style', 'cleanup', 'prettyscreenshot',
'download', 'scrape', 'archive',
'inject-auth', 'trace',
]);

export const META_COMMANDS = new Set([
Expand Down Expand Up @@ -139,6 +140,8 @@ export const COMMAND_DESCRIPTIONS: Record<string, { category: string; descriptio
'download': { category: 'Extraction', description: 'Download URL or media element to disk using browser cookies. Use --navigate for URLs that trigger browser downloads (CDN redirects, Content-Disposition, anti-bot protected sites)', usage: 'download <url|@ref> [path] [--base64] [--navigate]' },
'scrape': { category: 'Extraction', description: 'Bulk download all media from page. Writes manifest.json', usage: 'scrape <images|videos|media> [--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 <appOrigin> <storageKey> <authJson>' },
'trace': { category: 'Interaction', description: 'Record a Playwright trace for the current session; view a stopped trace with show-trace.', usage: 'trace start | trace stop <path>' },
// Visual
'screenshot': { category: 'Visual', description: 'Save screenshot. --selector targets a specific element (explicit flag form). Positional selectors starting with ./#/@/[ still work.', usage: 'screenshot [--selector <css>] [--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 <payload.json> for large payloads. Use --tab-id <N> to target a specific tab.', usage: 'pdf [path] [--format letter|a4|legal] [--width <dim> --height <dim>] [--margins <dim>] [--margin-top <dim> --margin-right <dim> --margin-bottom <dim> --margin-left <dim>] [--header-template <html>] [--footer-template <html>] [--page-numbers] [--tagged] [--outline] [--print-background] [--prefer-css-page-size] [--toc] [--tab-id <N>] | pdf --from-file <payload.json> [--tab-id <N>]' },
Expand Down
8 changes: 7 additions & 1 deletion browse/src/error-handling.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
183 changes: 183 additions & 0 deletions browse/src/frontend-adapter.ts
Original file line number Diff line number Diff line change
@@ -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/<repo>/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: { <name>: { 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<string, EnvConfig>;
/**
* 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<string, unknown>;

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<string, unknown>)) {
const env = raw as Record<string, unknown>;
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<string, unknown> | 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<string, unknown> | 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<FrontendAdapter> {
const file = adapterPath(frontendRoot);
if (!fs.existsSync(file)) {
throw new AdapterError(
`No agent-browser adapter found at ${file}. ` +
`Author one at agent-tools/consumers/<repo>/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) };
}
6 changes: 5 additions & 1 deletion browse/src/read-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -371,6 +372,7 @@ export async function handleReadCommand(
}

case 'console': {
const { consoleBuffer } = bm.getBuffers();
if (args[0] === '--clear') {
consoleBuffer.clear();
return 'Console buffer cleared.';
Expand All @@ -385,6 +387,7 @@ export async function handleReadCommand(
}

case 'network': {
const { networkBuffer } = bm.getBuffers();
if (args[0] === '--clear') {
networkBuffer.clear();
return 'Network buffer cleared.';
Expand Down Expand Up @@ -440,6 +443,7 @@ export async function handleReadCommand(
}

case 'dialog': {
const { dialogBuffer } = bm.getBuffers();
if (args[0] === '--clear') {
dialogBuffer.clear();
return 'Dialog buffer cleared.';
Expand Down
Loading