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
+229
-3
@@ -1,8 +1,17 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import type { Page, Frame } from 'playwright-core';
|
||||
import { mkdirSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import type { BrowserManager, ScreencastFrame } from './browser.js';
|
||||
import { getAppDir } from './daemon.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
readStateFile,
|
||||
isValidSessionName,
|
||||
isEncryptedPayload,
|
||||
listStateFiles,
|
||||
cleanupExpiredStates,
|
||||
} from './state-utils.js';
|
||||
import type {
|
||||
Command,
|
||||
Response,
|
||||
@@ -58,6 +67,11 @@ import type {
|
||||
TraceStopCommand,
|
||||
HarStopCommand,
|
||||
StorageStateSaveCommand,
|
||||
StateListCommand,
|
||||
StateClearCommand,
|
||||
StateShowCommand,
|
||||
StateCleanCommand,
|
||||
StateRenameCommand,
|
||||
ConsoleCommand,
|
||||
ErrorsCommand,
|
||||
KeyboardCommand,
|
||||
@@ -349,6 +363,16 @@ export async function executeCommand(command: Command, browser: BrowserManager):
|
||||
return await handleStateSave(command, browser);
|
||||
case 'state_load':
|
||||
return await handleStateLoad(command, browser);
|
||||
case 'state_list':
|
||||
return await handleStateList(command);
|
||||
case 'state_clear':
|
||||
return await handleStateClear(command);
|
||||
case 'state_show':
|
||||
return await handleStateShow(command);
|
||||
case 'state_clean':
|
||||
return await handleStateClean(command);
|
||||
case 'state_rename':
|
||||
return await handleStateRename(command);
|
||||
case 'console':
|
||||
return await handleConsole(command, browser);
|
||||
case 'errors':
|
||||
@@ -501,6 +525,32 @@ async function handleClick(command: ClickCommand, browser: BrowserManager): Prom
|
||||
const locator = browser.getLocator(command.selector);
|
||||
|
||||
try {
|
||||
// If --new-tab flag is set, get the href and open in a new tab
|
||||
if (command.newTab) {
|
||||
const fullUrl = await locator.evaluate((el) => {
|
||||
const href = el.getAttribute('href');
|
||||
// URL and document.baseURI are available in the browser context
|
||||
return href
|
||||
? new (globalThis as any).URL(href, (globalThis as any).document.baseURI).toString()
|
||||
: '';
|
||||
});
|
||||
if (!fullUrl) {
|
||||
throw new Error(
|
||||
`Element '${command.selector}' does not have an href attribute. --new-tab only works on links.`
|
||||
);
|
||||
}
|
||||
|
||||
await browser.newTab();
|
||||
const newPage = browser.getPage();
|
||||
await newPage.goto(fullUrl);
|
||||
|
||||
return successResponse(command.id, {
|
||||
clicked: true,
|
||||
newTab: true,
|
||||
url: fullUrl,
|
||||
});
|
||||
}
|
||||
|
||||
await locator.click({
|
||||
button: command.button,
|
||||
clickCount: command.clickCount,
|
||||
@@ -1426,13 +1476,189 @@ async function handleStateLoad(
|
||||
command: Command & { action: 'state_load'; path: string },
|
||||
browser: BrowserManager
|
||||
): Promise<Response> {
|
||||
// Storage state is loaded at context creation
|
||||
if (browser.isLaunched()) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
'Cannot load state while browser is running. Close browser first, then relaunch with loaded state.'
|
||||
);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(command.path)) {
|
||||
return errorResponse(command.id, `State file not found: ${command.path}`);
|
||||
}
|
||||
|
||||
await browser.launch({
|
||||
id: command.id,
|
||||
action: 'launch',
|
||||
headless: true,
|
||||
autoStateFilePath: command.path,
|
||||
});
|
||||
|
||||
return successResponse(command.id, {
|
||||
note: 'Storage state must be loaded at browser launch. Use --state flag.',
|
||||
loaded: true,
|
||||
path: command.path,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStateList(command: StateListCommand): Promise<Response> {
|
||||
const sessionsDir = getSessionsDir();
|
||||
const files = listStateFiles();
|
||||
|
||||
if (files.length === 0) {
|
||||
return successResponse(command.id, { files: [], directory: sessionsDir });
|
||||
}
|
||||
|
||||
const stateFiles = files
|
||||
.map((filename) => {
|
||||
const filepath = path.join(sessionsDir, filename);
|
||||
const stats = fs.statSync(filepath);
|
||||
|
||||
let encrypted = false;
|
||||
try {
|
||||
const content = fs.readFileSync(filepath, 'utf-8');
|
||||
const parsed = JSON.parse(content);
|
||||
encrypted = isEncryptedPayload(parsed);
|
||||
} catch {
|
||||
// Ignore parse errors
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
path: filepath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
encrypted,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(b.modified).getTime() - new Date(a.modified).getTime());
|
||||
|
||||
return successResponse(command.id, { files: stateFiles, directory: sessionsDir });
|
||||
}
|
||||
|
||||
async function handleStateClear(command: StateClearCommand): Promise<Response> {
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
if (command.sessionName && !isValidSessionName(command.sessionName)) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
'Invalid session name. Use only letters, numbers, dashes, and underscores.'
|
||||
);
|
||||
}
|
||||
|
||||
const files = listStateFiles();
|
||||
if (files.length === 0) {
|
||||
return successResponse(command.id, { cleared: 0, deleted: [] });
|
||||
}
|
||||
|
||||
const deleted: string[] = [];
|
||||
|
||||
if (command.all) {
|
||||
for (const file of files) {
|
||||
fs.unlinkSync(path.join(sessionsDir, file));
|
||||
deleted.push(file);
|
||||
}
|
||||
} else if (command.sessionName) {
|
||||
for (const file of files) {
|
||||
if (file.startsWith(`${command.sessionName}-`)) {
|
||||
fs.unlinkSync(path.join(sessionsDir, file));
|
||||
deleted.push(file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return successResponse(command.id, { cleared: deleted.length, deleted });
|
||||
}
|
||||
|
||||
async function handleStateShow(command: StateShowCommand): Promise<Response> {
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
const baseName = command.filename.replace(/\.json$/, '');
|
||||
if (!command.filename.endsWith('.json') || !isValidSessionName(baseName)) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
'Invalid filename. Use only letters, numbers, dashes, and underscores (with .json extension).'
|
||||
);
|
||||
}
|
||||
|
||||
const filepath = path.join(sessionsDir, command.filename);
|
||||
|
||||
if (!fs.existsSync(filepath)) {
|
||||
return errorResponse(command.id, `State file not found: ${command.filename}`);
|
||||
}
|
||||
|
||||
try {
|
||||
const { data: state, wasEncrypted } = readStateFile(filepath);
|
||||
const stats = fs.statSync(filepath);
|
||||
|
||||
const stateObj = state as {
|
||||
cookies?: Array<{ domain: string }>;
|
||||
origins?: unknown[];
|
||||
};
|
||||
const cookies = stateObj.cookies?.length || 0;
|
||||
const origins = stateObj.origins?.length || 0;
|
||||
const domains = [...new Set((stateObj.cookies || []).map((c) => c.domain))];
|
||||
|
||||
return successResponse(command.id, {
|
||||
filename: command.filename,
|
||||
path: filepath,
|
||||
size: stats.size,
|
||||
modified: stats.mtime.toISOString(),
|
||||
encrypted: wasEncrypted,
|
||||
summary: {
|
||||
cookies,
|
||||
origins,
|
||||
domains,
|
||||
},
|
||||
state,
|
||||
});
|
||||
} catch (e) {
|
||||
return errorResponse(command.id, `Failed to parse state file: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleStateClean(command: StateCleanCommand): Promise<Response> {
|
||||
const deleted = cleanupExpiredStates(command.days);
|
||||
const keptCount = listStateFiles().length;
|
||||
|
||||
return successResponse(command.id, {
|
||||
cleaned: deleted.length,
|
||||
deleted,
|
||||
keptCount,
|
||||
days: command.days,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleStateRename(command: StateRenameCommand): Promise<Response> {
|
||||
const sessionsDir = getSessionsDir();
|
||||
|
||||
if (!isValidSessionName(command.oldName) || !isValidSessionName(command.newName)) {
|
||||
return errorResponse(
|
||||
command.id,
|
||||
'Invalid name. Use only letters, numbers, dashes, and underscores.'
|
||||
);
|
||||
}
|
||||
|
||||
const oldPath = path.join(sessionsDir, `${command.oldName}.json`);
|
||||
const newPath = path.join(sessionsDir, `${command.newName}.json`);
|
||||
|
||||
if (!fs.existsSync(oldPath)) {
|
||||
return errorResponse(command.id, `State file not found: ${command.oldName}.json`);
|
||||
}
|
||||
|
||||
if (fs.existsSync(newPath)) {
|
||||
return errorResponse(command.id, `Destination already exists: ${command.newName}.json`);
|
||||
}
|
||||
|
||||
fs.renameSync(oldPath, newPath);
|
||||
|
||||
return successResponse(command.id, {
|
||||
renamed: true,
|
||||
oldName: `${command.oldName}.json`,
|
||||
newName: `${command.newName}.json`,
|
||||
path: newPath,
|
||||
});
|
||||
}
|
||||
|
||||
async function handleConsole(command: ConsoleCommand, browser: BrowserManager): Promise<Response> {
|
||||
if (command.clear) {
|
||||
browser.clearConsoleMessages();
|
||||
|
||||
+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 }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+149
-2
@@ -8,6 +8,15 @@ import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
|
||||
import { executeCommand } from './actions.js';
|
||||
import { executeIOSCommand } from './ios-actions.js';
|
||||
import { StreamServer } from './stream-server.js';
|
||||
import {
|
||||
getSessionsDir,
|
||||
ensureSessionsDir,
|
||||
getEncryptionKey,
|
||||
encryptData,
|
||||
isValidSessionName,
|
||||
cleanupExpiredStates,
|
||||
getAutoStateFilePath,
|
||||
} from './state-utils.js';
|
||||
|
||||
// Manager type - either desktop browser or iOS
|
||||
type Manager = BrowserManager | IOSManager;
|
||||
@@ -24,6 +33,99 @@ let streamServer: StreamServer | null = null;
|
||||
// Default stream port (can be overridden with AGENT_BROWSER_STREAM_PORT)
|
||||
const DEFAULT_STREAM_PORT = 9223;
|
||||
|
||||
/**
|
||||
* Save state to file with optional encryption.
|
||||
*/
|
||||
async function saveStateToFile(
|
||||
browser: BrowserManager,
|
||||
filepath: string
|
||||
): Promise<{ encrypted: boolean }> {
|
||||
const context = browser.getContext();
|
||||
if (!context) {
|
||||
throw new Error('No browser context available');
|
||||
}
|
||||
|
||||
const state = await context.storageState();
|
||||
const jsonData = JSON.stringify(state, null, 2);
|
||||
|
||||
const key = getEncryptionKey();
|
||||
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 };
|
||||
}
|
||||
|
||||
const AUTO_EXPIRE_ENV = 'AGENT_BROWSER_STATE_EXPIRE_DAYS';
|
||||
const DEFAULT_EXPIRE_DAYS = 30;
|
||||
|
||||
function runCleanupExpiredStates(): void {
|
||||
const expireDaysStr = process.env[AUTO_EXPIRE_ENV];
|
||||
const expireDays = expireDaysStr ? parseInt(expireDaysStr, 10) : DEFAULT_EXPIRE_DAYS;
|
||||
|
||||
if (isNaN(expireDays) || expireDays <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const deleted = cleanupExpiredStates(expireDays);
|
||||
if (deleted.length > 0 && process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`[DEBUG] Auto-expired ${deleted.length} state file(s) older than ${expireDays} days`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[DEBUG] Failed to clean up expired states:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validated session name and auto-state file path.
|
||||
* Centralizes session name validation to prevent path traversal.
|
||||
*/
|
||||
function getSessionAutoStatePath(): string | undefined {
|
||||
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
|
||||
if (!sessionNameRaw) return undefined;
|
||||
|
||||
if (!isValidSessionName(sessionNameRaw)) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`[SECURITY] Invalid session name rejected: ${sessionNameRaw}`);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
try {
|
||||
const autoStatePath = getAutoStateFilePath(sessionNameRaw, sessionId);
|
||||
return autoStatePath && fs.existsSync(autoStatePath) ? autoStatePath : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the auto-state file path for saving (creates sessions dir if needed).
|
||||
* Returns undefined if no valid session name is configured.
|
||||
*/
|
||||
function getSessionSaveStatePath(): string | undefined {
|
||||
const sessionNameRaw = process.env.AGENT_BROWSER_SESSION_NAME;
|
||||
if (!sessionNameRaw) return undefined;
|
||||
|
||||
if (!isValidSessionName(sessionNameRaw)) return undefined;
|
||||
|
||||
const sessionId = process.env.AGENT_BROWSER_SESSION || 'default';
|
||||
try {
|
||||
return getAutoStateFilePath(sessionNameRaw, sessionId) ?? undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the current session
|
||||
*/
|
||||
@@ -178,15 +280,18 @@ export async function startDaemon(options?: {
|
||||
streamPort?: number;
|
||||
provider?: string;
|
||||
}): Promise<void> {
|
||||
// Ensure socket directory exists
|
||||
// Ensure socket directory exists with restricted permissions (owner-only access)
|
||||
const socketDir = getSocketDir();
|
||||
if (!fs.existsSync(socketDir)) {
|
||||
fs.mkdirSync(socketDir, { recursive: true });
|
||||
fs.mkdirSync(socketDir, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
// Clean up any stale socket
|
||||
cleanupSocket();
|
||||
|
||||
// Clean up expired state files on startup
|
||||
runCleanupExpiredStates();
|
||||
|
||||
// Determine provider from options or environment
|
||||
const provider = options?.provider ?? process.env.AGENT_BROWSER_PROVIDER;
|
||||
const isIOS = provider === 'ios';
|
||||
@@ -325,6 +430,7 @@ export async function startDaemon(options?: {
|
||||
proxy,
|
||||
ignoreHTTPSErrors: ignoreHTTPSErrors,
|
||||
allowFileAccess: allowFileAccess,
|
||||
autoStateFilePath: getSessionAutoStatePath(),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -340,8 +446,40 @@ export async function startDaemon(options?: {
|
||||
await manager.ensurePage();
|
||||
}
|
||||
|
||||
// Handle explicit launch with auto-load state
|
||||
if (
|
||||
parseResult.command.action === 'launch' &&
|
||||
manager instanceof BrowserManager &&
|
||||
!parseResult.command.autoStateFilePath
|
||||
) {
|
||||
const autoStatePath = getSessionAutoStatePath();
|
||||
if (autoStatePath) {
|
||||
parseResult.command.autoStateFilePath = autoStatePath;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle close command specially - shuts down daemon
|
||||
if (parseResult.command.action === 'close') {
|
||||
// Auto-save state before closing
|
||||
if (manager instanceof BrowserManager && manager.isLaunched()) {
|
||||
const savePath = getSessionSaveStatePath();
|
||||
if (savePath) {
|
||||
try {
|
||||
const { encrypted } = await saveStateToFile(manager, savePath);
|
||||
fs.chmodSync(savePath, 0o600);
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(
|
||||
`Auto-saved session state: ${savePath}${encrypted ? ' (encrypted)' : ''}`
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.AGENT_BROWSER_DEBUG === '1') {
|
||||
console.error(`Failed to auto-save session state:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const response =
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
@@ -364,6 +502,15 @@ export async function startDaemon(options?: {
|
||||
isIOS && manager instanceof IOSManager
|
||||
? await executeIOSCommand(parseResult.command, manager)
|
||||
: await executeCommand(parseResult.command, manager as BrowserManager);
|
||||
|
||||
// Add any launch warnings to the response
|
||||
if (manager instanceof BrowserManager) {
|
||||
const warnings = manager.getAndClearWarnings();
|
||||
if (warnings.length > 0 && response.success && response.data) {
|
||||
(response.data as Record<string, unknown>).warnings = warnings;
|
||||
}
|
||||
}
|
||||
|
||||
socket.write(serializeResponse(response) + '\n');
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
||||
@@ -0,0 +1,410 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import * as crypto from 'crypto';
|
||||
import {
|
||||
encryptData,
|
||||
decryptData,
|
||||
getEncryptionKey,
|
||||
isEncryptedPayload,
|
||||
ENCRYPTION_KEY_ENV,
|
||||
IV_LENGTH,
|
||||
type EncryptedPayload,
|
||||
} from './encryption.js';
|
||||
|
||||
// Generate a valid test key (256 bits = 32 bytes = 64 hex chars)
|
||||
const generateTestKey = () => crypto.randomBytes(32);
|
||||
const generateTestKeyHex = () => crypto.randomBytes(32).toString('hex');
|
||||
|
||||
describe('encryption', () => {
|
||||
describe('encryptData / decryptData', () => {
|
||||
it('should round-trip encrypt and decrypt data correctly', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Hello, World! This is a test message.';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with complex JSON data', () => {
|
||||
const key = generateTestKey();
|
||||
const data = {
|
||||
cookies: [{ name: 'session', value: 'abc123', domain: '.example.com' }],
|
||||
localStorage: { theme: 'dark', userId: '12345' },
|
||||
sessionStorage: {},
|
||||
};
|
||||
const plaintext = JSON.stringify(data);
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(JSON.parse(decrypted)).toEqual(data);
|
||||
});
|
||||
|
||||
it('should round-trip with empty string', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = '';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with unicode characters', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = '你好世界 🌍 Привет мир émojis: 🔐🔑';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should round-trip with large data', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'x'.repeat(100000); // 100KB of data
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
const decrypted = decryptData(encrypted, key);
|
||||
|
||||
expect(decrypted).toBe(plaintext);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IV uniqueness', () => {
|
||||
it('should generate different IVs for each encryption', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Same message encrypted twice';
|
||||
|
||||
const encrypted1 = encryptData(plaintext, key);
|
||||
const encrypted2 = encryptData(plaintext, key);
|
||||
|
||||
// IVs should be different
|
||||
expect(encrypted1.iv).not.toBe(encrypted2.iv);
|
||||
|
||||
// Ciphertext should also be different due to different IVs
|
||||
expect(encrypted1.data).not.toBe(encrypted2.data);
|
||||
|
||||
// Both should decrypt to the same plaintext
|
||||
expect(decryptData(encrypted1, key)).toBe(plaintext);
|
||||
expect(decryptData(encrypted2, key)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('should have correct IV length', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const ivBuffer = Buffer.from(encrypted.iv, 'base64');
|
||||
expect(ivBuffer.length).toBe(IV_LENGTH);
|
||||
});
|
||||
});
|
||||
|
||||
describe('authentication (tamper detection)', () => {
|
||||
it('should throw error when auth tag is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the auth tag
|
||||
const tamperedAuthTag = Buffer.from(encrypted.authTag, 'base64');
|
||||
tamperedAuthTag[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: tamperedAuthTag.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when ciphertext is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the ciphertext
|
||||
const tamperedData = Buffer.from(encrypted.data, 'base64');
|
||||
tamperedData[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
data: tamperedData.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when IV is tampered', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Tamper with the IV
|
||||
const tamperedIv = Buffer.from(encrypted.iv, 'base64');
|
||||
tamperedIv[0] ^= 0xff; // Flip bits
|
||||
const tamperedPayload: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: tamperedIv.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(tamperedPayload, key)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('wrong key handling', () => {
|
||||
it('should throw error when decrypting with wrong key', () => {
|
||||
const key1 = generateTestKey();
|
||||
const key2 = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key1);
|
||||
|
||||
// Try to decrypt with a different key
|
||||
expect(() => decryptData(encrypted, key2)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when key is partially wrong', () => {
|
||||
const key = generateTestKey();
|
||||
const plaintext = 'Sensitive data';
|
||||
|
||||
const encrypted = encryptData(plaintext, key);
|
||||
|
||||
// Create a key with one byte different
|
||||
const wrongKey = Buffer.from(key);
|
||||
wrongKey[0] ^= 0xff;
|
||||
|
||||
expect(() => decryptData(encrypted, wrongKey)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('malformed payload detection', () => {
|
||||
it('should throw error for empty IV', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: '',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for empty auth tag', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: '',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for invalid base64 in IV', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
iv: '!!!not-valid-base64!!!',
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for truncated auth tag', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
// Truncate auth tag to just 4 bytes (minimum allowed, but wrong value)
|
||||
// This won't match the actual tag, so authentication will fail
|
||||
const truncatedTag = crypto.randomBytes(4); // Random 4 bytes won't match
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: truncatedTag.toString('base64'),
|
||||
};
|
||||
|
||||
// Note: With Node.js deprecation warning, very short tags may still be
|
||||
// accepted but will fail authentication during decipher.final()
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
|
||||
it('should throw error for completely wrong auth tag length', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
// Use a completely wrong auth tag (right length but wrong value)
|
||||
const wrongTag = crypto.randomBytes(16); // Same length as real tag
|
||||
const malformed: EncryptedPayload = {
|
||||
...encrypted,
|
||||
authTag: wrongTag.toString('base64'),
|
||||
};
|
||||
|
||||
expect(() => decryptData(malformed, key)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEncryptionKey', () => {
|
||||
const originalEnv = process.env[ENCRYPTION_KEY_ENV];
|
||||
|
||||
afterEach(() => {
|
||||
// Restore original env
|
||||
if (originalEnv !== undefined) {
|
||||
process.env[ENCRYPTION_KEY_ENV] = originalEnv;
|
||||
} else {
|
||||
delete process.env[ENCRYPTION_KEY_ENV];
|
||||
}
|
||||
});
|
||||
|
||||
it('should return null when env var is not set', () => {
|
||||
delete process.env[ENCRYPTION_KEY_ENV];
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty string', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = '';
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid hex (too short)', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'abc123'; // Only 6 chars, need 64
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for invalid hex (too long)', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'a'.repeat(128); // 128 chars, need 64
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for non-hex characters', () => {
|
||||
process.env[ENCRYPTION_KEY_ENV] = 'g'.repeat(64); // 'g' is not hex
|
||||
expect(getEncryptionKey()).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid key buffer for correct hex string', () => {
|
||||
const keyHex = generateTestKeyHex();
|
||||
process.env[ENCRYPTION_KEY_ENV] = keyHex;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key).toBeInstanceOf(Buffer);
|
||||
expect(key!.length).toBe(32); // 256 bits
|
||||
expect(key!.toString('hex')).toBe(keyHex.toLowerCase());
|
||||
});
|
||||
|
||||
it('should accept uppercase hex', () => {
|
||||
const keyHex = generateTestKeyHex().toUpperCase();
|
||||
process.env[ENCRYPTION_KEY_ENV] = keyHex;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key!.length).toBe(32);
|
||||
});
|
||||
|
||||
it('should accept mixed case hex', () => {
|
||||
const keyHex = generateTestKeyHex();
|
||||
const mixedCase = keyHex
|
||||
.split('')
|
||||
.map((c, i) => (i % 2 === 0 ? c.toUpperCase() : c.toLowerCase()))
|
||||
.join('');
|
||||
process.env[ENCRYPTION_KEY_ENV] = mixedCase;
|
||||
|
||||
const key = getEncryptionKey();
|
||||
expect(key).not.toBeNull();
|
||||
expect(key!.length).toBe(32);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEncryptedPayload', () => {
|
||||
it('should return true for valid encrypted payload', () => {
|
||||
const key = generateTestKey();
|
||||
const encrypted = encryptData('test', key);
|
||||
|
||||
expect(isEncryptedPayload(encrypted)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for null', () => {
|
||||
expect(isEncryptedPayload(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for undefined', () => {
|
||||
expect(isEncryptedPayload(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for plain object without encrypted flag', () => {
|
||||
expect(isEncryptedPayload({ data: 'test' })).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object with encrypted: false', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: false,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing version', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing iv', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
authTag: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing authTag', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
data: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for object missing data', () => {
|
||||
expect(
|
||||
isEncryptedPayload({
|
||||
encrypted: true,
|
||||
version: 1,
|
||||
iv: 'test',
|
||||
authTag: 'test',
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for array', () => {
|
||||
expect(isEncryptedPayload([])).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for string', () => {
|
||||
expect(isEncryptedPayload('encrypted')).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for number', () => {
|
||||
expect(isEncryptedPayload(42)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,7 @@ const clickSchema = baseCommandSchema.extend({
|
||||
button: z.enum(['left', 'right', 'middle']).optional(),
|
||||
clickCount: z.number().positive().optional(),
|
||||
delay: z.number().nonnegative().optional(),
|
||||
newTab: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const typeSchema = baseCommandSchema.extend({
|
||||
@@ -390,6 +391,32 @@ const stateLoadSchema = baseCommandSchema.extend({
|
||||
path: z.string().min(1),
|
||||
});
|
||||
|
||||
const stateListSchema = baseCommandSchema.extend({
|
||||
action: z.literal('state_list'),
|
||||
});
|
||||
|
||||
const stateClearSchema = baseCommandSchema.extend({
|
||||
action: z.literal('state_clear'),
|
||||
sessionName: z.string().optional(),
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
|
||||
const stateShowSchema = baseCommandSchema.extend({
|
||||
action: z.literal('state_show'),
|
||||
filename: z.string().min(1),
|
||||
});
|
||||
|
||||
const stateCleanSchema = baseCommandSchema.extend({
|
||||
action: z.literal('state_clean'),
|
||||
days: z.number().int().positive(),
|
||||
});
|
||||
|
||||
const stateRenameSchema = baseCommandSchema.extend({
|
||||
action: z.literal('state_rename'),
|
||||
oldName: z.string().min(1),
|
||||
newName: z.string().min(1),
|
||||
});
|
||||
|
||||
const consoleSchema = baseCommandSchema.extend({
|
||||
action: z.literal('console'),
|
||||
clear: z.boolean().optional(),
|
||||
@@ -872,6 +899,11 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
harStopSchema,
|
||||
stateSaveSchema,
|
||||
stateLoadSchema,
|
||||
stateListSchema,
|
||||
stateClearSchema,
|
||||
stateShowSchema,
|
||||
stateCleanSchema,
|
||||
stateRenameSchema,
|
||||
consoleSchema,
|
||||
errorsSchema,
|
||||
keyboardSchema,
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
|
||||
let tempHome: string;
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('os')>();
|
||||
return {
|
||||
...actual,
|
||||
homedir: () => tempHome,
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
getAutoStateFilePath,
|
||||
isValidSessionName,
|
||||
getSessionsDir,
|
||||
safeHeaderMerge,
|
||||
listStateFiles,
|
||||
cleanupExpiredStates,
|
||||
} from './state-utils.js';
|
||||
|
||||
describe('state-utils', () => {
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-browser-test-'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('isValidSessionName', () => {
|
||||
it('should accept alphanumeric names', () => {
|
||||
expect(isValidSessionName('twitter')).toBe(true);
|
||||
expect(isValidSessionName('Twitter123')).toBe(true);
|
||||
expect(isValidSessionName('123')).toBe(true);
|
||||
expect(isValidSessionName('ABC')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept names with hyphens', () => {
|
||||
expect(isValidSessionName('my-session')).toBe(true);
|
||||
expect(isValidSessionName('twitter-prod')).toBe(true);
|
||||
expect(isValidSessionName('a-b-c-d')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept names with underscores', () => {
|
||||
expect(isValidSessionName('my_session')).toBe(true);
|
||||
expect(isValidSessionName('twitter_prod')).toBe(true);
|
||||
expect(isValidSessionName('a_b_c_d')).toBe(true);
|
||||
});
|
||||
|
||||
it('should accept mixed valid characters', () => {
|
||||
expect(isValidSessionName('my-session_123')).toBe(true);
|
||||
expect(isValidSessionName('Twitter_Prod-v2')).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject empty string', () => {
|
||||
expect(isValidSessionName('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject path traversal attempts', () => {
|
||||
expect(isValidSessionName('../../../etc/passwd')).toBe(false);
|
||||
expect(isValidSessionName('..\\..\\windows\\system32')).toBe(false);
|
||||
expect(isValidSessionName('../parent')).toBe(false);
|
||||
expect(isValidSessionName('./current')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with slashes', () => {
|
||||
expect(isValidSessionName('path/to/file')).toBe(false);
|
||||
expect(isValidSessionName('path\\to\\file')).toBe(false);
|
||||
expect(isValidSessionName('/absolute/path')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with spaces', () => {
|
||||
expect(isValidSessionName('my session')).toBe(false);
|
||||
expect(isValidSessionName(' leading')).toBe(false);
|
||||
expect(isValidSessionName('trailing ')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with special characters', () => {
|
||||
expect(isValidSessionName('session@user')).toBe(false);
|
||||
expect(isValidSessionName('session#1')).toBe(false);
|
||||
expect(isValidSessionName('session$var')).toBe(false);
|
||||
expect(isValidSessionName('session%20')).toBe(false);
|
||||
expect(isValidSessionName('session:name')).toBe(false);
|
||||
expect(isValidSessionName('session;drop')).toBe(false);
|
||||
expect(isValidSessionName("session'sql")).toBe(false);
|
||||
expect(isValidSessionName('session"quote')).toBe(false);
|
||||
expect(isValidSessionName('session<script>')).toBe(false);
|
||||
expect(isValidSessionName('session|pipe')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with null bytes', () => {
|
||||
expect(isValidSessionName('session\x00name')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject names with newlines', () => {
|
||||
expect(isValidSessionName('session\nname')).toBe(false);
|
||||
expect(isValidSessionName('session\rname')).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject Unicode tricks', () => {
|
||||
// Homograph attacks
|
||||
expect(isValidSessionName('sеssion')).toBe(false); // Cyrillic 'е'
|
||||
expect(isValidSessionName('session\u2024')).toBe(false); // One dot leader
|
||||
expect(isValidSessionName('session\u2025')).toBe(false); // Two dot leader
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAutoStateFilePath', () => {
|
||||
it('should return null for empty session name', () => {
|
||||
expect(getAutoStateFilePath('', 'default')).toBeNull();
|
||||
});
|
||||
|
||||
it('should return valid path for valid inputs', () => {
|
||||
const result = getAutoStateFilePath('twitter', 'default');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('twitter-default.json');
|
||||
expect(result).toContain('.agent-browser');
|
||||
expect(result).toContain('sessions');
|
||||
});
|
||||
|
||||
it('should throw error for path traversal in session name', () => {
|
||||
expect(() => getAutoStateFilePath('../etc/passwd', 'default')).toThrow(
|
||||
/Invalid session name/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for path traversal in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', '../../../etc/passwd')).toThrow(
|
||||
/Invalid session ID/
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error for slashes in session name', () => {
|
||||
expect(() => getAutoStateFilePath('path/to/file', 'default')).toThrow(/Invalid session name/);
|
||||
});
|
||||
|
||||
it('should throw error for slashes in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', 'path/to/file')).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it('should throw error for special characters in session name', () => {
|
||||
expect(() => getAutoStateFilePath('session@evil', 'default')).toThrow(/Invalid session name/);
|
||||
});
|
||||
|
||||
it('should throw error for special characters in session ID', () => {
|
||||
expect(() => getAutoStateFilePath('twitter', 'id@evil')).toThrow(/Invalid session ID/);
|
||||
});
|
||||
|
||||
it('should accept valid session name with hyphens and underscores', () => {
|
||||
const result = getAutoStateFilePath('my-session_v2', 'agent_1');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result).toContain('my-session_v2-agent_1.json');
|
||||
});
|
||||
|
||||
// Security: Ensure the resulting path is within the sessions directory
|
||||
it('should always produce path within sessions directory', () => {
|
||||
const sessionsDir = getSessionsDir();
|
||||
const result = getAutoStateFilePath('twitter', 'default');
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.startsWith(sessionsDir)).toBe(true);
|
||||
|
||||
// Verify the path is actually within the directory (no traversal)
|
||||
const resolvedPath = path.resolve(result!);
|
||||
const resolvedSessionsDir = path.resolve(sessionsDir);
|
||||
expect(resolvedPath.startsWith(resolvedSessionsDir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safeHeaderMerge', () => {
|
||||
it('should merge two header objects', () => {
|
||||
const base = { 'Content-Type': 'application/json', Accept: 'text/html' };
|
||||
const override = { Authorization: 'Bearer token' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect(result['Authorization']).toBe('Bearer token');
|
||||
});
|
||||
|
||||
it('should allow override to replace base values', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = { 'Content-Type': 'application/json' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('application/json');
|
||||
});
|
||||
|
||||
it('should filter out __proto__ from base', () => {
|
||||
const base = { 'Content-Type': 'text/plain', __proto__: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('text/plain');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('__proto__' in result).toBe(false);
|
||||
expect(Object.prototype.hasOwnProperty.call(result, '__proto__')).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out __proto__ from override', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = { Accept: 'text/html', __proto__: 'evil' } as Record<string, string>;
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Content-Type']).toBe('text/plain');
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('__proto__' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out constructor key', () => {
|
||||
const base = { constructor: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('constructor' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should filter out prototype key', () => {
|
||||
const base = { prototype: 'evil' } as Record<string, string>;
|
||||
const override = { Accept: 'text/html' };
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(result['Accept']).toBe('text/html');
|
||||
expect('prototype' in result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return null-prototype object', () => {
|
||||
const base = { 'Content-Type': 'text/plain' };
|
||||
const override = {};
|
||||
|
||||
const result = safeHeaderMerge(base, override);
|
||||
|
||||
expect(Object.getPrototypeOf(result)).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle empty objects', () => {
|
||||
const result = safeHeaderMerge({}, {});
|
||||
expect(Object.keys(result)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listStateFiles', () => {
|
||||
it('should return empty array when directory does not exist', () => {
|
||||
const result = listStateFiles();
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanupExpiredStates', () => {
|
||||
it('should return empty array for 0 days', () => {
|
||||
const result = cleanupExpiredStates(0);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return empty array for negative days', () => {
|
||||
const result = cleanupExpiredStates(-5);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
@@ -114,8 +114,12 @@ export class StreamServer {
|
||||
start(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// SECURITY: Bind to localhost only to prevent network exposure.
|
||||
// The stream server allows direct input injection (mouse, keyboard, touch)
|
||||
// which would be a critical security risk if exposed to the network.
|
||||
this.wss = new WebSocketServer({
|
||||
port: this.port,
|
||||
host: '127.0.0.1',
|
||||
// Security: Reject cross-origin WebSocket connections from untrusted origins.
|
||||
// This prevents malicious web pages from connecting and injecting input events.
|
||||
// Localhost origins are allowed so browser-based stream viewers can connect.
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface LaunchCommand extends BaseCommand {
|
||||
provider?: string;
|
||||
ignoreHTTPSErrors?: boolean;
|
||||
allowFileAccess?: boolean; // Enable file:// URL access and cross-origin file requests
|
||||
// Auto-load state file for session persistence
|
||||
autoStateFilePath?: string;
|
||||
}
|
||||
|
||||
export interface NavigateCommand extends BaseCommand {
|
||||
@@ -46,6 +48,7 @@ export interface ClickCommand extends BaseCommand {
|
||||
button?: 'left' | 'right' | 'middle';
|
||||
clickCount?: number;
|
||||
delay?: number;
|
||||
newTab?: boolean;
|
||||
}
|
||||
|
||||
export interface TypeCommand extends BaseCommand {
|
||||
@@ -597,6 +600,33 @@ export interface StorageStateLoadCommand extends BaseCommand {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// State management commands (v2)
|
||||
export interface StateListCommand extends BaseCommand {
|
||||
action: 'state_list';
|
||||
}
|
||||
|
||||
export interface StateClearCommand extends BaseCommand {
|
||||
action: 'state_clear';
|
||||
sessionName?: string;
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
export interface StateShowCommand extends BaseCommand {
|
||||
action: 'state_show';
|
||||
filename: string;
|
||||
}
|
||||
|
||||
export interface StateCleanCommand extends BaseCommand {
|
||||
action: 'state_clean';
|
||||
days: number;
|
||||
}
|
||||
|
||||
export interface StateRenameCommand extends BaseCommand {
|
||||
action: 'state_rename';
|
||||
oldName: string;
|
||||
newName: string;
|
||||
}
|
||||
|
||||
// Console logs
|
||||
export interface ConsoleCommand extends BaseCommand {
|
||||
action: 'console';
|
||||
@@ -898,6 +928,11 @@ export type Command =
|
||||
| HarStopCommand
|
||||
| StorageStateSaveCommand
|
||||
| StorageStateLoadCommand
|
||||
| StateListCommand
|
||||
| StateClearCommand
|
||||
| StateShowCommand
|
||||
| StateCleanCommand
|
||||
| StateRenameCommand
|
||||
| ConsoleCommand
|
||||
| ErrorsCommand
|
||||
| KeyboardCommand
|
||||
|
||||
Reference in New Issue
Block a user