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:
Aman pandit
2026-02-13 11:56:20 -06:00
committed by GitHub
co-authored by Chris Tate
parent cdd10ebb54
commit 697b788af0
21 changed files with 1989 additions and 40 deletions
+94 -5
View File
@@ -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 }),
});
}