Files
chrome-use/src/state-utils.ts
T
Aman panditandChris Tate 697b788af0 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>
2026-02-13 11:56:20 -06:00

225 lines
6.5 KiB
TypeScript

/**
* Shared utilities for session state management.
*/
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
} from './encryption.js';
/**
* Get the session persistence directory.
* Located at ~/.agent-browser/sessions/
*/
export function getSessionsDir(): string {
return path.join(os.homedir(), '.agent-browser', 'sessions');
}
/**
* Ensure the sessions directory exists with proper permissions.
* Creates directory with mode 0o700 (owner only).
*/
export function ensureSessionsDir(): string {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
fs.mkdirSync(sessionsDir, { recursive: true, mode: 0o700 });
}
return sessionsDir;
}
/**
* Validate a session ID to prevent path traversal attacks.
* Only allows alphanumeric characters, hyphens, and underscores.
*/
function isValidSessionId(id: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(id);
}
/**
* Validate a session name for safety (no path traversal).
* Only allows alphanumeric characters, dashes, and underscores.
* This validation is critical for security - the daemon reads session names
* from environment variables which can be set by attackers bypassing CLI validation.
*/
export function isValidSessionName(name: string): boolean {
return /^[a-zA-Z0-9_-]+$/.test(name);
}
/**
* Get the auto-save state file path for a session.
* Pattern: {SESSION_NAME}-{SESSION_ID}.json
*
* @param sessionName - The session name (e.g., "twitter")
* @param sessionId - The session ID (e.g., "default" or "agent1")
* @returns Full path to the state file, or null if sessionName is empty
* @throws Error if sessionName or sessionId contains invalid characters (path traversal prevention)
*/
export function getAutoStateFilePath(sessionName: string, sessionId: string): string | null {
if (!sessionName) return null;
// SECURITY: Validate sessionName to prevent path traversal attacks.
// The daemon reads AGENT_BROWSER_SESSION_NAME from environment which
// can be set directly by attackers, bypassing CLI validation.
if (!isValidSessionName(sessionName)) {
throw new Error(
`Invalid session name '${sessionName}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
if (!isValidSessionId(sessionId)) {
throw new Error(
`Invalid session ID '${sessionId}'. Only alphanumeric characters, hyphens, and underscores are allowed.`
);
}
const sessionsDir = ensureSessionsDir();
return path.join(sessionsDir, `${sessionName}-${sessionId}.json`);
}
/**
* Check if an auto-state file exists for a session.
*/
export function autoStateFileExists(sessionName: string, sessionId: string): boolean {
const filePath = getAutoStateFilePath(sessionName, sessionId);
return filePath ? fs.existsSync(filePath) : false;
}
/**
* Write state data to file, encrypting if encryption key is available.
*
* @param filepath - Path to write the state file
* @param data - State data object to write
* @returns Object indicating whether the file was encrypted
*/
export function writeStateFile(filepath: string, data: object): { encrypted: boolean } {
const key = getEncryptionKey();
const jsonData = JSON.stringify(data, null, 2);
if (key) {
const encrypted = encryptData(jsonData, key);
fs.writeFileSync(filepath, JSON.stringify(encrypted, null, 2));
return { encrypted: true };
}
fs.writeFileSync(filepath, jsonData);
return { encrypted: false };
}
/**
* Read state data from file, decrypting if necessary.
*
* @param filepath - Path to the state file
* @returns Object containing the data and whether it was encrypted
* @throws Error if file is encrypted but no key is available
*/
export function readStateFile(filepath: string): { data: object; wasEncrypted: boolean } {
const content = fs.readFileSync(filepath, 'utf-8');
const parsed = JSON.parse(content);
if (isEncryptedPayload(parsed)) {
const key = getEncryptionKey();
if (!key) {
throw new Error(
`State file is encrypted but ${ENCRYPTION_KEY_ENV} is not set. ` +
`Set the environment variable to decrypt.`
);
}
const decrypted = decryptData(parsed, key);
return { data: JSON.parse(decrypted), wasEncrypted: true };
}
return { data: parsed, wasEncrypted: false };
}
/**
* List all state files in the sessions directory.
* @returns Array of filenames ending in .json
*/
export function listStateFiles(): string[] {
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
return fs.readdirSync(sessionsDir).filter((f) => f.endsWith('.json'));
}
/**
* Clean up state files older than specified days.
* @param days - Maximum age in days (files older than this are deleted)
* @returns Array of deleted filenames
*/
export function cleanupExpiredStates(days: number): string[] {
if (days <= 0) return [];
const sessionsDir = getSessionsDir();
if (!fs.existsSync(sessionsDir)) {
return [];
}
const now = Date.now();
const maxAge = days * 24 * 60 * 60 * 1000;
const deleted: string[] = [];
const files = listStateFiles();
for (const file of files) {
const filepath = path.join(sessionsDir, file);
try {
const stats = fs.statSync(filepath);
if (now - stats.mtime.getTime() > maxAge) {
fs.unlinkSync(filepath);
deleted.push(file);
}
} catch {
// Ignore individual file errors
}
}
return deleted;
}
const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'];
/**
* Safely merge headers without prototype pollution risk.
* Filters out dangerous keys like __proto__, constructor, prototype.
* @param base - Base headers object
* @param override - Headers to merge (takes precedence)
* @returns Merged headers object (null-prototype)
*/
export function safeHeaderMerge(
base: Record<string, string>,
override: Record<string, string>
): Record<string, string> {
const result: Record<string, string> = Object.create(null);
for (const [key, value] of Object.entries(base)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
for (const [key, value] of Object.entries(override)) {
if (!DANGEROUS_KEYS.includes(key)) {
result[key] = value;
}
}
return result;
}
// Re-export encryption utilities
export {
getEncryptionKey,
encryptData,
decryptData,
isEncryptedPayload,
type EncryptedPayload,
ENCRYPTION_KEY_ENV,
};