Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 65 additions & 0 deletions browse/src/audit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* Persistent command audit log — forensic trail for all browse server commands.
*
* Writes append-only JSONL to .gstack/browse-audit.jsonl. Unlike the in-memory
* ring buffers (console, network, dialog), the audit log persists across server
* restarts and is never truncated by the server. Each entry records:
*
* - timestamp, command, args (truncated), page origin
* - duration, status (ok/error), error message if any
* - whether cookies were imported (elevated security context)
* - connection mode (headless/headed)
*
* All writes are best-effort — audit failures never cause command failures.
*/

import * as fs from 'fs';

export interface AuditEntry {
ts: string;
cmd: string;
args: string;
origin: string;
durationMs: number;
status: 'ok' | 'error';
error?: string;
hasCookies: boolean;
mode: 'launched' | 'headed';
}

const MAX_ARGS_LENGTH = 200;
const MAX_ERROR_LENGTH = 300;

let auditPath: string | null = null;

export function initAuditLog(logPath: string): void {
auditPath = logPath;
}

export function writeAuditEntry(entry: AuditEntry): void {
if (!auditPath) return;
try {
const truncatedArgs = entry.args.length > MAX_ARGS_LENGTH
? entry.args.slice(0, MAX_ARGS_LENGTH) + '…'
: entry.args;
const truncatedError = entry.error && entry.error.length > MAX_ERROR_LENGTH
? entry.error.slice(0, MAX_ERROR_LENGTH) + '…'
: entry.error;

const record: Record<string, unknown> = {
ts: entry.ts,
cmd: entry.cmd,
args: truncatedArgs,
origin: entry.origin,
durationMs: entry.durationMs,
status: entry.status,
hasCookies: entry.hasCookies,
mode: entry.mode,
};
if (truncatedError) record.error = truncatedError;

fs.appendFileSync(auditPath, JSON.stringify(record) + '\n');
} catch {
// Audit write failures are silent — never block command execution
}
}
12 changes: 12 additions & 0 deletions browse/src/browser-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ export class BrowserManager {
private dialogAutoAccept: boolean = true;
private dialogPromptText: string | null = null;

// ─── Cookie Import Tracking (for audit log) ───────────────
private _hasCookieImports: boolean = false;

// ─── Handoff State ─────────────────────────────────────────
private isHeaded: boolean = false;
private consecutiveFailures: number = 0;
Expand Down Expand Up @@ -521,6 +524,15 @@ export class BrowserManager {
return this.dialogPromptText;
}

// ─── Cookie Import Tracking ────────────────────────────────
markCookiesImported(): void {
this._hasCookieImports = true;
}

hasCookieImports(): boolean {
return this._hasCookieImports;
}

// ─── Viewport ──────────────────────────────────────────────
async setViewport(width: number, height: number) {
await this.getPage().setViewportSize({ width, height });
Expand Down
2 changes: 2 additions & 0 deletions browse/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface BrowseConfig {
consoleLog: string;
networkLog: string;
dialogLog: string;
auditLog: string;
}

/**
Expand Down Expand Up @@ -70,6 +71,7 @@ export function resolveConfig(
consoleLog: path.join(stateDir, 'browse-console.log'),
networkLog: path.join(stateDir, 'browse-network.log'),
dialogLog: path.join(stateDir, 'browse-dialog.log'),
auditLog: path.join(stateDir, 'browse-audit.jsonl'),
};
}

Expand Down
31 changes: 29 additions & 2 deletions browse/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { COMMAND_DESCRIPTIONS } from './commands';
import { handleSnapshot, SNAPSHOT_FLAGS } from './snapshot';
import { resolveConfig, ensureStateDir, readVersionHash } from './config';
import { emitActivity, subscribe, getActivityAfter, getActivityHistory, getSubscriberCount } from './activity';
import { initAuditLog, writeAuditEntry } from './audit';
// Bun.spawn used instead of child_process.spawn (compiled bun binaries
// fail posix_spawn on all executables including /bin/bash)
import * as fs from 'fs';
Expand All @@ -33,6 +34,7 @@ import * as crypto from 'crypto';
// ─── Config ─────────────────────────────────────────────────────
const config = resolveConfig();
ensureStateDir(config);
initAuditLog(config.auditLog);

// ─── Auth ───────────────────────────────────────────────────────
const AUTH_TOKEN = crypto.randomUUID();
Expand Down Expand Up @@ -707,37 +709,62 @@ async function handleCommand(body: any): Promise<Response> {
}

// Activity: emit command_end (success)
const successDuration = Date.now() - startTime;
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
duration: successDuration,
status: 'ok',
result: result,
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
});

writeAuditEntry({
ts: new Date().toISOString(),
cmd: command,
args: args.join(' '),
origin: browserManager.getCurrentUrl(),
durationMs: successDuration,
status: 'ok',
hasCookies: browserManager.hasCookieImports(),
mode: browserManager.getConnectionMode(),
});

browserManager.resetFailures();
return new Response(result, {
status: 200,
headers: { 'Content-Type': 'text/plain' },
});
} catch (err: any) {
// Activity: emit command_end (error)
const errorDuration = Date.now() - startTime;
emitActivity({
type: 'command_end',
command,
args,
url: browserManager.getCurrentUrl(),
duration: Date.now() - startTime,
duration: errorDuration,
status: 'error',
error: err.message,
tabs: browserManager.getTabCount(),
mode: browserManager.getConnectionMode(),
});

writeAuditEntry({
ts: new Date().toISOString(),
cmd: command,
args: args.join(' '),
origin: browserManager.getCurrentUrl(),
durationMs: errorDuration,
status: 'error',
error: err.message,
hasCookies: browserManager.hasCookieImports(),
mode: browserManager.getConnectionMode(),
});

browserManager.incrementFailures();
let errorMsg = wrapError(err);
const hint = browserManager.getFailureHint();
Expand Down
2 changes: 2 additions & 0 deletions browse/src/write-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,7 @@ export async function handleWriteCommand(
}

await page.context().addCookies(cookies);
bm.markCookiesImported();
return `Loaded ${cookies.length} cookies from ${filePath}`;
}

Expand All @@ -333,6 +334,7 @@ export async function handleWriteCommand(
const result = await importCookies(browser, [domain], profile);
if (result.cookies.length > 0) {
await page.context().addCookies(result.cookies);
bm.markCookiesImported();
}
const msg = [`Imported ${result.count} cookies for ${domain} from ${browser}`];
if (result.failed > 0) msg.push(`(${result.failed} failed to decrypt)`);
Expand Down