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>
225 lines
6.5 KiB
TypeScript
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,
|
|
};
|