From 04f53bbc04a6e6379e9d845e39b336e6601fa860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 07:59:47 +0200 Subject: [PATCH 1/3] docs: clarify agent-device help entrypoint --- src/utils/__tests__/args.test.ts | 47 +++++++++++++++++++++++---- src/utils/cli-help.ts | 56 ++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index e3a90cac7..95349d3ce 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -1177,10 +1177,35 @@ test('usage includes only global flags in the top-level global flags section', ( test('usage includes agent workflows, config, environment, and examples footers', () => { const usageText = usage(); + assert.match( + usageText, + /CLI to automate supported app, device, desktop, and web targets for AI agents/, + ); assert.ok( usageText.indexOf('Agent Workflows:') < usageText.indexOf('Commands:'), 'Agent workflows should appear before the command list for agents that only read the top of help.', ); + assert.ok( + usageText.indexOf('Agent Starting Point:') < usageText.indexOf('Agent Workflows:'), + 'The agent starting point should appear before topic selection.', + ); + assert.match(usageText, /Agent Starting Point:/); + assert.match( + usageText, + /agent-device is the default automation surface for app\/device workflows across supported targets/, + ); + assert.match( + usageText, + /Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network\/perf evidence, and verification/, + ); + assert.match( + usageText, + /Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step/, + ); + assert.match( + usageText, + /Start with agent-device help workflow to understand the core loop and how to use the tool/, + ); assert.match(usageText, /Agent Quickstart:/); assert.match(usageText, /Default loop: devices\/apps -> open -> snapshot -i/); assert.match(usageText, /Use selectors or refs as positional targets/); @@ -1225,21 +1250,30 @@ test('usage includes agent workflows, config, environment, and examples footers' assert.match(usageText, /Full operating guide: agent-device help workflow/); assert.match(usageText, /Exploratory QA: agent-device help dogfood/); assert.match(usageText, /Agent Workflows:/); - assert.match(usageText, /help workflow\s+Normal bootstrap, exploration, and validation loop/); - assert.match(usageText, /help debugging\s+Logs, network, perf memory, and traces/); assert.match( usageText, - /help react-devtools\s+React Native performance, profiling, component tree, and renders/, + /agent-device help workflow\s+Start here for the core loop, command shape, refs\/selectors, and verification/, + ); + assert.match( + usageText, + /agent-device help debugging\s+Use when logs, network, perf memory, traces, alerts, or diagnostics matter/, + ); + assert.match( + usageText, + /agent-device help react-devtools\s+Use when inspecting components, props\/state\/hooks, renders, or profiles/, + ); + assert.match( + usageText, + /agent-device help physical-device\s+Use when using a connected phone\/tablet or iOS signing setup/, ); assert.match( usageText, - /help physical-device\s+Connected phone\/tablet setup and iOS signing prerequisites/, + /agent-device help react-native\s+Use when the target app is React Native, Expo, or a dev client/, ); assert.match( usageText, - /help react-native\s+React Native app automation hazards, overlays, Metro, and routing/, + /agent-device help web\s+Use when automating a browser through agent-device sessions/, ); - assert.match(usageText, /help web\s+Minimal browser sessions through agent-browser/); assert.match(usageText, /Configuration:/); assert.match( usageText, @@ -1663,6 +1697,7 @@ test('usageForCommand resolves react-native help topic', () => { assert.match(help, /help debugging/); assert.match(help, /help react-devtools/); assert.match(help, /Help workflow owns the full Expo URL command shapes/); + assert.match(help, /For app\/package launches, run metro prepare/); assert.match(help, /same host context that owns Metro/); assert.match(help, /sandbox probe is not authoritative/); assert.match(help, /adb reverse only affects Android device-to-host traffic/); diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 0f70280b0..7b5117d63 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -10,31 +10,50 @@ import { } from './command-schema.ts'; const AGENT_WORKFLOWS = [ - { label: 'help workflow', description: 'Normal bootstrap, exploration, and validation loop' }, - { label: 'help debugging', description: 'Logs, network, perf memory, and traces' }, { - label: 'help react-native', - description: 'React Native app automation hazards, overlays, Metro, and routing', + label: 'agent-device help workflow', + description: 'Start here for the core loop, command shape, refs/selectors, and verification', }, { - label: 'help react-devtools', - description: 'React Native performance, profiling, component tree, and renders', + label: 'agent-device help debugging', + description: 'Use when logs, network, perf memory, traces, alerts, or diagnostics matter', }, { - label: 'help cdp', - description: 'React Native CDP targets, JS heap snapshots, and leak triage', + label: 'agent-device help react-native', + description: 'Use when the target app is React Native, Expo, or a dev client', }, { - label: 'help physical-device', - description: 'Connected phone/tablet setup and iOS signing prerequisites', + label: 'agent-device help react-devtools', + description: 'Use when inspecting components, props/state/hooks, renders, or profiles', }, { - label: 'help remote', - description: 'Remote/cloud config, tenants, leases, and local service tunnels', + label: 'agent-device help cdp', + description: 'Use when investigating JS heap growth, heap snapshots, or retainers', }, - { label: 'help web', description: 'Minimal browser sessions through agent-browser' }, - { label: 'help macos', description: 'Desktop, frontmost-app, and menu bar surfaces' }, - { label: 'help dogfood', description: 'Exploratory QA report workflow' }, + { + label: 'agent-device help physical-device', + description: 'Use when using a connected phone/tablet or iOS signing setup', + }, + { + label: 'agent-device help remote', + description: 'Use when working through cloud config, tenants, leases, or local tunnels', + }, + { + label: 'agent-device help web', + description: 'Use when automating a browser through agent-device sessions', + }, + { + label: 'agent-device help macos', + description: 'Use when targeting desktop, frontmost app, or menu bar surfaces', + }, + { label: 'agent-device help dogfood', description: 'Use when producing exploratory QA evidence' }, +] as const; + +const AGENT_START_LINES = [ + 'agent-device is the default automation surface for app/device workflows across supported targets.', + 'Default to agent-device for installs, opens, snapshots, interactions, screenshots, logs, network/perf evidence, and verification.', + 'Use raw adb, simctl, xcrun, or platform scripts only when this help calls out a tool gap or platform setup step.', + 'Start with agent-device help workflow to understand the core loop and how to use the tool.', ] as const; const AGENT_QUICKSTART_LINES = [ @@ -536,7 +555,7 @@ React Native dev loop: agent-device metro reload agent-device find "Home" Do not use agent-device reload. Use open --relaunch for native startup reset. - Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, use help react-native if the app cannot reach local Metro. + Android RN/Expo Metro: direct Android localhost URL opens with a port auto-configure host reachability. For app/package launches, run metro prepare when the app cannot reach local Metro. Verify Metro from the same host context that owns Metro. If a sandboxed shell cannot curl localhost:8081/status but an unrestricted host shell can, Metro is running and the sandbox probe is not authoritative. adb reverse only affects Android device-to-host traffic. It does not prove host-to-Metro reachability, and it does not fix a redbox caused by a stale or wrong Metro/app state. Multiple local worktrees can reuse one native iOS simulator build by running each worktree's Metro on a different port and opening the same installed app on different simulators with explicit runtime hints: @@ -863,7 +882,7 @@ function buildCommandListUsage(commandName: string, schema: CommandSchema): stri function renderUsageText(): string { const header = `agent-device [args] [--json] -CLI to control iOS and Android devices for AI agents. +CLI to automate supported app, device, desktop, and web targets for AI agents. `; const commands = listCliCommandNames().map((name) => { @@ -878,6 +897,7 @@ CLI to control iOS and Android devices for AI agents. const helpFlags = listHelpFlags(GLOBAL_FLAG_KEYS); const flagsSection = renderFlagSection('Global Flags:', helpFlags); + const startSection = renderTextSection('Agent Starting Point:', AGENT_START_LINES); const quickstartSection = renderTextSection('Agent Quickstart:', AGENT_QUICKSTART_LINES); const workflowsSection = renderAlignedSection('Agent Workflows:', AGENT_WORKFLOWS); const configSection = renderTextSection('Configuration:', CONFIGURATION_LINES); @@ -885,6 +905,8 @@ CLI to control iOS and Android devices for AI agents. const examplesSection = renderTextSection('Examples:', EXAMPLE_LINES); return `${header} +${startSection} + ${workflowsSection} ${commandLines} From c593ecb04d1a8c835b9e096c8a71c3a1f73dc5c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:10:03 +0200 Subject: [PATCH 2/3] docs: trim duplicated help guidance --- src/utils/cli-help.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/utils/cli-help.ts b/src/utils/cli-help.ts index 7b5117d63..3898d0e7a 100644 --- a/src/utils/cli-help.ts +++ b/src/utils/cli-help.ts @@ -88,7 +88,6 @@ const AGENT_QUICKSTART_LINES = [ 'Web browser sessions: read help web; first slice is web setup if needed -> web doctor -> open --platform web -> snapshot -i -> click/fill/get/is/find/wait/screenshot -> close.', 'Verification commands must name the expected text/selector; bare screenshots/snapshots are not enough.', 'Debug evidence: Session state contains request diagnostics and runner.log; use logs clear --restart/mark/path, trace, and network dump --include headers for app evidence.', - 'Use agent-device commands in final plans; raw platform tools, pseudo commands, and helper prose are wrong.', 'Full operating guide: agent-device help workflow. Exploratory QA: agent-device help dogfood.', ] as const; From e753aeaf654f3fafda5eba74fbda68f5e1dae0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 26 Jun 2026 12:51:31 +0200 Subject: [PATCH 3/3] fix: enforce device leases for daemon sessions --- .../__tests__/request-execution-scope.test.ts | 175 ++++++++++++++++++ .../__tests__/request-router-open.test.ts | 138 +++++++++++++- src/daemon/__tests__/session-store.test.ts | 26 +++ src/daemon/handlers/session-close.ts | 24 ++- src/daemon/handlers/session-open.ts | 3 + src/daemon/handlers/session.ts | 4 + src/daemon/lease-context.ts | 91 +++++++++ src/daemon/lease-registry.ts | 32 +++- src/daemon/request-admission.ts | 79 +++++++- src/daemon/request-execution-scope.ts | 51 ++++- src/daemon/request-handler-chain.ts | 1 + src/daemon/types.ts | 13 ++ 12 files changed, 614 insertions(+), 23 deletions(-) diff --git a/src/daemon/__tests__/request-execution-scope.test.ts b/src/daemon/__tests__/request-execution-scope.test.ts index fa1749aaf..304d7ded2 100644 --- a/src/daemon/__tests__/request-execution-scope.test.ts +++ b/src/daemon/__tests__/request-execution-scope.test.ts @@ -6,7 +6,9 @@ import { flushDiagnosticsToSessionFile, withDiagnosticsScope } from '../../utils import { makeAndroidSession, makeIosSession, + makeSession, } from '../../__tests__/test-utils/session-factories.ts'; +import { LINUX_DEVICE } from '../../__tests__/test-utils/device-fixtures.ts'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { LeaseRegistry } from '../lease-registry.ts'; import { clearRequestCanceled, markRequestCanceled } from '../request-cancel.ts'; @@ -114,6 +116,179 @@ test('createRequestExecutionScope rejects tenant requests without an active leas ).rejects.toThrow(/Lease is not active/); }); +test('leased session admission uses stored lease metadata and heartbeats', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ now: () => now }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:sim-1', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 2_000; + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(scope.sessionName).toBe('default'); + const activeLease = leaseRegistry.listActiveLeases()[0]; + expect(activeLease?.heartbeatAt).toBe(2_000); + expect(activeLease?.expiresAt).toBe(302_000); + expect(sessionStore.get('default')?.lease?.expiresAt).toBe(302_000); +}); + +test('leased session rejects mismatched lease id before dispatch', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + }, + }), + ); + + await expect( + createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta: { leaseId: '1'.repeat(32) } }), + sessionStore, + leaseRegistry, + }), + ).rejects.toThrow(/Lease does not match session owner \(leaseId\)/); +}); + +test.each([ + ['leaseProvider', { leaseProvider: 'cloud' }], + ['clientId', { clientId: 'client-b' }], + ['deviceKey', { deviceKey: 'ios:SIM-002' }], +] as const)('leased session rejects mismatched %s before dispatch', async (_field, meta) => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeIosSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-001', + }, + }), + ); + + await expect( + createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot', meta }), + sessionStore, + leaseRegistry, + }), + ).rejects.toThrow(/Lease does not match session owner/); +}); + +test('local unleased session admission still succeeds', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + sessionStore.set('default', makeIosSession('default')); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry: new LeaseRegistry(), + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('provider lease admission succeeds without a device key', async () => { + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'android-instance', + }); + sessionStore.set( + 'default', + makeAndroidSession('default', { + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + leaseProvider: 'limrun', + }, + }), + ); + + const scope = await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(scope.sessionName).toBe('default'); +}); + +test('expired leases remove owned sessions before the next command and free capacity', async () => { + let now = 1_000; + const sessionStore = makeSessionStore('agent-device-request-scope-'); + const leaseRegistry = new LeaseRegistry({ + maxActiveSimulatorLeases: 1, + defaultLeaseTtlMs: 10, + minLeaseTtlMs: 1, + now: () => now, + }); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set( + 'default', + makeSession('default', { + device: LINUX_DEVICE, + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: lease.expiresAt, + }, + }), + ); + now = 1_011; + + await createRequestExecutionScope({ + req: makeRequest({ command: 'snapshot' }), + sessionStore, + leaseRegistry, + }); + + expect(sessionStore.get('default')).toBeUndefined(); + const nextLease = leaseRegistry.allocateLease({ tenantId: 'tenant-b', runId: 'run-2' }); + expect(nextLease.tenantId).toBe('tenant-b'); +}); + test('tenant lease rejection flushes diagnostics into the effective session request log', async () => { const sessionStore = makeSessionStore('agent-device-request-scope-'); const requestId = 'tenant-lease-rejection'; diff --git a/src/daemon/__tests__/request-router-open.test.ts b/src/daemon/__tests__/request-router-open.test.ts index 4d129dd8e..2cb032989 100644 --- a/src/daemon/__tests__/request-router-open.test.ts +++ b/src/daemon/__tests__/request-router-open.test.ts @@ -29,24 +29,32 @@ function makeIosDevice(id: string): DeviceInfo { }; } -function createOpenHandler(sessionStore: ReturnType) { +function createOpenHandler( + sessionStore: ReturnType, + leaseRegistry = new LeaseRegistry(), +) { return createRequestHandler({ logPath: path.join(os.tmpdir(), 'daemon.log'), token: 'test-token', sessionStore, - leaseRegistry: new LeaseRegistry(), + leaseRegistry, trackDownloadableArtifact: () => 'artifact-id', }); } -function openRequest(session: string, flags: Record, requestId: string) { +function openRequest( + session: string, + flags: Record, + requestId: string, + meta: Record = {}, +) { return { token: 'test-token', session, command: 'open', positionals: [], flags, - meta: { requestId }, + meta: { requestId, ...meta }, }; } @@ -82,6 +90,128 @@ test('open returns and creates the session state directory', async () => { } }); +test('open stores admitted lease metadata on the session', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + const device = makeIosDevice('SIM-LEASED'); + mockResolveTargetDevice.mockResolvedValue(device); + + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-lease', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseId: lease.leaseId, + sessionIsolation: 'tenant', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + leaseBackend: 'ios-simulator', + }), + ); + + expect(response.ok).toBe(true); + expect(sessionStore.get('tenant-a:default')?.lease).toEqual({ + leaseId: lease.leaseId, + tenantId: 'tenant-a', + runId: 'run-1', + backend: 'ios-simulator', + leaseProvider: 'proxy', + clientId: 'client-a', + deviceKey: 'ios:SIM-LEASED', + expiresAt: undefined, + }); +}); + +test('proxy open without required lease metadata fails before device resolution', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const handler = createOpenHandler(sessionStore, new LeaseRegistry()); + + const response = await handler( + openRequest('default', { platform: 'ios' }, 'req-open-proxy-missing', { + tenantId: 'tenant-a', + runId: 'run-1', + leaseProvider: 'proxy', + sessionIsolation: 'tenant', + }), + ); + + expect(response.ok).toBe(false); + if (!response.ok) { + expect(response.error.code).toBe('INVALID_ARGS'); + expect(response.error.message).toMatch(/Proxy open requires leaseId/); + } + expect(mockResolveTargetDevice).not.toHaveBeenCalled(); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + +test('close releases the session lease', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-lease' }, + }); + + expect(response.ok).toBe(true); + expect(sessionStore.get('default')).toBeUndefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(0); +}); + +test('close rejects a different client before cleanup', async () => { + const sessionStore = makeSessionStore('agent-device-router-open-'); + const leaseRegistry = new LeaseRegistry(); + const lease = leaseRegistry.allocateLease({ tenantId: 'tenant-a', runId: 'run-1' }); + sessionStore.set('default', { + name: 'default', + device: makeIosDevice('SIM-CLOSE-CLIENT'), + createdAt: Date.now(), + actions: [], + lease: { + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + backend: lease.backend, + clientId: 'client-a', + }, + }); + const handler = createOpenHandler(sessionStore, leaseRegistry); + + const response = await handler({ + token: 'test-token', + session: 'default', + command: 'close', + positionals: [], + meta: { requestId: 'req-close-wrong-client', clientId: 'client-b' }, + }); + + expect(response.ok).toBe(false); + expect(sessionStore.get('default')).toBeDefined(); + expect(leaseRegistry.listActiveLeases()).toHaveLength(1); + expect(mockDispatch).not.toHaveBeenCalled(); +}); + test('router serializes same-device open requests before first session creation finishes', async () => { const sessionStore = makeSessionStore('agent-device-router-open-'); const sameDevice = makeIosDevice('SIM-001'); diff --git a/src/daemon/__tests__/session-store.test.ts b/src/daemon/__tests__/session-store.test.ts index c7eacdfb1..cd37d54fa 100644 --- a/src/daemon/__tests__/session-store.test.ts +++ b/src/daemon/__tests__/session-store.test.ts @@ -108,6 +108,32 @@ test('defaultTracePath sanitizes session name', () => { assert.match(tracePath, /\.trace\.log$/); }); +test('session lease metadata round-trips through the store', () => { + const { store, session } = makeFixture('agent-device-session-lease-'); + session.lease = { + leaseId: 'f'.repeat(32), + tenantId: 'tenant-a', + runId: 'run-1', + clientId: 'client-a', + backend: 'ios-simulator', + leaseProvider: 'proxy', + deviceKey: 'ios:SIM-001', + expiresAt: 123_456, + }; + + store.set(session.name, session); + + assert.deepEqual(store.get(session.name)?.lease, session.lease); +}); + +test('sessions without lease metadata remain valid', () => { + const { store, session } = makeFixture('agent-device-session-unleased-'); + + store.set(session.name, session); + + assert.equal(store.get(session.name)?.lease, undefined); +}); + test('saveScript flag enables .ad session log writing', () => { const { root, store, session } = makeFixture('agent-device-session-log-enabled-'); recordOpen(store, session); diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index e42430216..5b768e20b 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -25,6 +25,7 @@ import { settleIosSimulator, } from './session-device-utils.ts'; import { errorResponse } from './response.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; async function maybeShutdownSessionTarget(params: { device: DeviceInfo; @@ -107,8 +108,9 @@ export async function handleCloseCommand(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry?: LeaseRegistry; }): Promise { - const { req, sessionName, logPath, sessionStore } = params; + const { req, sessionName, logPath, sessionStore, leaseRegistry = new LeaseRegistry() } = params; const session = sessionStore.get(sessionName); if (!session) { return await closeWithoutSession(req, logPath); @@ -163,6 +165,7 @@ export async function handleCloseCommand(params: { } sessionStore.writeSessionLog(session); await cleanupRetainedMaterializedPathsForSession(sessionName).catch(() => {}); + releaseSessionLease(session, leaseRegistry); sessionStore.delete(sessionName); const shutdownResult = await maybeShutdownSessionTarget({ device: session.device, @@ -180,6 +183,25 @@ export async function handleCloseCommand(params: { return { ok: true, data: { session: session.name, ...successText(`Closed: ${session.name}`) } }; } +function releaseSessionLease(session: SessionState, leaseRegistry: LeaseRegistry): void { + const lease = session.lease; + if (!lease) return; + const result = leaseRegistry.releaseLease({ + leaseId: lease.leaseId, + tenantId: lease.tenantId, + runId: lease.runId, + }); + emitDiagnostic({ + level: 'info', + phase: 'session_lease_released', + data: { + session: session.name, + leaseId: lease.leaseId, + released: result.released, + }, + }); +} + function shouldDispatchPlatformClose(req: DaemonRequest, session: SessionState): boolean { return hasCloseTarget(req) || session.device.platform === 'web'; } diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 2fd6e29e0..595a4dc9a 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -43,6 +43,7 @@ import { resolveImplicitSessionScope, resolvePublicSessionName, } from '../session-routing.ts'; +import { buildSessionLeaseFromRequest } from '../lease-context.ts'; const firstSessionOpenLocks = new Map>(); @@ -279,6 +280,7 @@ async function completeOpenCommand(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + nextSession.lease = buildSessionLeaseFromRequest(req) ?? existingSession?.lease; if (req.runtime !== undefined) { setSessionRuntimeHintsForOpen(sessionStore, sessionName, runtime); } @@ -347,6 +349,7 @@ async function prepareOpenDispatchSession(params: { appName, saveScript: Boolean(req.flags?.saveScript), }); + provisionalSession.lease = buildSessionLeaseFromRequest(req) ?? existingSession?.lease; sessionStore.set(sessionName, provisionalSession); const lifecycleResponse = await beforeDispatch(provisionalSession); if (lifecycleResponse && !lifecycleResponse.ok) { diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 9d30f903e..0dd9b174f 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -36,6 +36,7 @@ import { handleSessionStateCommands } from './session-state.ts'; import { handleSessionObservabilityCommands } from './session-observability.ts'; import { handleSessionReplayCommands } from './session-replay.ts'; import { getSessionCommandKind } from '../daemon-command-registry.ts'; +import { LeaseRegistry } from '../lease-registry.ts'; const PREPARE_IOS_RUNNER_MIN_STARTUP_TIMEOUT_MS = 45_000; const PREPARE_IOS_RUNNER_DEFAULT_BUILD_TIMEOUT_MS = 5 * 60_000; @@ -247,6 +248,7 @@ export async function handleSessionCommands(params: { sessionName: string; logPath: string; sessionStore: SessionStore; + leaseRegistry?: LeaseRegistry; invoke: DaemonInvokeFn; invokeReplayAction?: DaemonInvokeFn; androidAdbExecutor?: AndroidAdbExecutor; @@ -256,6 +258,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry = new LeaseRegistry(), invoke, invokeReplayAction, androidAdbExecutor, @@ -435,6 +438,7 @@ export async function handleSessionCommands(params: { sessionName, logPath, sessionStore, + leaseRegistry, }); } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 763e309c4..38ec8324d 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -1,5 +1,17 @@ import type { DaemonRequest } from './types.ts'; import type { LeaseBackend } from '../contracts.ts'; +import type { DeviceLease } from './lease-registry.ts'; +import type { SessionState } from './types.ts'; + +export const PROXY_LEASE_PROVIDER = 'proxy'; +export const DEFAULT_PROXY_LEASE_TTL_MS = 300_000; +export const REQUIRED_PROXY_LEASE_FIELDS = [ + 'leaseId', + 'tenantId', + 'runId', + 'clientId', + 'deviceKey', +] as const satisfies readonly (keyof LeaseScope)[]; export type LeaseScope = { tenantId?: string; @@ -7,6 +19,10 @@ export type LeaseScope = { leaseId?: string; leaseTtlMs?: number; leaseBackend?: LeaseBackend; + clientId?: string; + leaseProvider?: string; + deviceKey?: string; + expiresAt?: number; }; export function resolveLeaseScope(req: Pick): LeaseScope { @@ -16,5 +32,80 @@ export function resolveLeaseScope(req: Pick): L leaseId: req.meta?.leaseId ?? req.flags?.leaseId, leaseTtlMs: req.meta?.leaseTtlMs, leaseBackend: req.meta?.leaseBackend, + clientId: req.meta?.clientId, + leaseProvider: req.meta?.leaseProvider, + deviceKey: req.meta?.deviceKey, + }; +} + +export function resolveRequestOrSessionLeaseScope( + req: Pick, + session: SessionState | undefined, +): LeaseScope { + const requestLease = resolveLeaseScope(req); + const sessionLease = session?.lease; + if (requestLease.leaseId) { + return { + tenantId: requestLease.tenantId ?? sessionLease?.tenantId, + runId: requestLease.runId ?? sessionLease?.runId, + leaseId: requestLease.leaseId, + leaseTtlMs: requestLease.leaseTtlMs, + leaseBackend: + requestLease.leaseBackend ?? normalizeSessionLeaseBackend(sessionLease?.backend), + clientId: requestLease.clientId ?? sessionLease?.clientId, + leaseProvider: requestLease.leaseProvider ?? sessionLease?.leaseProvider, + deviceKey: requestLease.deviceKey ?? sessionLease?.deviceKey, + expiresAt: sessionLease?.expiresAt, + }; + } + if (!sessionLease) return requestLease; + return { + tenantId: sessionLease.tenantId, + runId: sessionLease.runId, + leaseId: sessionLease.leaseId, + leaseTtlMs: requestLease.leaseTtlMs, + leaseBackend: normalizeSessionLeaseBackend(sessionLease.backend), + clientId: sessionLease.clientId, + leaseProvider: sessionLease.leaseProvider, + deviceKey: sessionLease.deviceKey, + expiresAt: sessionLease.expiresAt, }; } + +export function buildSessionLeaseFromRequest( + req: Pick, + activeLease?: DeviceLease, +): SessionState['lease'] | undefined { + const scope = resolveLeaseScope(req); + if (!scope.leaseId && !activeLease?.leaseId) return undefined; + const leaseId = scope.leaseId ?? activeLease?.leaseId; + const tenantId = scope.tenantId ?? activeLease?.tenantId; + const runId = scope.runId ?? activeLease?.runId; + if (!leaseId || !tenantId || !runId) return undefined; + return { + leaseId, + tenantId, + runId, + clientId: scope.clientId, + backend: scope.leaseBackend ?? activeLease?.backend, + leaseProvider: scope.leaseProvider, + deviceKey: scope.deviceKey, + expiresAt: activeLease?.expiresAt, + }; +} + +export function isProxyLeaseScope(scope: LeaseScope): boolean { + return scope.leaseProvider === PROXY_LEASE_PROVIDER; +} + +export function findMissingProxyLeaseFields(scope: LeaseScope): string[] { + if (!isProxyLeaseScope(scope)) return []; + return REQUIRED_PROXY_LEASE_FIELDS.filter((field) => !scope[field]); +} + +function normalizeSessionLeaseBackend(raw: string | undefined): LeaseBackend | undefined { + if (raw === 'ios-simulator' || raw === 'ios-instance' || raw === 'android-instance') { + return raw; + } + return undefined; +} diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 3f5a4ba3f..7b11798a5 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -3,16 +3,21 @@ import { AppError } from '../utils/errors.ts'; import { normalizeTenantId } from './config.ts'; import type { LeaseBackend } from '../contracts.ts'; -export type SimulatorLease = { +export type DeviceLease = { leaseId: string; tenantId: string; runId: string; backend: LeaseBackend; + clientId?: string; + leaseProvider?: string; + deviceKey?: string; createdAt: number; heartbeatAt: number; expiresAt: number; }; +export type SimulatorLease = DeviceLease; + export type LeaseRegistryOptions = { maxActiveSimulatorLeases?: number; defaultLeaseTtlMs?: number; @@ -76,7 +81,7 @@ function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { } export class LeaseRegistry { - private readonly leases = new Map(); + private readonly leases = new Map(); private readonly runBindings = new Map(); private readonly maxActiveSimulatorLeases: number; private readonly defaultLeaseTtlMs: number; @@ -100,7 +105,7 @@ export class LeaseRegistry { this.now = options.now ?? (() => Date.now()); } - allocateLease(request: AllocateLeaseRequest): SimulatorLease { + allocateLease(request: AllocateLeaseRequest): DeviceLease { const backend = normalizeLeaseBackend(request.backend); const tenantId = normalizeTenantId(request.tenantId); if (!tenantId) { @@ -129,7 +134,7 @@ export class LeaseRegistry { } this.enforceCapacity(backend); const now = this.now(); - const lease: SimulatorLease = { + const lease: DeviceLease = { leaseId: crypto.randomBytes(16).toString('hex'), tenantId, runId, @@ -143,7 +148,7 @@ export class LeaseRegistry { return { ...lease }; } - heartbeatLease(request: HeartbeatLeaseRequest): SimulatorLease { + heartbeatLease(request: HeartbeatLeaseRequest): DeviceLease { const leaseId = normalizeLeaseId(request.leaseId); if (!leaseId) { throw new AppError('INVALID_ARGS', 'Invalid lease id.'); @@ -204,18 +209,25 @@ export class LeaseRegistry { } } - listActiveLeases(): SimulatorLease[] { + listActiveLeases(): DeviceLease[] { this.cleanupExpiredLeases(); return Array.from(this.leases.values()).map((entry) => ({ ...entry })); } - private cleanupExpiredLeases(): void { + consumeExpiredLeases(): DeviceLease[] { const now = this.now(); + const expired: DeviceLease[] = []; for (const lease of this.leases.values()) { if (lease.expiresAt > now) continue; this.leases.delete(lease.leaseId); this.runBindings.delete(this.bindingKey(lease.tenantId, lease.runId, lease.backend)); + expired.push({ ...lease }); } + return expired; + } + + private cleanupExpiredLeases(): void { + this.consumeExpiredLeases(); } private enforceCapacity(backend: LeaseBackend): void { @@ -246,9 +258,9 @@ export class LeaseRegistry { return value; } - private refreshLease(lease: SimulatorLease, ttlMs: number): SimulatorLease { + private refreshLease(lease: DeviceLease, ttlMs: number): DeviceLease { const now = this.now(); - const updated: SimulatorLease = { + const updated: DeviceLease = { ...lease, heartbeatAt: now, expiresAt: now + ttlMs, @@ -266,7 +278,7 @@ export class LeaseRegistry { } private assertOptionalScopeMatch( - lease: SimulatorLease, + lease: DeviceLease, tenantRaw: string | undefined, runRaw: string | undefined, ): void { diff --git a/src/daemon/request-admission.ts b/src/daemon/request-admission.ts index 1930a0784..9fba9767c 100644 --- a/src/daemon/request-admission.ts +++ b/src/daemon/request-admission.ts @@ -1,9 +1,15 @@ import { AppError } from '../utils/errors.ts'; import { normalizeTenantId, resolveSessionIsolationMode } from './config.ts'; import { isLeaseAdmissionExempt } from './daemon-command-registry.ts'; -import { resolveLeaseScope } from './lease-context.ts'; -import type { LeaseRegistry } from './lease-registry.ts'; -import type { DaemonRequest } from './types.ts'; +import { + DEFAULT_PROXY_LEASE_TTL_MS, + findMissingProxyLeaseFields, + isProxyLeaseScope, + resolveLeaseScope, + resolveRequestOrSessionLeaseScope, +} from './lease-context.ts'; +import type { DeviceLease, LeaseRegistry } from './lease-registry.ts'; +import type { DaemonRequest, SessionState } from './types.ts'; export function scopeRequestSession(req: DaemonRequest): DaemonRequest { const isolation = resolveSessionIsolationMode( @@ -52,15 +58,74 @@ export function scopeRequestSession(req: DaemonRequest): DaemonRequest { export function assertRequestLeaseAdmission( req: DaemonRequest, leaseRegistry: LeaseRegistry, -): void { - if (isLeaseAdmissionExempt(req.command) || req.meta?.sessionIsolation !== 'tenant') { - return; + session: SessionState | undefined, +): DeviceLease | undefined { + if (isLeaseAdmissionExempt(req.command)) { + return undefined; + } + const requestLeaseScope = resolveLeaseScope(req); + assertProxyOpenLeaseMetadata(req, requestLeaseScope); + const sessionLease = session?.lease; + if (!sessionLease && req.meta?.sessionIsolation !== 'tenant' && !requestLeaseScope.leaseId) { + return undefined; } - const leaseScope = resolveLeaseScope(req); + assertRequestSessionLeaseMatches(requestLeaseScope, sessionLease); + const leaseScope = resolveRequestOrSessionLeaseScope(req, session); leaseRegistry.assertLeaseAdmission({ tenantId: leaseScope.tenantId, runId: leaseScope.runId, leaseId: leaseScope.leaseId, backend: leaseScope.leaseBackend, }); + return leaseRegistry.heartbeatLease({ + leaseId: leaseScope.leaseId ?? '', + tenantId: leaseScope.tenantId, + runId: leaseScope.runId, + ttlMs: + leaseScope.leaseTtlMs ?? + (isProxyLeaseScope(leaseScope) ? DEFAULT_PROXY_LEASE_TTL_MS : undefined), + }); +} + +function assertProxyOpenLeaseMetadata( + req: DaemonRequest, + requestLeaseScope: ReturnType, +): void { + if (req.command !== 'open') return; + const missing = findMissingProxyLeaseFields(requestLeaseScope); + if (missing.length === 0) return; + throw new AppError( + 'INVALID_ARGS', + 'Proxy open requires leaseId, tenantId, runId, clientId, and deviceKey lease metadata.', + { missing }, + ); +} + +function assertRequestSessionLeaseMatches( + requestLeaseScope: ReturnType, + sessionLease: SessionState['lease'] | undefined, +): void { + if (!sessionLease) return; + assertMatchingLeaseField('leaseId', requestLeaseScope.leaseId, sessionLease.leaseId); + assertMatchingLeaseField('tenantId', requestLeaseScope.tenantId, sessionLease.tenantId); + assertMatchingLeaseField('runId', requestLeaseScope.runId, sessionLease.runId); + assertMatchingLeaseField( + 'leaseProvider', + requestLeaseScope.leaseProvider, + sessionLease.leaseProvider, + ); + assertMatchingLeaseField('clientId', requestLeaseScope.clientId, sessionLease.clientId); + assertMatchingLeaseField('deviceKey', requestLeaseScope.deviceKey, sessionLease.deviceKey); +} + +function assertMatchingLeaseField( + field: string, + requestValue?: string, + sessionValue?: string, +): void { + if (!requestValue || !sessionValue || requestValue === sessionValue) return; + throw new AppError('UNAUTHORIZED', `Lease does not match session owner (${field})`, { + reason: 'LEASE_SESSION_MISMATCH', + field, + }); } diff --git a/src/daemon/request-execution-scope.ts b/src/daemon/request-execution-scope.ts index 115edb418..f54388384 100644 --- a/src/daemon/request-execution-scope.ts +++ b/src/daemon/request-execution-scope.ts @@ -31,6 +31,7 @@ import { type SessionStore, } from './session-store.ts'; import type { DaemonRequest, DaemonResponse, SessionState } from './types.ts'; +import { teardownSessionResources } from './handlers/session-close.ts'; // Production daemon wiring owns one LeaseRegistry per process; scoping locks by registry keeps // test and embedded routers isolated without changing process-level serialization there. @@ -75,9 +76,11 @@ export async function createRequestExecutionScope(params: { }): Promise { const { sessionStore, leaseRegistry } = params; const scopedReq = applyRequestCommandDefaults(scopeRequestSession(params.req)); + await cleanupExpiredLeasedSessions({ sessionStore, leaseRegistry }); const command = scopedReq.command; const sessionName = resolveEffectiveSessionName(scopedReq, sessionStore); + const existingSession = sessionStore.get(sessionName); const diagnosticsMeta = getDiagnosticsMeta(); const sessionDir = sessionStore.resolveSessionDir(sessionName); const requestLogPath = resolveSessionRequestLogPath( @@ -102,7 +105,17 @@ export async function createRequestExecutionScope(params: { runnerLogPath, }, }); - assertRequestLeaseAdmission(scopedReq, leaseRegistry); + const activeLease = assertRequestLeaseAdmission(scopedReq, leaseRegistry, existingSession); + if (activeLease && existingSession?.lease) { + sessionStore.set(sessionName, { + ...existingSession, + lease: { + ...existingSession.lease, + backend: activeLease.backend, + expiresAt: activeLease.expiresAt, + }, + }); + } const executionLockKeys = shouldLockSessionExecution(command) ? await resolveRequestExecutionLockKeys({ req: scopedReq, sessionName, sessionStore }) : []; @@ -127,6 +140,42 @@ export async function createRequestExecutionScope(params: { return scope; } +async function cleanupExpiredLeasedSessions(params: { + sessionStore: SessionStore; + leaseRegistry: LeaseRegistry; +}): Promise { + const expiredLeases = params.leaseRegistry.consumeExpiredLeases(); + if (expiredLeases.length === 0) return; + const expiredLeaseIds = new Set(expiredLeases.map((lease) => lease.leaseId)); + for (const session of params.sessionStore.toArray()) { + const lease = session.lease; + if (!lease || !expiredLeaseIds.has(lease.leaseId)) continue; + emitDiagnostic({ + level: 'info', + phase: 'leased_session_expired', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + deviceKey: lease.deviceKey, + }, + }); + await teardownSessionResources(session, session.name).catch((error) => { + emitDiagnostic({ + level: 'debug', + phase: 'leased_session_expiry_cleanup_failed', + data: { + reason: 'LEASE_EXPIRED', + leaseId: lease.leaseId, + session: session.name, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + params.sessionStore.delete(session.name); + } +} + async function withRequestExecutionLocks( locks: Map>, keys: RequestExecutionLockKey[], diff --git a/src/daemon/request-handler-chain.ts b/src/daemon/request-handler-chain.ts index b7c863305..ebd25e2d9 100644 --- a/src/daemon/request-handler-chain.ts +++ b/src/daemon/request-handler-chain.ts @@ -73,6 +73,7 @@ async function runSessionHandler(params: RequestHandlerChainParams): Promise