diff --git a/src/daemon/handlers/__tests__/session-doctor-web.test.ts b/src/daemon/handlers/__tests__/session-doctor-web.test.ts new file mode 100644 index 000000000..c1ed94367 --- /dev/null +++ b/src/daemon/handlers/__tests__/session-doctor-web.test.ts @@ -0,0 +1,75 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; +import type { AgentBrowserProcessSummary } from '../../../platforms/web/agent-browser-lifecycle.ts'; +import { installFakeManagedAgentBrowser } from '../../../platforms/web/__tests__/test-utils.ts'; +import type { DoctorCheck } from '../session-doctor-types.ts'; + +vi.mock('../../../platforms/web/agent-browser-lifecycle.ts', async () => { + const actual = await vi.importActual< + typeof import('../../../platforms/web/agent-browser-lifecycle.ts') + >('../../../platforms/web/agent-browser-lifecycle.ts'); + return { + ...actual, + inspectManagedAgentBrowserProcesses: vi.fn(), + }; +}); + +const { inspectManagedAgentBrowserProcesses } = + await import('../../../platforms/web/agent-browser-lifecycle.ts'); +const { appendWebBrowserLifecycleCheck } = await import('../session-doctor-web.ts'); + +const mockInspectManagedAgentBrowserProcesses = vi.mocked(inspectManagedAgentBrowserProcesses); + +beforeEach(() => { + mockInspectManagedAgentBrowserProcesses.mockReset(); +}); + +test('web doctor lifecycle check reports live managed Chrome process count', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-doctor-')); + try { + installFakeManagedAgentBrowser(stateDir); + mockInspectManagedAgentBrowserProcesses.mockResolvedValue({ + count: 2, + pids: [101, 102], + processes: [ + { + process: { pid: 101, command: 'chrome --agent-device-managed-web=abc' }, + reason: 'launch-marker', + }, + { + process: { pid: 102, command: 'chrome from managed home' }, + reason: 'managed-browser-home', + }, + ], + } satisfies AgentBrowserProcessSummary); + + const checks: DoctorCheck[] = []; + await appendWebBrowserLifecycleCheck(checks, stateDir); + + assert.equal(checks.length, 1); + assert.equal(checks[0]?.id, 'web-agent-browser-processes'); + assert.equal(checks[0]?.status, 'info'); + assert.match(checks[0]?.summary ?? '', /2 live agent-device-owned Chrome processes/); + assert.deepEqual(checks[0]?.evidence?.pids, [101, 102]); + assert.deepEqual(checks[0]?.evidence?.matchReasons, ['launch-marker', 'managed-browser-home']); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('web doctor lifecycle check stays informational when managed backend is missing', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-doctor-missing-')); + try { + const checks: DoctorCheck[] = []; + await appendWebBrowserLifecycleCheck(checks, stateDir); + + assert.equal(checks[0]?.status, 'info'); + assert.match(checks[0]?.summary ?? '', /not installed/); + assert.equal(mockInspectManagedAgentBrowserProcesses.mock.calls.length, 0); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/handlers/session-doctor-web.ts b/src/daemon/handlers/session-doctor-web.ts new file mode 100644 index 000000000..f8e946e65 --- /dev/null +++ b/src/daemon/handlers/session-doctor-web.ts @@ -0,0 +1,50 @@ +import { inspectManagedAgentBrowserProcesses } from '../../platforms/web/agent-browser-lifecycle.ts'; +import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts'; +import { appendDoctorCheck } from './session-doctor-output.ts'; +import type { DoctorCheck } from './session-doctor-types.ts'; + +export async function appendWebBrowserLifecycleCheck( + checks: DoctorCheck[], + stateDir: string, +): Promise { + appendDoctorCheck(checks, await webBrowserLifecycleCheck(stateDir)); +} + +async function webBrowserLifecycleCheck(stateDir: string): Promise { + const status = getManagedAgentBrowserStatus({ stateDir }); + if (!status.installed) { + return { + id: 'web-agent-browser-processes', + status: 'info', + summary: + 'Managed web backend is not installed; no agent-device-owned browser processes counted.', + evidence: { stateDir, installed: false }, + }; + } + try { + const summary = await inspectManagedAgentBrowserProcesses(status); + return { + id: 'web-agent-browser-processes', + status: summary.count > 0 ? 'info' : 'pass', + summary: + summary.count > 0 + ? `${summary.count} live agent-device-owned Chrome process${summary.count === 1 ? '' : 'es'} detected.` + : 'No live agent-device-owned Chrome processes detected.', + evidence: { + stateDir, + installed: true, + count: summary.count, + pids: summary.pids, + matchReasons: summary.processes.map((match) => match.reason), + }, + }; + } catch (error) { + return { + id: 'web-agent-browser-processes', + status: 'info', + summary: 'Could not inspect live agent-device-owned Chrome processes.', + hint: 'Run doctor again from a shell with permission to inspect local processes.', + evidence: { stateDir, error: error instanceof Error ? error.message : String(error) }, + }; + } +} diff --git a/src/daemon/handlers/session-doctor.ts b/src/daemon/handlers/session-doctor.ts index 11947507c..0fa23d28e 100644 --- a/src/daemon/handlers/session-doctor.ts +++ b/src/daemon/handlers/session-doctor.ts @@ -33,6 +33,7 @@ import { hasCachedAppleRunnerArtifact, prewarmAppleRunnerCache, } from '../../platforms/apple/core/runner/runner-client.ts'; +import { appendWebBrowserLifecycleCheck } from './session-doctor-web.ts'; export async function handleDoctorCommand(params: { req: DaemonRequest; @@ -71,6 +72,7 @@ export async function handleDoctorCommand(params: { inventory, options, session, + stateDir, }); await appendIosRunnerWarmupCheck(checks, appCheckDevice ?? resolveWarmupSimulator(inventory)); return doctorResponse(checks, options, { device: appCheckDevice, includeMetro: true, inventory }); @@ -139,8 +141,9 @@ async function appendLocalDoctorChecks(params: { inventory: DoctorDeviceInventory | undefined; options: DoctorOptions; session: SessionState | undefined; + stateDir: string; }): Promise { - const { checks, inventory, options, session, androidAdbExecutor } = params; + const { checks, inventory, options, session, androidAdbExecutor, stateDir } = params; const appCheckDevice = session?.device ?? resolveDoctorDeviceForAppCheck(checks, inventory, options.targetApp); if (appCheckDevice) { @@ -154,6 +157,7 @@ async function appendLocalDoctorChecks(params: { if (options.shouldProbeMetro) { appendDoctorCheck(checks, await probeMetro(options.metroHost, options.metroPort, options.kind)); } + await appendWebBrowserLifecycleCheck(checks, stateDir); return appCheckDevice; } diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 12f500419..1315d6373 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -176,7 +176,7 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { webProvider: webProvider ?? (shouldUseDefaultWebProvider(lockedScope) - ? createDefaultWebProvider(stateDir) + ? createDefaultWebProvider(stateDir, sessionStore) : undefined), appLogProvider, recordingProvider, @@ -244,14 +244,25 @@ export function createRequestHandler(deps: RequestRouterDeps): DaemonInvokeFn { } const createDefaultWebProvider = - (stateDir: string | undefined): WebProviderResolver => + (stateDir: string | undefined, sessionStore: SessionStore): WebProviderResolver => ({ req, session }) => - createAgentBrowserWebProvider({ session: session?.name ?? req.session, stateDir }); + createAgentBrowserWebProvider({ + session: session?.name ?? req.session, + stateDir, + openWebSessionNames: () => openWebSessionNames(sessionStore), + }); function shouldUseDefaultWebProvider(scope: LockedRequestScope): boolean { return scope.existingSession?.device.platform === 'web' || scope.req.flags?.platform === 'web'; } +function openWebSessionNames(sessionStore: SessionStore): string[] { + return sessionStore + .toArray() + .filter((session) => session.device.platform === 'web') + .map((session) => session.name); +} + function unauthorizedResponse(): DaemonResponse { return { ok: false, diff --git a/src/daemon/server/daemon-runtime-web-cleanup.test.ts b/src/daemon/server/daemon-runtime-web-cleanup.test.ts new file mode 100644 index 000000000..feb15e738 --- /dev/null +++ b/src/daemon/server/daemon-runtime-web-cleanup.test.ts @@ -0,0 +1,66 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { beforeEach, test, vi } from 'vitest'; + +const { cleanupManagedAgentBrowserOrphansMock } = vi.hoisted(() => ({ + cleanupManagedAgentBrowserOrphansMock: vi.fn(), +})); + +vi.mock('../../platforms/web/agent-browser-lifecycle.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + cleanupManagedAgentBrowserOrphans: cleanupManagedAgentBrowserOrphansMock, + }; +}); + +import { WEB_DESKTOP_DEVICE } from '../../__tests__/test-utils/index.ts'; +import { SessionStore } from '../session-store.ts'; +import { cleanupWebBrowserOrphansForDaemonStartup } from './daemon-runtime.ts'; +import { installFakeManagedAgentBrowser } from '../../platforms/web/__tests__/test-utils.ts'; + +const mockCleanupManagedAgentBrowserOrphans = vi.mocked(cleanupManagedAgentBrowserOrphansMock); + +beforeEach(() => { + mockCleanupManagedAgentBrowserOrphans.mockReset(); +}); + +test('daemon-startup web cleanup passes open web sessions to the reaper', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-daemon-cleanup-')); + try { + installFakeManagedAgentBrowser(stateDir); + const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); + sessionStore.set('web-session', { + name: 'web-session', + device: WEB_DESKTOP_DEVICE, + createdAt: Date.now(), + actions: [], + }); + + await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore }); + + assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 1); + assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[1], 'daemon-startup'); + assert.deepEqual(mockCleanupManagedAgentBrowserOrphans.mock.calls[0]?.[2], { + openWebSessionNames: ['web-session'], + }); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('daemon-startup web cleanup does not run when the managed backend is absent', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-daemon-cleanup-')); + try { + const sessionStore = new SessionStore(path.join(stateDir, 'sessions')); + + await cleanupWebBrowserOrphansForDaemonStartup({ stateDir, sessionStore }); + + assert.equal(mockCleanupManagedAgentBrowserOrphans.mock.calls.length, 0); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/daemon/server/daemon-runtime.ts b/src/daemon/server/daemon-runtime.ts index aff7b91ae..5426e9e46 100644 --- a/src/daemon/server/daemon-runtime.ts +++ b/src/daemon/server/daemon-runtime.ts @@ -41,6 +41,8 @@ import { import { prewarmPngWorker, terminatePngWorker } from '../../utils/png-worker-client.ts'; import { sleep } from '../../utils/timeouts.ts'; import { setRunnerLeaseOwnerStateDir } from '../../platforms/apple/core/runner/runner-lease.ts'; +import { cleanupManagedAgentBrowserOrphans } from '../../platforms/web/agent-browser-lifecycle.ts'; +import { getManagedAgentBrowserStatus } from '../../platforms/web/agent-browser-tool.ts'; const DAEMON_SESSION_TEARDOWN_TIMEOUT_MS = 5_000; const DAEMON_PNG_WORKER_TERMINATE_TIMEOUT_MS = 1_000; @@ -216,6 +218,7 @@ export async function startDaemonRuntime( let socketPort: number | undefined; let httpPort: number | undefined; try { + await cleanupWebBrowserOrphansForDaemonStartup({ stateDir: baseDir, sessionStore }); const opened = await openDaemonServers(); servers = opened.servers; socketPort = opened.socketPort; @@ -299,3 +302,29 @@ export async function startDaemonRuntime( token, }; } + +export async function cleanupWebBrowserOrphansForDaemonStartup(params: { + stateDir: string; + sessionStore: SessionStore; +}): Promise { + const status = getManagedAgentBrowserStatus({ stateDir: params.stateDir }); + if (!status.installed) return; + try { + await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + openWebSessionNames: openWebSessionNames(params.sessionStore), + }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'web_agent_browser_orphan_cleanup_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } +} + +function openWebSessionNames(sessionStore: SessionStore): string[] { + return sessionStore + .toArray() + .filter((session) => session.device.platform === 'web') + .map((session) => session.name); +} diff --git a/src/platforms/web/agent-browser-lifecycle.test.ts b/src/platforms/web/agent-browser-lifecycle.test.ts new file mode 100644 index 000000000..b252e54ef --- /dev/null +++ b/src/platforms/web/agent-browser-lifecycle.test.ts @@ -0,0 +1,324 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, test, vi } from 'vitest'; + +const { runCmdMock } = vi.hoisted(() => ({ + runCmdMock: vi.fn(), +})); + +vi.mock('../../utils/exec.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + runCmd: runCmdMock, + withoutCommandExecutorOverride: async (fn: () => Promise) => await fn(), + }; +}); + +import { + DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS, + agentBrowserChromeLaunchMarker, + appendAgentDeviceChromeArgs, + cleanupManagedAgentBrowserOrphans, + expandProcessTree, + matchAgentBrowserChromeProcess, + parseHostProcessList, + resolveAgentBrowserIdleTimeoutMs, + summarizeAgentBrowserProcesses, +} from './agent-browser-lifecycle.ts'; +import { getManagedAgentBrowserStatus } from './agent-browser-tool.ts'; +import { installFakeManagedAgentBrowser } from './__tests__/test-utils.ts'; + +const mockRunCmd = vi.mocked(runCmdMock); + +beforeEach(() => { + mockRunCmd.mockReset(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +test('managed Chrome launch args include a stable agent-device ownership marker once', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + const marker = agentBrowserChromeLaunchMarker(status); + + assert.match(marker, /^--agent-device-managed-web=[a-f0-9]{16}$/); + assert.equal(appendAgentDeviceChromeArgs(undefined, status), marker); + assert.equal(appendAgentDeviceChromeArgs('--no-sandbox', status), `--no-sandbox,${marker}`); + assert.equal( + appendAgentDeviceChromeArgs(`--no-sandbox,${marker}`, status), + `--no-sandbox,${marker}`, + ); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('managed agent-browser idle timeout defaults to five minutes and respects overrides', () => { + assert.equal(resolveAgentBrowserIdleTimeoutMs({}), DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS); + assert.equal( + resolveAgentBrowserIdleTimeoutMs({ AGENT_DEVICE_WEB_IDLE_TIMEOUT_MS: '1200' }), + 1200, + ); + assert.equal( + resolveAgentBrowserIdleTimeoutMs({ + AGENT_BROWSER_IDLE_TIMEOUT_MS: '900', + AGENT_DEVICE_WEB_IDLE_TIMEOUT_MS: '1200', + }), + 900, + ); + assert.equal( + resolveAgentBrowserIdleTimeoutMs({ AGENT_BROWSER_IDLE_TIMEOUT_MS: '0' }), + DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS, + ); +}); + +test('host process parser preserves command text after pid and parent pid', () => { + assert.deepEqual( + parseHostProcessList( + [ + ' 101 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ' 202 101 /tmp/chrome --type=renderer --flag value', + 'not a process', + ].join('\n'), + ), + [ + { + pid: 101, + ppid: 1, + command: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + }, + { pid: 202, ppid: 101, command: '/tmp/chrome --type=renderer --flag value' }, + ], + ); +}); + +test('ownership detection accepts only Chrome-like processes with managed markers', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + const marker = agentBrowserChromeLaunchMarker(status); + const managedChrome = path.join( + status.homeDir, + '.agent-browser', + 'browsers', + 'chrome-150', + 'Google Chrome for Testing.app', + 'Contents', + 'MacOS', + 'Google Chrome for Testing', + ); + + const markerMatch = matchAgentBrowserChromeProcess( + { pid: 11, command: `/Applications/Chromium.app/Contents/MacOS/Chromium ${marker}` }, + status, + ); + const homeMatch = matchAgentBrowserChromeProcess( + { pid: 12, command: `${managedChrome} --type=renderer` }, + status, + ); + const userChrome = matchAgentBrowserChromeProcess( + { + pid: 13, + command: + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --profile-directory=Default', + }, + status, + ); + const broadRuntimeHome = matchAgentBrowserChromeProcess( + { pid: 15, command: `${status.runtimeHomeDir}/profile-cache/chrome-helper` }, + status, + ); + const markedNonBrowser = matchAgentBrowserChromeProcess( + { pid: 14, command: `/bin/sh -c echo ${marker}` }, + status, + ); + + assert.equal(markerMatch?.reason, 'launch-marker'); + assert.equal(homeMatch?.reason, 'managed-browser-home'); + assert.equal(userChrome, undefined); + assert.equal(broadRuntimeHome, undefined); + assert.equal(markedNonBrowser, undefined); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('cleanup skips reaping when the daemon reports open web sessions', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup', { + openWebSessionNames: ['default-web'], + }); + + assert.equal(result.skipped?.reason, 'open-web-session'); + assert.deepEqual(result.skipped?.openWebSessionNames, ['default-web']); + assert.equal(mockRunCmd.mock.calls.length, 0); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('cleanup skips reaping when managed browser socket activity is recent', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + const originalIdleTimeout = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '60000'; + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + fs.mkdirSync(status.socketDir, { recursive: true }); + fs.writeFileSync(path.join(status.socketDir, 'agent-browser.sock'), ''); + + const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup'); + + assert.equal(result.skipped?.reason, 'recent-browser-activity'); + assert.equal(mockRunCmd.mock.calls.length, 0); + } finally { + if (originalIdleTimeout === undefined) { + delete process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + } else { + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = originalIdleTimeout; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('cleanup does not treat the shared socket directory mtime as browser activity', async () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + const originalIdleTimeout = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '60000'; + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + fs.mkdirSync(status.socketDir, { recursive: true }); + mockRunCmd.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + + const result = await cleanupManagedAgentBrowserOrphans(status, 'daemon-startup'); + + assert.equal(result.skipped, undefined); + assert.equal(mockRunCmd.mock.calls.length, 1); + } finally { + if (originalIdleTimeout === undefined) { + delete process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + } else { + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = originalIdleTimeout; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('process tree expansion includes descendants of matched browser roots', () => { + const expanded = expandProcessTree( + [{ process: { pid: 101, ppid: 1, command: 'chrome' }, reason: 'launch-marker' }], + [ + { pid: 101, ppid: 1, command: 'chrome' }, + { pid: 201, ppid: 101, command: 'Chrome Helper --type=renderer' }, + { pid: 301, ppid: 201, command: 'Chrome Helper --type=gpu' }, + { pid: 999, ppid: 1, command: 'Google Chrome' }, + ], + ); + + assert.deepEqual( + expanded.map((processInfo) => processInfo.pid), + [101, 201, 301], + ); +}); + +test('cleanup signals matched browser process trees with TERM then KILL only for live pids', async () => { + vi.useFakeTimers(); + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + const originalIdleTimeout = process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = '1'; + const alivePids = new Set([101, 201, 301, 999]); + const killCalls: Array<{ pid: number; signal: string | number | undefined }> = []; + const killSpy = vi.spyOn(process, 'kill').mockImplementation((pid, signal) => { + const numericPid = Number(pid); + killCalls.push({ pid: numericPid, signal }); + if (signal === 'SIGKILL') alivePids.delete(numericPid); + if (signal === 0 && !alivePids.has(numericPid)) { + const error = new Error('not found') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }); + + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + const marker = agentBrowserChromeLaunchMarker(status); + mockRunCmd.mockResolvedValue({ + stdout: [ + ` 101 1 /Applications/Chromium.app/Contents/MacOS/Chromium ${marker}`, + ' 201 101 /Applications/Chromium.app/Contents/Frameworks/Chromium Helper --type=renderer', + ' 301 201 /Applications/Chromium.app/Contents/Frameworks/Chromium Helper --type=gpu', + ' 999 1 /Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + ].join('\n'), + stderr: '', + exitCode: 0, + }); + + const cleanup = cleanupManagedAgentBrowserOrphans(status, 'provider-startup'); + await vi.advanceTimersByTimeAsync(1_500); + const result = await cleanup; + + assert.deepEqual(result.pids, [101]); + assert.deepEqual(result.signalPids, [101, 201, 301]); + assert.deepEqual( + killCalls.filter((call) => call.signal === 'SIGTERM').map((call) => call.pid), + [101, 201, 301], + ); + assert.deepEqual( + killCalls.filter((call) => call.signal === 'SIGKILL').map((call) => call.pid), + [101, 201, 301], + ); + assert.equal( + killCalls.some((call) => call.pid === 999), + false, + ); + assert.equal(killSpy.mock.calls.length > 0, true); + } finally { + if (originalIdleTimeout === undefined) { + delete process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS; + } else { + process.env.AGENT_BROWSER_IDLE_TIMEOUT_MS = originalIdleTimeout; + } + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); + +test('process summary reports only conservatively owned managed Chrome processes', () => { + const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-life-')); + try { + installFakeManagedAgentBrowser(stateDir); + const status = getManagedAgentBrowserStatus({ stateDir }); + const marker = agentBrowserChromeLaunchMarker(status); + const summary = summarizeAgentBrowserProcesses( + [ + { pid: 101, command: `/opt/chrome ${marker}` }, + { pid: 102, command: `${status.runtimeHomeDir}/.agent-browser/browsers/chrome/chrome` }, + { pid: 103, command: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome' }, + ], + status, + ); + + assert.equal(summary.count, 2); + assert.deepEqual(summary.pids, [101, 102]); + assert.deepEqual( + summary.processes.map((process) => process.reason), + ['launch-marker', 'managed-browser-home'], + ); + } finally { + fs.rmSync(stateDir, { recursive: true, force: true }); + } +}); diff --git a/src/platforms/web/agent-browser-lifecycle.ts b/src/platforms/web/agent-browser-lifecycle.ts new file mode 100644 index 000000000..ab7259d78 --- /dev/null +++ b/src/platforms/web/agent-browser-lifecycle.ts @@ -0,0 +1,313 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { runCmd, withoutCommandExecutorOverride } from '../../utils/exec.ts'; +import { isProcessAlive, waitForProcessExit } from '../../utils/process-identity.ts'; +import { sleep } from '../../utils/timeouts.ts'; +import type { AgentBrowserToolStatus } from './agent-browser-tool.ts'; + +const HOST_PROCESS_LIST_TIMEOUT_MS = 1_500; +const WEB_BROWSER_REAP_TERM_TIMEOUT_MS = 1_500; +const WEB_BROWSER_REAP_KILL_TIMEOUT_MS = 1_000; +const PROVIDER_STARTUP_CLEANUP_DEBOUNCE_MS = 30_000; +const AGENT_DEVICE_BROWSER_MARKER_PREFIX = '--agent-device-managed-web='; +export const DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS = 5 * 60_000; + +export type HostProcessInfo = { + pid: number; + ppid?: number; + command: string; +}; + +export type AgentBrowserProcessMatch = { + process: HostProcessInfo; + reason: 'launch-marker' | 'managed-browser-home'; +}; + +export type AgentBrowserCleanupSkipReason = 'open-web-session' | 'recent-browser-activity'; + +export type AgentBrowserProcessSummary = { + count: number; + pids: number[]; + processes: AgentBrowserProcessMatch[]; +}; + +export type AgentBrowserCleanupResult = AgentBrowserProcessSummary & { + signalPids: number[]; + skipped?: { + reason: AgentBrowserCleanupSkipReason; + openWebSessionNames?: string[]; + idleTimeoutMs?: number; + latestActivityMs?: number; + }; +}; + +export type AgentBrowserCleanupOptions = { + openWebSessionNames?: readonly string[]; +}; + +const providerStartupCleanupAttempts = new Map(); + +export function agentBrowserChromeLaunchMarker(status: AgentBrowserToolStatus): string { + const hash = crypto.createHash('sha256'); + hash.update(path.resolve(status.stateDir)); + hash.update('\0'); + hash.update(path.resolve(status.installDir)); + return `${AGENT_DEVICE_BROWSER_MARKER_PREFIX}${hash.digest('hex').slice(0, 16)}`; +} + +export function appendAgentDeviceChromeArgs( + existingArgs: string | undefined, + status: AgentBrowserToolStatus, +): string { + const marker = agentBrowserChromeLaunchMarker(status); + const existing = existingArgs?.trim(); + if (!existing) return marker; + if (splitAgentBrowserArgs(existing).includes(marker)) return existing; + return `${existing},${marker}`; +} + +export function resolveAgentBrowserIdleTimeoutMs(env: NodeJS.ProcessEnv): number { + return ( + readPositiveInteger(env.AGENT_BROWSER_IDLE_TIMEOUT_MS) ?? + readPositiveInteger(env.AGENT_DEVICE_WEB_IDLE_TIMEOUT_MS) ?? + DEFAULT_AGENT_BROWSER_IDLE_TIMEOUT_MS + ); +} + +export function parseHostProcessList(stdout: string): HostProcessInfo[] { + const processes: HostProcessInfo[] = []; + for (const line of stdout.split('\n')) { + const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/.exec(line); + if (!match) continue; + const pid = Number.parseInt(match[1]!, 10); + const ppid = Number.parseInt(match[2]!, 10); + const command = match[3]!; + if (!Number.isInteger(pid) || pid <= 0) continue; + processes.push({ pid, ppid: Number.isInteger(ppid) && ppid > 0 ? ppid : undefined, command }); + } + return processes; +} + +export function matchAgentBrowserChromeProcess( + processInfo: HostProcessInfo, + status: AgentBrowserToolStatus, +): AgentBrowserProcessMatch | undefined { + if (processInfo.pid === process.pid) return undefined; + if (!isChromeLikeCommand(processInfo.command)) return undefined; + if (processInfo.command.includes(agentBrowserChromeLaunchMarker(status))) { + return { process: processInfo, reason: 'launch-marker' }; + } + if (managedBrowserHomeMarkers(status).some((marker) => isCommandUnderPath(processInfo, marker))) { + return { process: processInfo, reason: 'managed-browser-home' }; + } + return undefined; +} + +export function summarizeAgentBrowserProcesses( + processes: HostProcessInfo[], + status: AgentBrowserToolStatus, +): AgentBrowserProcessSummary { + const matches = processes.flatMap((processInfo) => { + const match = matchAgentBrowserChromeProcess(processInfo, status); + return match ? [match] : []; + }); + return { + count: matches.length, + pids: matches.map((match) => match.process.pid), + processes: matches, + }; +} + +export async function inspectManagedAgentBrowserProcesses( + status: AgentBrowserToolStatus, +): Promise { + const processes = await listHostProcesses(); + return summarizeAgentBrowserProcesses(processes, status); +} + +export async function cleanupManagedAgentBrowserOrphans( + status: AgentBrowserToolStatus, + reason: 'daemon-startup' | 'provider-startup', + options: AgentBrowserCleanupOptions = {}, +): Promise { + const openWebSessionNames = uniqueStrings([...(options.openWebSessionNames ?? [])]); + if (openWebSessionNames.length > 0) { + return skippedCleanupResult('open-web-session', { openWebSessionNames }); + } + + const idleTimeoutMs = resolveAgentBrowserIdleTimeoutMs(process.env); + const latestActivityMs = readLatestManagedBrowserActivityMs(status); + if (latestActivityMs !== undefined && Date.now() - latestActivityMs < idleTimeoutMs) { + return skippedCleanupResult('recent-browser-activity', { idleTimeoutMs, latestActivityMs }); + } + + const processes = await listHostProcesses(); + const summary = summarizeAgentBrowserProcesses(processes, status); + const signalPids = expandProcessTree(summary.processes, processes).map( + (processInfo) => processInfo.pid, + ); + const result = { ...summary, signalPids }; + if (summary.count === 0) return result; + emitDiagnostic({ + level: 'warn', + phase: 'web_agent_browser_orphan_cleanup', + data: { + reason, + count: summary.count, + pids: summary.pids, + signalPids, + stateDir: status.stateDir, + installDir: status.installDir, + matchReasons: summary.processes.map((match) => match.reason), + }, + }); + await stopPids(signalPids); + return result; +} + +export async function cleanupManagedAgentBrowserOrphansForProviderStartup( + status: AgentBrowserToolStatus, + options: AgentBrowserCleanupOptions = {}, +): Promise { + const now = Date.now(); + const key = path.resolve(status.stateDir); + const lastAttemptMs = providerStartupCleanupAttempts.get(key); + if (lastAttemptMs !== undefined && now - lastAttemptMs < PROVIDER_STARTUP_CLEANUP_DEBOUNCE_MS) { + return undefined; + } + providerStartupCleanupAttempts.set(key, now); + return await cleanupManagedAgentBrowserOrphans(status, 'provider-startup', options); +} + +export function expandProcessTree( + matches: AgentBrowserProcessMatch[], + processes: HostProcessInfo[], +): HostProcessInfo[] { + const selected = new Set(matches.map((match) => match.process.pid)); + let changed = true; + while (changed) { + changed = false; + for (const processInfo of processes) { + if (processInfo.ppid === undefined || !selected.has(processInfo.ppid)) continue; + if (selected.has(processInfo.pid)) continue; + selected.add(processInfo.pid); + changed = true; + } + } + return processes.filter((processInfo) => selected.has(processInfo.pid)); +} + +async function listHostProcesses(): Promise { + const result = await withoutCommandExecutorOverride( + async () => + await runCmd('ps', ['-ax', '-o', 'pid=,ppid=,command='], { + allowFailure: true, + timeoutMs: HOST_PROCESS_LIST_TIMEOUT_MS, + }), + ); + if (result.exitCode !== 0) return []; + return parseHostProcessList(result.stdout); +} + +async function stopPids(pidsToStop: number[]): Promise { + const pids = uniquePids(pidsToStop); + for (const pid of pids) { + signalProcess(pid, 'SIGTERM'); + } + await sleep(WEB_BROWSER_REAP_TERM_TIMEOUT_MS); + for (const pid of pids) { + if (!isProcessAlive(pid)) continue; + signalProcess(pid, 'SIGKILL'); + } + await Promise.all( + pids.map(async (pid) => await waitForProcessExit(pid, WEB_BROWSER_REAP_KILL_TIMEOUT_MS)), + ); +} + +function signalProcess(pid: number, signal: NodeJS.Signals): void { + try { + process.kill(pid, signal); + } catch {} +} + +function uniquePids(pids: number[]): number[] { + return [...new Set(pids.filter((pid) => Number.isInteger(pid) && pid > 0))]; +} + +function splitAgentBrowserArgs(args: string): string[] { + return args + .split(/[,\n]/) + .map((arg) => arg.trim()) + .filter(Boolean); +} + +function readPositiveInteger(value: string | undefined): number | undefined { + if (value === undefined || value.trim() === '') return undefined; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed > 0 ? parsed : undefined; +} + +function isChromeLikeCommand(command: string): boolean { + return /\b(?:Google Chrome for Testing|Chrome for Testing|Chromium|chrome|chrome\.exe|chromium|chromium-browser|headless_shell)\b/i.test( + command, + ); +} + +function managedBrowserHomeMarkers(status: AgentBrowserToolStatus): string[] { + return uniqueStrings([ + path.join(status.homeDir, '.agent-browser', 'browsers'), + path.join(status.runtimeHomeDir, '.agent-browser', 'browsers'), + ]); +} + +function uniqueStrings(values: string[]): string[] { + return [...new Set(values.filter((value) => value.length > 0))]; +} + +function skippedCleanupResult( + reason: AgentBrowserCleanupSkipReason, + details: Omit, 'reason'>, +): AgentBrowserCleanupResult { + return { + count: 0, + pids: [], + processes: [], + signalPids: [], + skipped: { reason, ...details }, + }; +} + +function isCommandUnderPath(processInfo: HostProcessInfo, rootPath: string): boolean { + const command = normalizePathSeparators(processInfo.command); + const root = `${normalizePathSeparators(path.resolve(rootPath)).replace(/\/+$/, '')}/`; + return command.includes(root); +} + +function normalizePathSeparators(value: string): string { + return value.replace(/\\/g, '/'); +} + +function readLatestManagedBrowserActivityMs(status: AgentBrowserToolStatus): number | undefined { + const mtimes = readDirectoryEntries(status.socketDir) + .map((entryPath) => readPathMtimeMs(entryPath)) + .filter((mtimeMs): mtimeMs is number => mtimeMs !== undefined); + return mtimes.length > 0 ? Math.max(...mtimes) : undefined; +} + +function readDirectoryEntries(dirPath: string): string[] { + try { + return fs.readdirSync(dirPath).map((entry) => path.join(dirPath, entry)); + } catch { + return []; + } +} + +function readPathMtimeMs(filePath: string): number | undefined { + try { + return fs.statSync(filePath).mtimeMs; + } catch { + return undefined; + } +} diff --git a/src/platforms/web/agent-browser-provider.test.ts b/src/platforms/web/agent-browser-provider.test.ts index 762a143f3..6f5954dbb 100644 --- a/src/platforms/web/agent-browser-provider.test.ts +++ b/src/platforms/web/agent-browser-provider.test.ts @@ -2,7 +2,20 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { test } from 'vitest'; +import { beforeEach, test, vi } from 'vitest'; + +const { providerStartupCleanupMock } = vi.hoisted(() => ({ + providerStartupCleanupMock: vi.fn(), +})); + +vi.mock('./agent-browser-lifecycle.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cleanupManagedAgentBrowserOrphansForProviderStartup: providerStartupCleanupMock, + }; +}); + import { createAgentBrowserWebProvider } from './agent-browser-provider.ts'; import type { WebSnapshotResult } from './provider.ts'; import { withCommandExecutorOverride, type ExecResult } from '../../utils/exec.ts'; @@ -20,6 +33,12 @@ type AgentBrowserCall = { args: string[]; }; +const mockProviderStartupCleanup = vi.mocked(providerStartupCleanupMock); + +beforeEach(() => { + mockProviderStartupCleanup.mockReset(); +}); + test('agent-browser provider maps supported operations to session-scoped JSON commands', async () => { await withManagedAgentBrowserProvider({ session: 'web-session' }, async (provider) => { const calls: AgentBrowserCall[] = []; @@ -73,6 +92,52 @@ test('agent-browser provider maps supported operations to session-scoped JSON co }); }); +test('agent-browser provider runs provider-startup cleanup before the first managed command', async () => { + const events: string[] = []; + mockProviderStartupCleanup.mockImplementation(async () => { + events.push('provider-startup-cleanup'); + }); + + await withManagedAgentBrowserProvider( + { session: 'web-session', openWebSessionNames: () => [] }, + async (provider) => { + await withCommandExecutorOverride( + async (cmd, args) => { + events.push(`${path.basename(cmd)} ${args[0] ?? ''}`); + return jsonResult({ success: true, data: {} }); + }, + async () => await provider.open('https://example.test'), + ); + }, + ); + + assert.deepEqual(events, ['provider-startup-cleanup', 'agent-browser open']); + assert.equal(mockProviderStartupCleanup.mock.calls.length, 1); + assert.deepEqual(mockProviderStartupCleanup.mock.calls[0]?.[1], { + openWebSessionNames: [], + }); +}); + +test('agent-browser provider ignores provider-startup cleanup failures', async () => { + const calls: AgentBrowserCall[] = []; + mockProviderStartupCleanup.mockRejectedValue(new Error('ps failed')); + + await withManagedAgentBrowserProvider( + { session: 'web-session', openWebSessionNames: () => [] }, + async (provider) => { + await withCommandExecutorOverride(recordingExecutor(calls), async () => { + await provider.open('https://example.test'); + }); + }, + ); + + assert.deepEqual( + calls.map((call) => call.args), + [['open', 'https://example.test', '--json', '--session', 'web-session']], + ); + assert.equal(mockProviderStartupCleanup.mock.calls.length, 1); +}); + test('agent-browser provider normalizes snapshot refs, labels, values, and parents', async () => { await withManagedAgentBrowserProvider({ session: 'web-session' }, async (provider) => { const calls: AgentBrowserCall[] = []; @@ -396,7 +461,7 @@ test('agent-browser provider preserves Node version guidance for missing managed }); async function withManagedAgentBrowserProvider( - options: { session?: string }, + options: { session?: string; openWebSessionNames?: () => readonly string[] }, testFn: (provider: ReturnType) => void | Promise, ): Promise { const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-web-provider-')); diff --git a/src/platforms/web/agent-browser-provider.ts b/src/platforms/web/agent-browser-provider.ts index 704c729a7..7cdca2876 100644 --- a/src/platforms/web/agent-browser-provider.ts +++ b/src/platforms/web/agent-browser-provider.ts @@ -1,5 +1,6 @@ import { execFailureDetails, runCmd } from '../../utils/exec.ts'; import { AppError } from '../../kernel/errors.ts'; +import { emitDiagnostic } from '../../utils/diagnostics.ts'; import { sleep } from '../../utils/timeouts.ts'; import type { Rect } from '../../kernel/snapshot.ts'; import { @@ -15,7 +16,12 @@ import { type JsonObject, } from './json-utils.ts'; import type { WebProvider, WebSnapshotOptions, WebSnapshotResult } from './provider.ts'; -import { mapManagedAgentBrowserError, resolveAgentBrowserTool } from './agent-browser-tool.ts'; +import { + getManagedAgentBrowserStatus, + mapManagedAgentBrowserError, + resolveAgentBrowserTool, +} from './agent-browser-tool.ts'; +import { cleanupManagedAgentBrowserOrphansForProviderStartup } from './agent-browser-lifecycle.ts'; const AGENT_BROWSER = 'agent-browser'; const AGENT_BROWSER_TIMEOUT_MS = 30_000; @@ -25,6 +31,7 @@ const AGENT_BROWSER_DOCTOR_HINT = type AgentBrowserProviderOptions = { session?: string; stateDir?: string; + openWebSessionNames?: () => readonly string[]; }; export function createAgentBrowserWebProvider( @@ -220,6 +227,7 @@ async function runAgentBrowserCommand( let stderr = ''; let exitCode = 0; try { + await cleanupProviderStartupOrphans(options); const tool = await resolveAgentBrowserTool({ stateDir: options.stateDir }); const result = await runCmd(tool.command, cliArgs, { allowFailure: true, @@ -236,6 +244,23 @@ async function runAgentBrowserCommand( return { stdout, stderr, exitCode }; } +async function cleanupProviderStartupOrphans(options: AgentBrowserProviderOptions): Promise { + if (!options.openWebSessionNames) return; + const status = getManagedAgentBrowserStatus({ stateDir: options.stateDir }); + if (!status.installed) return; + try { + await cleanupManagedAgentBrowserOrphansForProviderStartup(status, { + openWebSessionNames: options.openWebSessionNames(), + }); + } catch (error) { + emitDiagnostic({ + level: 'warn', + phase: 'web_agent_browser_provider_orphan_cleanup_failed', + data: { error: error instanceof Error ? error.message : String(error) }, + }); + } +} + function unwrapAgentBrowserJson( parsed: unknown, result: { stdout: string; stderr: string; exitCode: number }, diff --git a/src/platforms/web/agent-browser-tool.test.ts b/src/platforms/web/agent-browser-tool.test.ts index 91522f3f2..ea25029d6 100644 --- a/src/platforms/web/agent-browser-tool.test.ts +++ b/src/platforms/web/agent-browser-tool.test.ts @@ -33,6 +33,8 @@ test('managed agent-browser tool uses short runtime home for backend state', asy assert.equal(tool.command, status.binaryPath); assert.equal(tool.env?.HOME, status.runtimeHomeDir); assert.equal(tool.env?.AGENT_BROWSER_SOCKET_DIR, status.socketDir); + assert.equal(tool.env?.AGENT_BROWSER_IDLE_TIMEOUT_MS, '300000'); + assert.match(tool.env?.AGENT_BROWSER_ARGS ?? '', /^--agent-device-managed-web=[a-f0-9]{16}$/); assert.notEqual(status.runtimeHomeDir, status.homeDir); assert.ok(fs.existsSync(status.runtimeHomeDir)); assert.ok(fs.existsSync(status.socketDir)); diff --git a/src/platforms/web/agent-browser-tool.ts b/src/platforms/web/agent-browser-tool.ts index 09ca8979e..e8884cb83 100644 --- a/src/platforms/web/agent-browser-tool.ts +++ b/src/platforms/web/agent-browser-tool.ts @@ -6,6 +6,10 @@ import { runCmd } from '../../utils/exec.ts'; import { AppError, asAppError } from '../../kernel/errors.ts'; import { acquireProcessLock } from '../../utils/process-lock.ts'; import { readProcessStartTime } from '../../utils/process-identity.ts'; +import { + appendAgentDeviceChromeArgs, + resolveAgentBrowserIdleTimeoutMs, +} from './agent-browser-lifecycle.ts'; const MANAGED_AGENT_BROWSER_VERSION = '0.27.1'; @@ -89,7 +93,9 @@ export async function doctorManagedAgentBrowser(options: { return { status, ...result }; } -function getManagedAgentBrowserStatus(options: { stateDir?: string }): AgentBrowserToolStatus { +export function getManagedAgentBrowserStatus(options: { + stateDir?: string; +}): AgentBrowserToolStatus { const stateDir = options.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR ?? defaultStateDir(); const installDir = path.join(stateDir, 'tools', 'agent-browser', MANAGED_AGENT_BROWSER_VERSION); const binaryPath = resolveManagedBinaryPath(installDir); @@ -161,6 +167,8 @@ function managedAgentBrowserEnv( ...env, HOME: status.runtimeHomeDir, AGENT_BROWSER_SOCKET_DIR: status.socketDir, + AGENT_BROWSER_IDLE_TIMEOUT_MS: String(resolveAgentBrowserIdleTimeoutMs(env)), + AGENT_BROWSER_ARGS: appendAgentDeviceChromeArgs(env.AGENT_BROWSER_ARGS, status), }; }