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
+111
View File
@@ -0,0 +1,111 @@
/**
* Encryption utilities for state file protection using AES-256-GCM.
*/
import * as crypto from 'crypto';
// ============================================
// Constants
// ============================================
export const ENCRYPTION_ALGORITHM = 'aes-256-gcm';
export const ENCRYPTION_KEY_ENV = 'AGENT_BROWSER_ENCRYPTION_KEY';
export const IV_LENGTH = 12; // 96 bits for GCM
/**
* Encrypted payload structure.
*/
export interface EncryptedPayload {
version: 1;
encrypted: true;
iv: string; // Base64 encoded
authTag: string; // Base64 encoded
data: string; // Base64 encoded ciphertext
}
/**
* Get encryption key from environment variable.
* The key should be a 32-byte (256-bit) hex-encoded string (64 characters).
* Generate with: openssl rand -hex 32
*
* @returns Buffer containing the key, or null if not set/invalid
*/
export function getEncryptionKey(): Buffer | null {
const keyHex = process.env[ENCRYPTION_KEY_ENV];
if (!keyHex) return null;
// Key should be 64 hex chars = 32 bytes = 256 bits
if (!/^[a-fA-F0-9]{64}$/.test(keyHex)) {
console.warn(
`Warning: ${ENCRYPTION_KEY_ENV} should be a 64-character hex string (256 bits). ` +
`Generate one with: openssl rand -hex 32`
);
return null;
}
return Buffer.from(keyHex, 'hex');
}
/**
* Encrypt data using AES-256-GCM.
* Returns a JSON-serializable payload with IV, auth tag, and encrypted data.
*
* @param plaintext - The string to encrypt
* @param key - The 256-bit encryption key
* @returns Encrypted payload object
*/
export function encryptData(plaintext: string, key: Buffer): EncryptedPayload {
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ENCRYPTION_ALGORITHM, key, iv);
let encrypted = cipher.update(plaintext, 'utf8');
encrypted = Buffer.concat([encrypted, cipher.final()]);
return {
version: 1,
encrypted: true,
iv: iv.toString('base64'),
authTag: cipher.getAuthTag().toString('base64'),
data: encrypted.toString('base64'),
};
}
/**
* Decrypt data using AES-256-GCM.
*
* @param payload - The encrypted payload object
* @param key - The 256-bit encryption key
* @returns Decrypted plaintext string
* @throws Error if decryption fails (wrong key, tampered data, etc.)
*/
export function decryptData(payload: EncryptedPayload, key: Buffer): string {
const iv = Buffer.from(payload.iv, 'base64');
const authTag = Buffer.from(payload.authTag, 'base64');
const encryptedData = Buffer.from(payload.data, 'base64');
const decipher = crypto.createDecipheriv(ENCRYPTION_ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedData);
decrypted = Buffer.concat([decrypted, decipher.final()]);
return decrypted.toString('utf8');
}
/**
* Check if a parsed JSON object is an encrypted payload.
*
* @param data - The object to check
* @returns True if the object is a valid encrypted payload
*/
export function isEncryptedPayload(data: unknown): data is EncryptedPayload {
return (
typeof data === 'object' &&
data !== null &&
'encrypted' in data &&
(data as EncryptedPayload).encrypted === true &&
'version' in data &&
'iv' in data &&
'authTag' in data &&
'data' in data
);
}