feat: add session persistence, state management commands, and --new-tab click (#184)
Rebased and fixed implementation of PR #184 features on current main: Session persistence: - --session-name flag and AGENT_BROWSER_SESSION_NAME env var auto-save/restore cookies and localStorage across browser restarts - State files stored in ~/.agent-browser/sessions/ with owner-only permissions - AES-256-GCM encryption via AGENT_BROWSER_ENCRYPTION_KEY env var - Auto-expiration of old state files (AGENT_BROWSER_STATE_EXPIRE_DAYS, default 30) State management commands: - state list: list saved state files with metadata - state show <file>: display state summary (cookies, origins, domains) - state rename <old> <new>: rename state files - state clear [name] [--all]: clear saved states - state clean --older-than <days>: delete expired states New --new-tab flag for click command: - Opens link href in a new tab instead of navigating the current tab Security hardening: - Session name validation prevents path traversal (CLI + daemon) - safeHeaderMerge prevents prototype pollution in header merging - WebSocket stream server binds to 127.0.0.1 only - State files written with 0o600 permissions Fixes applied over the original PR: - Use color.rs module instead of hardcoded ANSI escape codes - Align CLI output field names with daemon response format - Add CLI-level --session-name validation (not just daemon-side) - Avoid adding "DOM" to tsconfig.json lib (use proper typing in evaluate) - Keep version at 0.9.3 (matches current main) - Centralize session name validation in daemon.ts helper - Update all documentation (README, SKILL.md, docs site, --help output) Co-authored-by: Chris Tate <chris@ctate.dev>
This commit is contained in:
co-authored by
Chris Tate
parent
cdd10ebb54
commit
697b788af0
+94
-5
@@ -19,6 +19,13 @@ import os from 'node:os';
|
||||
import { existsSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||
import type { LaunchCommand } from './types.js';
|
||||
import { type RefMap, type EnhancedSnapshot, getEnhancedSnapshot, parseRef } from './snapshot.js';
|
||||
import { safeHeaderMerge } from './state-utils.js';
|
||||
import {
|
||||
getEncryptionKey,
|
||||
isEncryptedPayload,
|
||||
decryptData,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
} from './state-utils.js';
|
||||
|
||||
// Screencast frame data from CDP
|
||||
export interface ScreencastFrame {
|
||||
@@ -102,6 +109,16 @@ export class BrowserManager {
|
||||
private recordingPage: Page | null = null;
|
||||
private recordingOutputPath: string = '';
|
||||
private recordingTempDir: string = '';
|
||||
private launchWarnings: string[] = [];
|
||||
|
||||
/**
|
||||
* Get and clear launch warnings (e.g., decryption failures)
|
||||
*/
|
||||
getAndClearWarnings(): string[] {
|
||||
const warnings = this.launchWarnings;
|
||||
this.launchWarnings = [];
|
||||
return warnings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
@@ -607,10 +624,7 @@ export class BrowserManager {
|
||||
const handler = async (route: Route) => {
|
||||
const requestHeaders = route.request().headers();
|
||||
await route.continue({
|
||||
headers: {
|
||||
...requestHeaders,
|
||||
...headers,
|
||||
},
|
||||
headers: safeHeaderMerge(requestHeaders, headers),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -671,6 +685,13 @@ export class BrowserManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current browser context (first context)
|
||||
*/
|
||||
getContext(): BrowserContext | null {
|
||||
return this.contexts[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save storage state (cookies, localStorage, etc.)
|
||||
*/
|
||||
@@ -1194,13 +1215,81 @@ export class BrowserManager {
|
||||
args: baseArgs,
|
||||
});
|
||||
this.cdpEndpoint = null;
|
||||
|
||||
// Check for auto-load state file (supports encrypted files)
|
||||
let storageState:
|
||||
| string
|
||||
| {
|
||||
cookies: Array<{
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: 'Strict' | 'Lax' | 'None';
|
||||
}>;
|
||||
origins: Array<{
|
||||
origin: string;
|
||||
localStorage: Array<{ name: string; value: string }>;
|
||||
}>;
|
||||
}
|
||||
| undefined = options.storageState ? options.storageState : undefined;
|
||||
|
||||
if (!storageState && options.autoStateFilePath) {
|
||||
try {
|
||||
const fs = await import('fs');
|
||||
if (fs.existsSync(options.autoStateFilePath)) {
|
||||
const content = fs.readFileSync(options.autoStateFilePath, 'utf8');
|
||||
const parsed = JSON.parse(content);
|
||||
|
||||
if (isEncryptedPayload(parsed)) {
|
||||
const key = getEncryptionKey();
|
||||
if (key) {
|
||||
try {
|
||||
const decrypted = decryptData(parsed, key);
|
||||
storageState = JSON.parse(decrypted);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`[DEBUG] Auto-loading session state (decrypted): ${options.autoStateFilePath}`
|
||||
);
|
||||
}
|
||||
} catch (decryptErr) {
|
||||
const warning =
|
||||
'Failed to decrypt state file - wrong encryption key? Starting fresh.';
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Decryption error:`, decryptErr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const warning = `State file is encrypted but ${ENCRYPTION_KEY_ENV} not set - starting fresh`;
|
||||
this.launchWarnings.push(warning);
|
||||
console.error(`[WARN] ${warning}`);
|
||||
}
|
||||
} else {
|
||||
storageState = options.autoStateFilePath;
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Auto-loading session state: ${options.autoStateFilePath}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Failed to load state file, starting fresh:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context = await this.browser.newContext({
|
||||
viewport,
|
||||
extraHTTPHeaders: options.headers,
|
||||
userAgent: options.userAgent,
|
||||
storageState,
|
||||
...(options.proxy && { proxy: options.proxy }),
|
||||
ignoreHTTPSErrors: options.ignoreHTTPSErrors ?? false,
|
||||
...(options.storageState && { storageState: options.storageState }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user