From 80ef938fb3d6d2ca7d820870d7feb4925a4f5240 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 26 Mar 2026 20:13:55 +0000 Subject: [PATCH 01/20] fix: add doctor isolation readiness checks --- src/cli/commands/doctor.ts | 160 ++++++++++-- test/unit/commands/doctor.test.ts | 173 ++++++++++++- test/unit/commands/golden-envelopes.test.ts | 267 +++++++++++++++++++- 3 files changed, 572 insertions(+), 28 deletions(-) diff --git a/src/cli/commands/doctor.ts b/src/cli/commands/doctor.ts index 6e52ce3..fe739f8 100644 --- a/src/cli/commands/doctor.ts +++ b/src/cli/commands/doctor.ts @@ -5,6 +5,7 @@ import { mkdir, mkdtemp, readFile, + readdir, rename, rm, stat, @@ -17,8 +18,8 @@ import { type Server, type Socket, } from 'node:net'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { homedir, tmpdir } from 'node:os'; +import { join, normalize } from 'node:path'; import process from 'node:process'; import type { CommandContext } from '../context.js'; @@ -32,7 +33,7 @@ import { artifactPath, ensureArtifactsDir, } from '../../storage/artifactPaths.js'; -import { ensureHome } from '../../storage/home.js'; +import { ensureHome, resolveHome } from '../../storage/home.js'; import { eventLogPath, sessionDir, @@ -52,12 +53,14 @@ const DOCTOR_CHECK_LABELS: Readonly> = Object.freeze({ 'node-runtime': 'node', 'cwd-access': 'cwd', 'temp-dir': 'temp', + home_isolation: 'home-isolation', 'home-writable': 'home-write', 'pty-spawn': 'pty', 'socket-viable': 'socket', 'artifact-atomicity': 'artifacts', 'event-log-writable': 'event-log', playwright_available: 'playwright', + browser_cache_accessible: 'browser-cache', browser_launch: 'browser', ghostty_web_available: 'ghostty-web', screenshot_viable: 'screenshot', @@ -67,7 +70,16 @@ let doctorResourceSequence = 0; type DoctorCheckGroupName = 'environment' | 'renderer'; type DoctorCheckStatus = 'pass' | 'fail' | 'skip'; -type DoctorCheckOperation = () => Promise | string; + +interface DoctorCheckSkipResult { + status: 'skip'; + message: string; +} + +type DoctorCheckOutcome = string | DoctorCheckSkipResult; +type DoctorCheckOperation = ( + priorChecks: ReadonlyArray, +) => Promise | DoctorCheckOutcome; type DoctorCheckDefinition = readonly [ name: string, operation: DoctorCheckOperation, @@ -171,6 +183,43 @@ function getCheckDurationMs(startedAtMs: number): number { return Math.max(0, Date.now() - startedAtMs); } +function skipDoctorCheck(message: string): DoctorCheckSkipResult { + assert(message.length > 0, 'doctor check skip message must be non-empty'); + return { + status: 'skip', + message, + }; +} + +function findDoctorCheck( + checks: ReadonlyArray, + name: string, +): DoctorCheck | undefined { + return checks.find((check) => check.name === name); +} + +function resolveSystemHomeDirectory(): string { + const configuredHome = process.env.HOME ?? homedir(); + assert( + configuredHome.length > 0, + 'system home directory must be a non-empty path', + ); + return normalize(configuredHome); +} + +function resolvePlaywrightBrowserCachePath(): string { + const overridePath = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (overridePath !== undefined) { + assert( + overridePath.length > 0, + 'PLAYWRIGHT_BROWSERS_PATH must be a non-empty path when set', + ); + return normalize(overridePath); + } + + return join(resolveSystemHomeDirectory(), '.cache', 'ms-playwright'); +} + function getDoctorDependencies( overrides: Partial, ): DoctorDependencies { @@ -247,24 +296,27 @@ export async function runDoctorCheck( name: string, operation: DoctorCheckOperation, timeoutMs = CHECK_TIMEOUT_MS, + priorChecks: ReadonlyArray = [], ): Promise { assert(name.length > 0, 'doctor check name must be a non-empty string'); const startedAtMs = Date.now(); try { - const message = await withTimeout( - Promise.resolve(operation()), + const outcome = await withTimeout( + Promise.resolve(operation(priorChecks)), timeoutMs, `${name} timed out after ${String(timeoutMs)}ms`, ); + const status = typeof outcome === 'string' ? 'pass' : outcome.status; + const message = typeof outcome === 'string' ? outcome : outcome.message; assert( message.length > 0, - 'doctor check success message must be non-empty', + `doctor check ${status} message must be non-empty`, ); return { name, - status: 'pass', + status, message, durationMs: getCheckDurationMs(startedAtMs), }; @@ -289,7 +341,7 @@ async function runCheckGroup( ): Promise { const results: DoctorCheck[] = []; for (const [name, operation, timeoutMs] of checks) { - results.push(await runDoctorCheck(name, operation, timeoutMs)); + results.push(await runDoctorCheck(name, operation, timeoutMs, results)); } return results; @@ -320,6 +372,21 @@ async function runTemporaryDirectoryCheck(): Promise { return `temp dir ok: ${tmpdir()}`; } +export function runHomeIsolationCheck(): string { + const configuredHome = process.env.AGENT_TERMINAL_HOME; + if (configuredHome === undefined) { + return 'Agent-terminal home uses default location'; + } + + const resolvedDoctorHome = resolveHome(configuredHome); + const systemHome = resolveSystemHomeDirectory(); + if (resolvedDoctorHome !== systemHome) { + return `Agent-terminal home is isolated from system home: ${resolvedDoctorHome}`; + } + + return 'Agent-terminal home uses default location'; +} + export async function runHomeWritableCheck( overrides: Partial = {}, ): Promise { @@ -606,6 +673,44 @@ async function runPlaywrightAvailableCheck(): Promise { return 'available'; } +const PLAYWRIGHT_BROWSER_DIRECTORY_PATTERN = + /^(?:chromium(?:_headless_shell)?|firefox|webkit|msedge)-/; + +export async function runBrowserCacheAccessibleCheck( + priorChecks: ReadonlyArray = [], +): Promise { + const playwrightCheck = findDoctorCheck(priorChecks, 'playwright_available'); + if (playwrightCheck?.status === 'fail') { + return skipDoctorCheck( + 'playwright unavailable; browser cache check not attempted', + ); + } + + const browserCachePath = resolvePlaywrightBrowserCachePath(); + try { + await access(browserCachePath, fsConstants.R_OK | fsConstants.X_OK); + const cacheEntries = await readdir(browserCachePath, { + withFileTypes: true, + }); + const browserDirectory = cacheEntries.find( + (entry) => + entry.isDirectory() && + PLAYWRIGHT_BROWSER_DIRECTORY_PATTERN.test(entry.name), + ); + assert( + browserDirectory !== undefined, + `Playwright browser cache not found at ${browserCachePath}. Run 'npx playwright install chromium' to install.`, + ); + } catch (error) { + throw new Error( + `Playwright browser cache not found at ${browserCachePath}. Run 'npx playwright install chromium' to install.`, + { cause: error }, + ); + } + + return `browser cache accessible: ${browserCachePath}`; +} + async function runBrowserLaunchCheck(): Promise { const chromium = await getPlaywrightChromium(); const browser = await chromium.launch({ @@ -689,20 +794,33 @@ async function runScreenshotViabilityCheck(): Promise { export async function runDoctorChecks(): Promise { const environment = await runCheckGroup([ - ['node-runtime', runNodeRuntimeCheck], - ['cwd-access', runWorkingDirectoryCheck], - ['temp-dir', runTemporaryDirectoryCheck], - ['home-writable', runHomeWritableCheck, QUICK_CHECK_TIMEOUT_MS], - ['pty-spawn', runPtySpawnCheck, QUICK_CHECK_TIMEOUT_MS], - ['socket-viable', runSocketViabilityCheck, QUICK_CHECK_TIMEOUT_MS], - ['artifact-atomicity', runArtifactAtomicityCheck, QUICK_CHECK_TIMEOUT_MS], - ['event-log-writable', runEventLogWritabilityCheck, QUICK_CHECK_TIMEOUT_MS], + ['node-runtime', () => runNodeRuntimeCheck()], + ['cwd-access', () => runWorkingDirectoryCheck()], + ['temp-dir', () => runTemporaryDirectoryCheck()], + ['home_isolation', () => runHomeIsolationCheck(), QUICK_CHECK_TIMEOUT_MS], + ['home-writable', () => runHomeWritableCheck(), QUICK_CHECK_TIMEOUT_MS], + ['pty-spawn', () => runPtySpawnCheck(), QUICK_CHECK_TIMEOUT_MS], + ['socket-viable', () => runSocketViabilityCheck(), QUICK_CHECK_TIMEOUT_MS], + [ + 'artifact-atomicity', + () => runArtifactAtomicityCheck(), + QUICK_CHECK_TIMEOUT_MS, + ], + [ + 'event-log-writable', + () => runEventLogWritabilityCheck(), + QUICK_CHECK_TIMEOUT_MS, + ], ]); const renderer = await runCheckGroup([ - ['playwright_available', runPlaywrightAvailableCheck], - ['browser_launch', runBrowserLaunchCheck], - ['ghostty_web_available', runGhosttyWebAvailableCheck], - ['screenshot_viable', runScreenshotViabilityCheck], + ['playwright_available', () => runPlaywrightAvailableCheck()], + [ + 'browser_cache_accessible', + (priorChecks) => runBrowserCacheAccessibleCheck(priorChecks), + ], + ['browser_launch', () => runBrowserLaunchCheck()], + ['ghostty_web_available', () => runGhosttyWebAvailableCheck()], + ['screenshot_viable', () => runScreenshotViabilityCheck()], ]); const allChecks = [...environment, ...renderer]; const uniqueCheckNames = new Set(allChecks.map((check) => check.name)); diff --git a/test/unit/commands/doctor.test.ts b/test/unit/commands/doctor.test.ts index 3158e4c..b6e235c 100644 --- a/test/unit/commands/doctor.test.ts +++ b/test/unit/commands/doctor.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { mkdir, mkdtemp, realpath, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -7,9 +7,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { buildDoctorLines, runArtifactAtomicityCheck, + runBrowserCacheAccessibleCheck, runDoctorCheck, runDoctorChecks, runEventLogWritabilityCheck, + runHomeIsolationCheck, runHomeWritableCheck, runPtySpawnCheck, runSocketViabilityCheck, @@ -19,6 +21,7 @@ import { const QUICK_TIMEOUT_MS = 5_000; const NEW_DOCTOR_CHECKS = [ + { name: 'home_isolation', run: () => runHomeIsolationCheck() }, { name: 'home-writable', run: () => runHomeWritableCheck() }, { name: 'pty-spawn', run: () => runPtySpawnCheck() }, { name: 'socket-viable', run: () => runSocketViabilityCheck() }, @@ -89,9 +92,13 @@ const BROKEN_DOCTOR_CHECKS = [ ] as const; let testHome = ''; +let originalHome: string | undefined; +let originalPlaywrightBrowsersPath: string | undefined; describe('doctor command', () => { beforeEach(async () => { + originalHome = process.env.HOME; + originalPlaywrightBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; // prettier-ignore testHome = await realpath(await mkdtemp(join(tmpdir(), 'agent-terminal-doctor-home-'))); process.env.AGENT_TERMINAL_HOME = testHome; @@ -99,6 +106,16 @@ describe('doctor command', () => { afterEach(async () => { delete process.env.AGENT_TERMINAL_HOME; + if (originalHome === undefined) { + delete process.env.HOME; + } else { + process.env.HOME = originalHome; + } + if (originalPlaywrightBrowsersPath === undefined) { + delete process.env.PLAYWRIGHT_BROWSERS_PATH; + } else { + process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath; + } await rm(testHome, { recursive: true, force: true }); testHome = ''; }); @@ -109,8 +126,8 @@ describe('doctor command', () => { const checkNames = allChecks.map((check) => check.name); expect(result.ok).toBe(true); - expect(result.checks.environment.length).toBeGreaterThan(0); - expect(result.checks.renderer.length).toBeGreaterThan(0); + expect(result.checks.environment).toHaveLength(9); + expect(result.checks.renderer).toHaveLength(5); expect(result.capabilities).toHaveLength(5); expect(result.capabilities.map((capability) => capability.name)).toEqual([ 'snapshot', @@ -136,11 +153,13 @@ describe('doctor command', () => { }); expect(checkNames).toEqual( expect.arrayContaining([ + 'home_isolation', 'home-writable', 'pty-spawn', 'socket-viable', 'artifact-atomicity', 'event-log-writable', + 'browser_cache_accessible', ]), ); expect(new Set(checkNames).size).toBe(checkNames.length); @@ -163,6 +182,154 @@ describe('doctor command', () => { }, ); + it('reports the default location when AGENT_TERMINAL_HOME is not explicitly set', () => { + delete process.env.AGENT_TERMINAL_HOME; + + expect(runHomeIsolationCheck()).toBe( + 'Agent-terminal home uses default location', + ); + }); + + it('reports the default location when AGENT_TERMINAL_HOME matches HOME', () => { + process.env.HOME = testHome; + process.env.AGENT_TERMINAL_HOME = testHome; + + expect(runHomeIsolationCheck()).toBe( + 'Agent-terminal home uses default location', + ); + }); + + it('reports an isolated agent-terminal home when AGENT_TERMINAL_HOME differs from HOME', async () => { + const systemHome = await realpath( + await mkdtemp(join(tmpdir(), 'agent-terminal-system-home-')), + ); + process.env.HOME = systemHome; + process.env.AGENT_TERMINAL_HOME = testHome; + + try { + expect(runHomeIsolationCheck()).toBe( + `Agent-terminal home is isolated from system home: ${testHome}`, + ); + } finally { + await rm(systemHome, { recursive: true, force: true }); + } + }); + + it('passes browser_cache_accessible when PLAYWRIGHT_BROWSERS_PATH contains browser directories', async () => { + const browserCachePath = await realpath( + await mkdtemp(join(tmpdir(), 'agent-terminal-browser-cache-')), + ); + process.env.PLAYWRIGHT_BROWSERS_PATH = browserCachePath; + await mkdir(join(browserCachePath, 'chromium-1234')); + + try { + const result = await runDoctorCheck( + 'browser_cache_accessible', + (priorChecks) => runBrowserCacheAccessibleCheck(priorChecks), + QUICK_TIMEOUT_MS, + [ + { + name: 'playwright_available', + status: 'pass', + message: 'available', + durationMs: 1, + }, + ], + ); + + expect(result).toMatchObject({ + name: 'browser_cache_accessible', + status: 'pass', + message: `browser cache accessible: ${browserCachePath}`, + }); + } finally { + await rm(browserCachePath, { recursive: true, force: true }); + } + }); + + it('fails browser_cache_accessible with an actionable message when the cache is missing', async () => { + const missingCachePath = join(testHome, 'missing-browser-cache'); + process.env.PLAYWRIGHT_BROWSERS_PATH = missingCachePath; + + const result = await runDoctorCheck( + 'browser_cache_accessible', + (priorChecks) => runBrowserCacheAccessibleCheck(priorChecks), + QUICK_TIMEOUT_MS, + [ + { + name: 'playwright_available', + status: 'pass', + message: 'available', + durationMs: 1, + }, + ], + ); + + expect(result).toMatchObject({ + name: 'browser_cache_accessible', + status: 'fail', + message: + `Playwright browser cache not found at ${missingCachePath}. ` + + "Run 'npx playwright install chromium' to install.", + }); + }); + + it('uses the HOME-based default Playwright cache path when no override is set', async () => { + const systemHome = await realpath( + await mkdtemp(join(tmpdir(), 'agent-terminal-system-home-')), + ); + const browserCachePath = join(systemHome, '.cache', 'ms-playwright'); + delete process.env.PLAYWRIGHT_BROWSERS_PATH; + process.env.HOME = systemHome; + await mkdir(join(browserCachePath, 'chromium-1234'), { recursive: true }); + + try { + const result = await runDoctorCheck( + 'browser_cache_accessible', + (priorChecks) => runBrowserCacheAccessibleCheck(priorChecks), + QUICK_TIMEOUT_MS, + [ + { + name: 'playwright_available', + status: 'pass', + message: 'available', + durationMs: 1, + }, + ], + ); + + expect(result).toMatchObject({ + name: 'browser_cache_accessible', + status: 'pass', + message: `browser cache accessible: ${browserCachePath}`, + }); + } finally { + await rm(systemHome, { recursive: true, force: true }); + } + }); + + it('skips browser_cache_accessible when playwright is unavailable', async () => { + const result = await runDoctorCheck( + 'browser_cache_accessible', + (priorChecks) => runBrowserCacheAccessibleCheck(priorChecks), + QUICK_TIMEOUT_MS, + [ + { + name: 'playwright_available', + status: 'fail', + message: 'playwright missing', + durationMs: 1, + }, + ], + ); + + expect(result).toMatchObject({ + name: 'browser_cache_accessible', + status: 'skip', + message: 'playwright unavailable; browser cache check not attempted', + }); + }); + it('kills the PTY when the outer doctor timeout expires', async () => { let onExitHandler: | ((event: { exitCode: number; signal?: number }) => void) diff --git a/test/unit/commands/golden-envelopes.test.ts b/test/unit/commands/golden-envelopes.test.ts index 67a48ca..904fa82 100644 --- a/test/unit/commands/golden-envelopes.test.ts +++ b/test/unit/commands/golden-envelopes.test.ts @@ -82,6 +82,24 @@ const ListResultSchema = z .strict(); const DoctorCheckStatusSchema = z.enum(['pass', 'fail', 'skip']); +const EnvironmentDoctorCheckNameSchema = z.enum([ + 'node-runtime', + 'cwd-access', + 'temp-dir', + 'home_isolation', + 'home-writable', + 'pty-spawn', + 'socket-viable', + 'artifact-atomicity', + 'event-log-writable', +]); +const RendererDoctorCheckNameSchema = z.enum([ + 'playwright_available', + 'browser_cache_accessible', + 'browser_launch', + 'ghostty_web_available', + 'screenshot_viable', +]); const DoctorCheckSchema = z .object({ @@ -92,13 +110,24 @@ const DoctorCheckSchema = z }) .strict(); +const EnvironmentDoctorCheckSchema = DoctorCheckSchema.extend({ + name: EnvironmentDoctorCheckNameSchema, +}); +const RendererDoctorCheckSchema = DoctorCheckSchema.extend({ + name: RendererDoctorCheckNameSchema, +}); + const DoctorResultSchema = z .object({ ok: z.boolean(), checks: z .object({ - environment: z.array(DoctorCheckSchema), - renderer: z.array(DoctorCheckSchema), + environment: z + .array(EnvironmentDoctorCheckSchema) + .length(EnvironmentDoctorCheckNameSchema.options.length), + renderer: z + .array(RendererDoctorCheckSchema) + .length(RendererDoctorCheckNameSchema.options.length), }) .strict(), capabilities: z.array(CapabilityEntrySchema), @@ -237,6 +266,54 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ message: 'ok', durationMs: 1, }, + { + name: 'cwd-access', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'temp-dir', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home_isolation', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'pty-spawn', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'socket-viable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'artifact-atomicity', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'event-log-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, ], renderer: [ { @@ -245,6 +322,30 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ message: 'ok', durationMs: 2, }, + { + name: 'browser_cache_accessible', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'browser_launch', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'ghostty_web_available', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'screenshot_viable', + status: 'pass', + message: 'ok', + durationMs: 2, + }, ], }, capabilities: [ @@ -264,8 +365,87 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ message: 'ok', durationMs: -1, }, + { + name: 'cwd-access', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'temp-dir', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home_isolation', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'pty-spawn', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'socket-viable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'artifact-atomicity', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'event-log-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + ], + renderer: [ + { + name: 'playwright_available', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'browser_cache_accessible', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'browser_launch', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'ghostty_web_available', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'screenshot_viable', + status: 'pass', + message: 'ok', + durationMs: 2, + }, ], - renderer: [], }, capabilities: [], }, @@ -280,8 +460,87 @@ const goldenResultContracts: readonly GoldenResultContractCase[] = [ durationMs: 1, ok: true, }, + { + name: 'cwd-access', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'temp-dir', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home_isolation', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'home-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'pty-spawn', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'socket-viable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'artifact-atomicity', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + { + name: 'event-log-writable', + status: 'pass', + message: 'ok', + durationMs: 1, + }, + ], + renderer: [ + { + name: 'playwright_available', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'browser_cache_accessible', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'browser_launch', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'ghostty_web_available', + status: 'pass', + message: 'ok', + durationMs: 2, + }, + { + name: 'screenshot_viable', + status: 'pass', + message: 'ok', + durationMs: 2, + }, ], - renderer: [], }, capabilities: [], }, From 611c074202053b54706a7d6be19e1cb9eb45615c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 26 Mar 2026 20:16:15 +0000 Subject: [PATCH 02/20] fix: resolve playwright browser cache from original home --- src/renderer/browserPath.ts | 99 +++++++++++++++++++++++ src/renderer/capabilities.ts | 4 + src/renderer/ghosttyWeb/backend.ts | 13 +++ test/integration/renderer-backend.test.ts | 30 +++++++ test/unit/renderer/browserPath.test.ts | 99 +++++++++++++++++++++++ 5 files changed, 245 insertions(+) create mode 100644 src/renderer/browserPath.ts create mode 100644 test/unit/renderer/browserPath.test.ts diff --git a/src/renderer/browserPath.ts b/src/renderer/browserPath.ts new file mode 100644 index 0000000..2928846 --- /dev/null +++ b/src/renderer/browserPath.ts @@ -0,0 +1,99 @@ +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import process from 'node:process'; + +export type PlaywrightBrowsersPathSource = 'env' | 'captured-home'; + +export interface PlaywrightBrowsersPathResolution { + path: string; + source: PlaywrightBrowsersPathSource; +} + +export interface ResolvePlaywrightBrowsersPathOptions { + env?: NodeJS.ProcessEnv; + capturedHome?: string | undefined; + platform?: NodeJS.Platform; +} + +const CAPTURED_PROCESS_HOME = process.env.HOME; + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function resolveDefaultPlaywrightBrowsersPath( + capturedHome: string, + platform: NodeJS.Platform, +): string | null { + switch (platform) { + case 'linux': { + return join(capturedHome, '.cache', 'ms-playwright'); + } + case 'darwin': { + return join(capturedHome, 'Library', 'Caches', 'ms-playwright'); + } + default: { + return null; + } + } +} + +function directoryContainsChromiumBrowser(browserCachePath: string): boolean { + try { + return readdirSync(browserCachePath, { withFileTypes: true }).some( + (entry) => entry.isDirectory() && entry.name.startsWith('chromium'), + ); + } catch { + return false; + } +} + +export function resolvePlaywrightBrowsersPath( + options: ResolvePlaywrightBrowsersPathOptions = {}, +): PlaywrightBrowsersPathResolution | null { + const env = options.env ?? process.env; + const explicitOverride = env.PLAYWRIGHT_BROWSERS_PATH; + if (isNonEmptyString(explicitOverride)) { + return { + path: explicitOverride, + source: 'env', + }; + } + + const capturedHome = options.capturedHome ?? CAPTURED_PROCESS_HOME; + if (!isNonEmptyString(capturedHome)) { + return null; + } + + const browserCachePath = resolveDefaultPlaywrightBrowsersPath( + capturedHome, + options.platform ?? process.platform, + ); + if (browserCachePath === null) { + return null; + } + + if (!directoryContainsChromiumBrowser(browserCachePath)) { + return null; + } + + return { + path: browserCachePath, + source: 'captured-home', + }; +} + +export function ensurePlaywrightBrowsersPath( + options: ResolvePlaywrightBrowsersPathOptions = {}, +): PlaywrightBrowsersPathResolution | null { + const env = options.env ?? process.env; + const resolution = resolvePlaywrightBrowsersPath({ + ...options, + env, + }); + if (resolution?.source === 'captured-home') { + env.PLAYWRIGHT_BROWSERS_PATH = resolution.path; + } + + return resolution; +} diff --git a/src/renderer/capabilities.ts b/src/renderer/capabilities.ts index 9f59b58..16ac112 100644 --- a/src/renderer/capabilities.ts +++ b/src/renderer/capabilities.ts @@ -2,6 +2,8 @@ import assert from 'node:assert/strict'; import { z } from 'zod'; +import { ensurePlaywrightBrowsersPath } from './browserPath.js'; + // --- Capability vocabulary --- export const CapabilityNameSchema = z.enum([ @@ -106,6 +108,8 @@ async function probePlaywrightAvailability( mode: DiscoveryMode, ): Promise { try { + ensurePlaywrightBrowsersPath(); + const playwrightModule = (await import('playwright')) as { chromium?: { launch?: unknown; diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index ba8ac80..9896909 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -9,6 +9,7 @@ import { import { readFile, readdir, stat } from 'node:fs/promises'; import { dirname, isAbsolute, join, resolve } from 'node:path'; +import { ensurePlaywrightBrowsersPath } from '../browserPath.js'; import { chromium, type Browser, @@ -1980,6 +1981,18 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { this.server = server; this.serverOrigin = origin; + const browserPathResolution = ensurePlaywrightBrowsersPath(); + if (browserPathResolution === null) { + this.logger.debug( + 'No Playwright browser cache override resolved; using Playwright defaults', + ); + } else { + this.logger.debug( + 'Resolved Playwright browser cache path', + browserPathResolution, + ); + } + this.browser = await chromium.launch({ headless: true, }); diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index 1881126..da5851c 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -110,6 +110,36 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { } }); + it('resolves the browser cache from the original HOME when HOME is isolated before boot', async () => { + // prettier-ignore + const isolatedHome = await realpath(await mkdtemp(join(tmpdir(), 'agent-terminal-renderer-home-'))); + const previousHome = process.env.HOME; + const previousBrowsersPath = process.env.PLAYWRIGHT_BROWSERS_PATH; + if (previousHome === undefined) { + throw new Error('expected HOME to be defined before isolating renderer boot'); + } + + try { + delete process.env.PLAYWRIGHT_BROWSERS_PATH; + process.env.HOME = isolatedHome; + + await backend.boot(); + + expect(backend.isBooted).toBe(true); + expect(process.env.PLAYWRIGHT_BROWSERS_PATH).toBe( + join(previousHome, '.cache', 'ms-playwright'), + ); + } finally { + if (previousBrowsersPath === undefined) { + delete process.env.PLAYWRIGHT_BROWSERS_PATH; + } else { + process.env.PLAYWRIGHT_BROWSERS_PATH = previousBrowsersPath; + } + process.env.HOME = previousHome; + await rm(isolatedHome, { recursive: true, force: true }); + } + }); + it('replays consecutive output events and flushes batches before target breaks', async () => { await backend.boot(); diff --git a/test/unit/renderer/browserPath.test.ts b/test/unit/renderer/browserPath.test.ts new file mode 100644 index 0000000..f101f23 --- /dev/null +++ b/test/unit/renderer/browserPath.test.ts @@ -0,0 +1,99 @@ +import { mkdtemp, mkdir, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { + ensurePlaywrightBrowsersPath, + resolvePlaywrightBrowsersPath, +} from '../../../src/renderer/browserPath.js'; + +const temporaryHomes: string[] = []; + +async function createHomeDirectory(prefix: string): Promise { + const home = await realpath(await mkdtemp(join(tmpdir(), prefix))); + temporaryHomes.push(home); + return home; +} + +async function createPlaywrightCacheHome(): Promise<{ + browserCachePath: string; + home: string; +}> { + const home = await createHomeDirectory('agent-terminal-browser-path-'); + const browserCachePath = join(home, '.cache', 'ms-playwright'); + await mkdir(join(browserCachePath, 'chromium-1234'), { recursive: true }); + return { browserCachePath, home }; +} + +afterEach(async () => { + await Promise.all( + temporaryHomes.splice(0).map((home) => + rm(home, { recursive: true, force: true }), + ), + ); +}); + +describe('Playwright browser path resolution', () => { + it('prefers an explicit PLAYWRIGHT_BROWSERS_PATH override', () => { + const resolution = resolvePlaywrightBrowsersPath({ + capturedHome: '/ignored-home', + env: { + PLAYWRIGHT_BROWSERS_PATH: '/custom/playwright-cache', + }, + platform: 'linux', + }); + + expect(resolution).toEqual({ + path: '/custom/playwright-cache', + source: 'env', + }); + }); + + it('resolves the default browser cache from the captured HOME when chromium exists', async () => { + const { browserCachePath, home } = await createPlaywrightCacheHome(); + + const resolution = resolvePlaywrightBrowsersPath({ + capturedHome: home, + env: {}, + platform: 'linux', + }); + + expect(resolution).toEqual({ + path: browserCachePath, + source: 'captured-home', + }); + }); + + it('sets PLAYWRIGHT_BROWSERS_PATH when the captured HOME fallback resolves', async () => { + const { browserCachePath, home } = await createPlaywrightCacheHome(); + const env: NodeJS.ProcessEnv = {}; + + const resolution = ensurePlaywrightBrowsersPath({ + capturedHome: home, + env, + platform: 'linux', + }); + + expect(resolution).toEqual({ + path: browserCachePath, + source: 'captured-home', + }); + expect(env.PLAYWRIGHT_BROWSERS_PATH).toBe(browserCachePath); + }); + + it('returns null without crashing when no browser cache is present', async () => { + const home = await createHomeDirectory('agent-terminal-browser-path-empty-'); + const env: NodeJS.ProcessEnv = {}; + + const resolution = ensurePlaywrightBrowsersPath({ + capturedHome: home, + env, + platform: 'linux', + }); + + expect(resolution).toBeNull(); + expect(env.PLAYWRIGHT_BROWSERS_PATH).toBeUndefined(); + }); +}); From ce78bf80b748291a902b16c034fbaa8e9ffa0715 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 26 Mar 2026 20:17:29 +0000 Subject: [PATCH 03/20] feat: add run protocol plumbing --- src/cli/commands/inputSource.ts | 4 ++-- src/host/eventLog.ts | 13 +++++++++++++ src/protocol/messages.ts | 26 ++++++++++++++++++++++++++ src/protocol/schemas.ts | 19 +++++++++++++++++++ src/renderer/ghosttyWeb/backend.ts | 2 ++ src/renderer/types.ts | 16 ++++++++++++++++ 6 files changed, 78 insertions(+), 2 deletions(-) diff --git a/src/cli/commands/inputSource.ts b/src/cli/commands/inputSource.ts index 2ae6341..7136484 100644 --- a/src/cli/commands/inputSource.ts +++ b/src/cli/commands/inputSource.ts @@ -4,7 +4,7 @@ import { lstat, readFile, stat } from 'node:fs/promises'; import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; interface ResolveCommandInputTextOptions { - commandName: 'type' | 'paste'; + commandName: 'type' | 'paste' | 'run'; text: string | undefined; file: string | undefined; } @@ -23,7 +23,7 @@ function createInvalidInputError( }); } -function usageMessage(commandName: 'type' | 'paste'): string { +function usageMessage(commandName: 'type' | 'paste' | 'run'): string { return `Usage: agent-terminal ${commandName} [text] [--file ]`; } diff --git a/src/host/eventLog.ts b/src/host/eventLog.ts index ef489a3..23aaeec 100644 --- a/src/host/eventLog.ts +++ b/src/host/eventLog.ts @@ -7,8 +7,10 @@ import { z } from 'zod'; import { EventRecordSchema, + InputRunEventPayloadSchema, MarkerEventPayloadSchema, type EventRecord, + type InputRunEventPayload, type MarkerEventPayload, } from '../protocol/schemas.js'; import { invariant } from '../util/assert.js'; @@ -76,6 +78,7 @@ type EventLogEventType = | 'input_text' | 'input_paste' | 'input_keys' + | 'input_run' | 'resize' | 'signal' | 'exit' @@ -85,6 +88,7 @@ type EventLogPayload = | InputTextEventPayload | InputPasteEventPayload | InputKeysEventPayload + | InputRunEventPayload | ResizeEventPayload | SignalEventPayload | ExitEventPayload @@ -128,6 +132,11 @@ function validatePayload( invariant(result.success, 'input_keys payload must match schema'); return result.data; } + case 'input_run': { + const result = InputRunEventPayloadSchema.safeParse(payload); + invariant(result.success, 'input_run payload must match schema'); + return result.data; + } case 'resize': { const result = ResizeEventPayloadSchema.safeParse(payload); invariant(result.success, 'resize payload must match schema'); @@ -302,6 +311,10 @@ export class EventLog { type: 'input_keys', payload: InputKeysEventPayload, ): Promise; + async append( + type: 'input_run', + payload: InputRunEventPayload, + ): Promise; async append(type: 'resize', payload: ResizeEventPayload): Promise; async append(type: 'signal', payload: SignalEventPayload): Promise; async append(type: 'exit', payload: ExitEventPayload): Promise; diff --git a/src/protocol/messages.ts b/src/protocol/messages.ts index 56179a8..f3a2180 100644 --- a/src/protocol/messages.ts +++ b/src/protocol/messages.ts @@ -197,6 +197,27 @@ export type PasteParams = z.infer; export const PasteResultSchema = EmptyObjectSchema; export type PasteResult = z.infer; +export const RunParamsSchema = z + .object({ + command: z.string().min(1), + noWait: z.boolean().optional().default(false), + timeoutMs: z.number().int().positive().optional(), + }) + .strict(); +export type RunParams = z.infer; + +export const RunResultSchema = z + .object({ + accepted: z.literal(true), + completed: z.boolean().optional(), + timedOut: z.boolean().optional(), + seq: z.number().int().nonnegative(), + durationMs: z.number().int().nonnegative().optional(), + marker: z.string().optional(), + }) + .strict(); +export type RunResult = z.infer; + export const MarkParamsSchema = z .object({ label: z.string(), @@ -289,6 +310,7 @@ const RPC_METHODS = [ 'screenshot', 'type', 'paste', + 'run', 'mark', 'sendKeys', 'resize', @@ -322,6 +344,10 @@ export const RpcMethodSchemas = { params: PasteParamsSchema, result: PasteResultSchema, }, + run: { + params: RunParamsSchema, + result: RunResultSchema, + }, mark: { params: MarkParamsSchema, result: MarkResultSchema, diff --git a/src/protocol/schemas.ts b/src/protocol/schemas.ts index 5d7b35d..ab41c4c 100644 --- a/src/protocol/schemas.ts +++ b/src/protocol/schemas.ts @@ -100,6 +100,15 @@ export const InputKeysEventPayloadSchema = z .strict(); export type InputKeysEventPayload = z.infer; +export const InputRunEventPayloadSchema = z + .object({ + command: z.string().min(1), + marker: z.string().optional(), + noWait: z.boolean(), + }) + .strict(); +export type InputRunEventPayload = z.infer; + export const ResizeEventPayloadSchema = z .object({ cols: PositiveIntSchema, @@ -137,6 +146,7 @@ export const EventTypeSchema = z.enum([ 'input_text', 'input_paste', 'input_keys', + 'input_run', 'resize', 'signal', 'exit', @@ -181,6 +191,14 @@ export const InputKeysEventRecordSchema = z }) .strict(); +export const InputRunEventRecordSchema = z + .object({ + ...EventRecordBaseShape, + type: z.literal('input_run'), + payload: InputRunEventPayloadSchema, + }) + .strict(); + export const ResizeEventRecordSchema = z .object({ ...EventRecordBaseShape, @@ -218,6 +236,7 @@ export const EventRecordSchema = z.discriminatedUnion('type', [ InputTextEventRecordSchema, InputPasteEventRecordSchema, InputKeysEventRecordSchema, + InputRunEventRecordSchema, ResizeEventRecordSchema, SignalEventRecordSchema, ExitEventRecordSchema, diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index 9896909..973f302 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -1463,6 +1463,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_text': case 'input_paste': case 'input_keys': + case 'input_run': case 'signal': case 'exit': { await flushOutputBatch(); @@ -1720,6 +1721,7 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { case 'input_text': case 'input_paste': case 'input_keys': + case 'input_run': case 'signal': case 'exit': { await flushOutputBatch(); diff --git a/src/renderer/types.ts b/src/renderer/types.ts index fa7128f..989e9ad 100644 --- a/src/renderer/types.ts +++ b/src/renderer/types.ts @@ -80,6 +80,21 @@ const InputKeysReplayEventSchema = z }) .strict(); +const InputRunReplayEventSchema = z + .object({ + seq: NonNegativeIntSchema, + ts: z.iso.datetime(), + type: z.literal('input_run'), + payload: z + .object({ + command: z.string().min(1), + marker: z.string().optional(), + noWait: z.boolean(), + }) + .strict(), + }) + .strict(); + const ResizeReplayEventSchema = z .object({ seq: NonNegativeIntSchema, @@ -135,6 +150,7 @@ export const ReplayEventSchema = z.discriminatedUnion('type', [ InputTextReplayEventSchema, InputPasteReplayEventSchema, InputKeysReplayEventSchema, + InputRunReplayEventSchema, ResizeReplayEventSchema, MarkerReplayEventSchema, SignalReplayEventSchema, From 0801ccf6e9ca46959c41e0c8a11438681dc85884 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 26 Mar 2026 20:21:59 +0000 Subject: [PATCH 04/20] feat: add host run rpc handler --- src/host/hostMain.ts | 139 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index a5165d4..5ca7641 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -1,3 +1,4 @@ +import crypto from 'node:crypto'; import { rename, rm } from 'node:fs/promises'; import process from 'node:process'; @@ -16,6 +17,8 @@ import type { MarkParams, PasteParams, ResizeParams, + RunParams, + RunResult, ScreenshotParams, SendKeysParams, SignalParams, @@ -625,6 +628,142 @@ export async function runHost(sessionId: string): Promise { await eventLog.append('input_paste', { data: encoded }); return {}; }, + run: async (params: unknown) => { + const { command, noWait, timeoutMs } = params as RunParams; + + if (!isSessionRunning(state)) { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: 'Session is not running.', + }); + } + + invariant( + typeof command === 'string' && command.length > 0, + 'run command must be a non-empty string', + ); + invariant( + noWait === undefined || typeof noWait === 'boolean', + 'run noWait must be a boolean when provided', + ); + if (timeoutMs !== undefined) { + invariant( + Number.isInteger(timeoutMs) && timeoutMs > 0, + 'run timeoutMs must be a positive integer', + ); + } + + const shouldWait = noWait !== true; + let marker: string | undefined; + if (shouldWait) { + marker = `__AT_MARKER_${crypto.randomUUID().replace(/-/g, '')}__`; + } + + const injectedText = shouldWait + ? `${command}\necho ${marker}\n` + : `${command}\n`; + const encoded = encodePaste(injectedText); + pty.write(encoded); + lastActivityAt = Date.now(); + + const seq = await eventLog.append('input_run', { + command, + ...(marker === undefined ? {} : { marker }), + noWait: noWait === true, + }); + + if (!shouldWait) { + return { + accepted: true as const, + seq, + } satisfies RunResult; + } + + const effectiveTimeoutMs = timeoutMs ?? 30_000; + const startTime = Date.now(); + const profile = resolveProfile(DEFAULT_RENDER_PROFILE_NAME); + const pollIntervalMs = 200; + const waitMarker = marker; + invariant(waitMarker !== undefined, 'run wait marker must be defined'); + let clearWaitPoll: (() => void) | null = null; + + const pollCondition = new Promise<{ matched: boolean }>((resolve) => { + let pollInFlight = false; + let consecutiveFailures = 0; + + const checkInterval = setInterval(() => { + if (pollInFlight) { + return; + } + + pollInFlight = true; + void (async () => { + try { + const replayInput = loadReplayInput(); + const backend = await rendererManager.getBackend( + profile, + replayInput, + ); + const snapshot = await backend.snapshot(); + const visibleText = snapshot.visibleLines + .map((line) => line.text) + .join('\n'); + consecutiveFailures = 0; + + if (visibleText.includes(waitMarker)) { + clearInterval(checkInterval); + resolve({ matched: true }); + } + } catch (pollError) { + void pollError; + consecutiveFailures += 1; + if (consecutiveFailures >= MAX_CONSECUTIVE_POLL_FAILURES) { + clearInterval(checkInterval); + resolve({ matched: false }); + } + } finally { + pollInFlight = false; + } + })(); + }, pollIntervalMs); + + clearWaitPoll = (): void => { + clearInterval(checkInterval); + }; + }); + + const pollResult = await new Promise<{ matched: boolean }>((resolve) => { + let resolved = false; + const timeoutHandle = setTimeout(() => { + if (resolved) { + return; + } + resolved = true; + clearWaitPoll?.(); + resolve({ matched: false }); + }, effectiveTimeoutMs); + + void pollCondition.then((result) => { + if (resolved) { + return; + } + resolved = true; + clearTimeout(timeoutHandle); + clearWaitPoll?.(); + resolve(result); + }); + }); + + const durationMs = Date.now() - startTime; + + return { + accepted: true as const, + completed: pollResult.matched, + timedOut: !pollResult.matched, + seq, + durationMs, + marker: waitMarker, + } satisfies RunResult; + }, sendKeys: async (params: unknown) => { const { keys } = params as SendKeysParams; From 7058f0a3821a22f239e0564b9929f11453aedc4c Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Thu, 26 Mar 2026 20:23:56 +0000 Subject: [PATCH 05/20] feat: add run cli command --- src/cli/commands/run.ts | 135 ++++++++++++++++++++++++++++++++++++++++ src/cli/main.ts | 35 +++++++++++ 2 files changed, 170 insertions(+) create mode 100644 src/cli/commands/run.ts diff --git a/src/cli/commands/run.ts b/src/cli/commands/run.ts new file mode 100644 index 0000000..a2bdea9 --- /dev/null +++ b/src/cli/commands/run.ts @@ -0,0 +1,135 @@ +import type { CommandContext } from '../context.js'; + +import { emitSuccess } from '../output.js'; +import { sendRpc } from '../../host/rpcClient.js'; +import { RunResultSchema } from '../../protocol/messages.js'; +import { ERROR_CODES, makeCliError } from '../../protocol/errors.js'; +import { readManifestIfExists } from '../../storage/manifests.js'; +import { + manifestPath, + sessionDir, + socketPath, +} from '../../storage/sessionPaths.js'; +import { resolveCommandInputText } from './inputSource.js'; + +export interface RunResult { + accepted: true; + completed?: boolean | undefined; + timedOut?: boolean | undefined; + seq: number; + durationMs?: number | undefined; + marker?: string | undefined; +} + +interface CommandOptions { + context: CommandContext; + json: boolean; + sessionId: string; + text: string | undefined; + file?: string; + timeout: number; + wait: boolean; +} + +export async function runRunCommand(options: CommandOptions): Promise { + const command = await resolveCommandInputText({ + commandName: 'run', + text: options.text, + file: options.file, + }); + + if (command.length === 0) { + throw makeCliError(ERROR_CODES.INVALID_INPUT, { + message: 'Command text must not be empty.', + details: { + command, + }, + }); + } + + const home = options.context.home; + const sessionDirectory = sessionDir(home, options.sessionId); + const manifestFile = manifestPath(sessionDirectory); + const manifest = await readManifestIfExists(manifestFile); + + if (manifest === null) { + throw makeCliError(ERROR_CODES.SESSION_NOT_FOUND, { + message: `Session "${options.sessionId}" was not found.`, + details: { + sessionId: options.sessionId, + manifestPath: manifestFile, + }, + }); + } + + if (manifest.status === 'destroyed') { + throw makeCliError(ERROR_CODES.SESSION_ALREADY_DESTROYED, { + message: `Session "${options.sessionId}" is already destroyed.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + if (manifest.status !== 'running') { + throw makeCliError(ERROR_CODES.SESSION_NOT_RUNNING, { + message: `Session "${options.sessionId}" is not running.`, + details: { + sessionId: options.sessionId, + status: manifest.status, + }, + }); + } + + const noWait = !options.wait; + const rpcParams: Record = { + command, + noWait, + }; + + if (!noWait && options.timeout > 0) { + rpcParams.timeoutMs = options.timeout; + } + + const rpcTimeoutMs = noWait ? 10_000 : options.timeout + 10_000; + const rawResult = await sendRpc( + socketPath(sessionDirectory), + 'run', + rpcParams, + rpcTimeoutMs, + ); + + const parsed = RunResultSchema.safeParse(rawResult); + if (!parsed.success) { + throw makeCliError(ERROR_CODES.PROTOCOL_ERROR, { + message: 'Unexpected response shape from the session host.', + details: { + errors: parsed.error.issues, + rawResult, + }, + }); + } + + const result: RunResult = parsed.data; + const lines: string[] = []; + + if (noWait) { + lines.push(`Command injected into session (seq=${String(result.seq)}).`); + } else if (result.completed) { + lines.push( + `Command completed (seq=${String(result.seq)}, ${String(result.durationMs)}ms).`, + ); + } else if (result.timedOut) { + lines.push( + `Command timed out after ${String(result.durationMs)}ms (seq=${String(result.seq)}).`, + ); + } + + emitSuccess({ + command: 'run', + json: options.json, + result, + lines, + }); +} diff --git a/src/cli/main.ts b/src/cli/main.ts index 412a3c6..dbff048 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -14,6 +14,7 @@ import { runInspectCommand } from './commands/inspect.js'; import { runListCommand } from './commands/list.js'; import { runMarkCommand } from './commands/mark.js'; import { runPasteCommand } from './commands/paste.js'; +import { runRunCommand } from './commands/run.js'; import { runRecordExportCommand } from './commands/record-export.js'; import { runResizeCommand } from './commands/resize.js'; import { runScreenshotCommand } from './commands/screenshot.js'; @@ -383,6 +384,40 @@ async function main(): Promise { ), ); + program + .command('run [command]') + .description('Run a command in a session and optionally wait for completion') + .option('--file ', 'Read command text from a file') + .option('--timeout ', 'Wait timeout in milliseconds', '30000') + .option('--no-wait', 'Do not wait for completion') + .option('--json', 'Emit a JSON command envelope', false) + .action( + wrapAction( + 'run', + async ( + sessionId: string, + text: string | undefined, + options: { + file?: string; + timeout: string; + wait: boolean; + json: boolean; + }, + context: CommandContext, + ) => { + await runRunCommand({ + context, + json: options.json, + sessionId, + text, + ...(options.file !== undefined ? { file: options.file } : {}), + timeout: Number.parseInt(options.timeout, 10), + wait: options.wait, + }); + }, + ), + ); + program .command('mark