diff --git a/npm_modules/cli/debugger/README.md b/npm_modules/cli/debugger/README.md index 508a0b5c..d8d8f02d 100644 --- a/npm_modules/cli/debugger/README.md +++ b/npm_modules/cli/debugger/README.md @@ -34,7 +34,7 @@ assets and proxies debugger requests to Valdi daemon or Hermes endpoints. Important routes: -- `/api/status`: probes daemon targets and hot reload proxy state. +- `/api/status`: preserves the legacy daemon-port and hot reload proxy status contract. - `/api/snapshot`: fetches the selected target's view tree and preview data. - `/api/runtime-logs` and `/api/runtime-logs/stream`: read and stream target logs. - `/api/debugger/state`, `/api/debugger/events`, and `/api/debugger/actions`: keep the browser UI and external agents in sync. @@ -43,7 +43,8 @@ Important routes: - `/api/debugger/providers` and `/api/debugger/providers/request`: discover and query target-owned debugger providers. - `/api/debugger/settings`: discover and update target-published debug settings. - `/api/performance/profile/*`: list Hermes contexts and capture CPU profiles. -- `/api/devtools/target`: matches the inspected Chromium page to the exact configured preview origin and path. +- `/api/devtools/targets`: returns a fresh, bounded registry of native Valdi, explicit web-preview, and JavaScript-proxy targets. +- `/api/devtools/target`: resolves either one opaque native target ID or the exact configured inspected Chromium page identity. - `/api/devtools/snapshot`, `/api/devtools/highlight`, and `/api/devtools/evaluate`: proxy the explicit web debugger bridge contract through loopback CDP. - `/api/devtools/performance/snapshot` and `/api/devtools/performance/trace/*`: sample the exact web preview and record one bounded global Chromium trace without changing the daemon/Hermes `/api/performance/*` routes. @@ -75,6 +76,28 @@ duplicate raw or Perfetto event list. The panel shows at most 120 graph samples, 120 timeline rows, and 12 grouped summary rows. Its only trace filters are Valdi, Browser, and All; Chrome Trace JSON is assembled only when exported. +Every `/api/devtools/targets` descriptor declares an `identityMode`. +`target-id` is used by native Valdi and waiting proxy records; only an +attachable `target-id` target using the `valdi-daemon` transport can be resolved +or snapshotted through `targetId`. `inspected-page` is reserved for the explicit +Chromium web preview, which must use the complete `inspectedUrl` plus +`targetNonce` identity for target resolution and the complete `sessionId`, +`inspectedUrl`, and `targetNonce` tuple for snapshots and performance. An +inspected-page target is never attachable through its public ID, and identity +modes cannot be mixed in one request. + +Registry reads are snapshots, not leases: each list or target-ID resolution +rediscovers the current endpoints and rejects removed or replaced identities. +Discovery reads existing companion-owned ADB forwards and loopback proxy +metadata without creating or replacing forwarding state. It retains at most 8 +ADB forwards, 10 daemon endpoints, 16 clients per endpoint, 64 contexts per +client, 128 proxy records, and 256 final targets; daemon fan-out is limited to +4 workers. Configured web-preview URLs are capped at 4,096 UTF-8 bytes and their +derived names at 256 bytes. Proxy responses are capped at 512 KiB and the final +serialized `/api/devtools/targets` response is independently capped at 512 KiB. +The legacy `/api/status` route intentionally keeps its prior port probing and +forwarding behavior and does not run registry discovery. + The Data section discovers target-owned providers through a generic custom message contract. The persistence module registers its bounded web snapshot as the `persistent-store` Storage provider and reports it unavailable on platforms diff --git a/npm_modules/cli/src/debugger/server.spec.ts b/npm_modules/cli/src/debugger/server.spec.ts index 5e7045bc..58fc211a 100644 --- a/npm_modules/cli/src/debugger/server.spec.ts +++ b/npm_modules/cli/src/debugger/server.spec.ts @@ -46,6 +46,8 @@ interface MockDaemon { close: () => Promise; port: number; requests: Array>; + setApplicationId(applicationId: string): void; + setContexts(contexts: Array<{ id: string; rootComponentName: string }>): void; } interface MockChromiumConsoleServer { @@ -186,7 +188,18 @@ async function startMockChromiumConsoleServer( !(options.rejectIdentityAfterTracingStart === true && tracingStarted); let value: unknown; if (guarded) { - value = { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: true } : {}) }; + const guardedValue = expression.includes('__VALDI_WEB_DEBUGGER__?.getSnapshot()') + ? { + channel: 'valdi-web-debugger', + selectedNodeId: 'web-root', + snapshot: { + tree: { children: [], id: 'web-root', tag: 'WebRoot' }, + viewport: { height: 800, width: 1200 }, + }, + type: 'snapshot', + } + : true; + value = { __valdiDevToolsTargetMatched: matched, ...(matched ? { value: guardedValue } : {}) }; } else if (expression === 'String(globalThis.location.href)') { value = currentInspectedUrl; } else if (expression.includes("getEntriesByType('resource')")) { @@ -364,6 +377,8 @@ function encodeDaemonPacket(payload: object): Buffer { async function startMockDaemon(customResponseBody?: unknown): Promise { const sockets = new Set(); const requests: Array> = []; + let applicationId = 'mock.app'; + let contexts = [{ id: 'mock-context', rootComponentName: 'Mock App' }]; let responseId = 0; const server = net.createServer(socket => { sockets.add(socket); @@ -386,7 +401,15 @@ async function startMockDaemon(customResponseBody?: unknown): Promise)['id']; + body = { + children: [], + id: `tree-${String(requestedContext)}`, + tag: 'MockRoot', + }; } else { responseType = -1000; const custom = requestBody['body'] as Record; @@ -434,7 +457,7 @@ async function startMockDaemon(customResponseBody?: unknown): Promise): void { + contexts = nextContexts; + }, + }; +} + +function targetDiscoveryFor(endpoints: () => Array<{ deviceId: string; port: number }>): { + defaultDaemonEndpoints: []; + discoverAndroidDaemonEndpoints(): Promise<{ + endpoints: Array<{ deviceId: string; port: number }>; + error: null; + }>; + discoverDebuggerProxyTargets(): Promise<[]>; + probeDebuggerProxy(): Promise; +} { + return { + defaultDaemonEndpoints: [], + discoverAndroidDaemonEndpoints: () => Promise.resolve({ endpoints: endpoints(), error: null }), + discoverDebuggerProxyTargets: () => Promise.resolve([]), + probeDebuggerProxy: () => Promise.resolve(false), }; } @@ -855,8 +901,10 @@ describe('debugger server', () => { expect(JSON.parse(matching.body)).toEqual({ target: jasmine.objectContaining({ applicationUrl: 'http://127.0.0.1:54321/index.html?tenant=alpha&mode=dev', + capabilities: ['components', 'snapshot', 'highlight', 'console', 'performance'], debuggingPort: 9333, id: 'owl:web-preview', + identityMode: 'inspected-page', sessionId: 'web-preview', }), }); @@ -893,6 +941,513 @@ describe('debugger server', () => { }); }); + it('bounds configured web preview URLs and derived target names at startup', async () => { + const urlPrefix = 'http://127.0.0.1:54321/?padding='; + const maximumUrl = `${urlPrefix}${'x'.repeat(4096 - Buffer.byteLength(urlPrefix, 'utf8'))}`; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: maximumUrl, + }); + await debuggerServer.close(); + debuggerServer = undefined; + + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: `${maximumUrl}x`, + }), + ).toBeRejectedWithError('The integrated DevTools web preview URL cannot exceed 4096 bytes.'); + + const maximumName = 'n'.repeat(256); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: `http://127.0.0.1:54321/${maximumName}`, + }); + await debuggerServer.close(); + debuggerServer = undefined; + + await expectAsync( + startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + webPreviewUrl: `http://127.0.0.1:54321/${maximumName}x`, + }), + ).toBeRejectedWithError('The integrated DevTools web preview target name cannot exceed 256 bytes.'); + }); + + it('discovers and exact-resolves opaque native targets alongside the explicit web preview', async () => { + mockDaemon = await startMockDaemon(); + mockDaemon.setContexts([ + { id: 'first-context', rootComponentName: 'First' }, + { id: 'second-context', rootComponentName: 'Second' }, + ]); + let endpoints = [{ deviceId: 'emulator-5554', port: mockDaemon.port }]; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: targetDiscoveryFor(() => endpoints), + webPreviewUrl: 'http://127.0.0.1:54321/index.html', + }); + + const registry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const registryBody = JSON.parse(registry.body) as { targets: Array> }; + const nativeTargets = registryBody.targets.filter(target => target['transport'] === 'valdi-daemon'); + const webTargets = registryBody.targets.filter(target => target['id'] === 'owl:web-preview'); + const second = nativeTargets.find(target => target['contextId'] === 'second-context'); + if (!second) throw new Error('Expected the second native debugger context.'); + const targetId = String(second['id']); + + expect(registry.statusCode).toBe(200); + expect(nativeTargets.length).toBe(2); + expect(webTargets.length).toBe(1); + expect(targetId).toMatch(/^vdt_[\w-]{32}$/); + expect(targetId).not.toContain(mockDaemon.port.toString()); + expect(targetId).not.toContain('second-context'); + expect(second['capabilities']).toEqual(['components', 'snapshot']); + expect(second['identityMode']).toBe('target-id'); + expect(webTargets[0]?.['identityMode']).toBe('inspected-page'); + expect(webTargets[0]?.['capabilities']).toEqual(['components', 'snapshot', 'highlight', 'console', 'performance']); + + const resolved = await request( + new URL(`/api/devtools/target?targetId=${encodeURIComponent(targetId)}`, debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const snapshot = await request( + new URL(`/api/devtools/snapshot?targetId=${encodeURIComponent(targetId)}`, debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const snapshotBody = JSON.parse(snapshot.body) as { + target: Record; + tree: { nodes: Array<{ data: Record }> }; + }; + + expect(resolved.statusCode).toBe(200); + expect((JSON.parse(resolved.body) as { target: Record }).target['id']).toBe(targetId); + expect(snapshot.statusCode).toBe(200); + expect(snapshotBody.target['id']).toBe(targetId); + expect(snapshotBody.tree.nodes[0]?.data['id']).toBe('tree-second-context'); + + const duplicate = await request( + new URL( + `/api/devtools/target?targetId=${encodeURIComponent(targetId)}&targetId=${encodeURIComponent(targetId)}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const mixed = await request( + new URL( + `/api/devtools/snapshot?targetId=${encodeURIComponent(targetId)}&port=${mockDaemon.port.toString()}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const unknown = await request( + new URL('/api/devtools/target?targetId=vdt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(duplicate.statusCode).toBe(400); + expect(mixed.statusCode).toBe(400); + expect(unknown.statusCode).toBe(404); + + mockDaemon.setApplicationId('mock.replacement'); + const replaced = await request( + new URL(`/api/devtools/target?targetId=${encodeURIComponent(targetId)}`, debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + expect(replaced.statusCode).toBe(404); + + const replacementRegistry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const replacementTargets = (JSON.parse(replacementRegistry.body) as { targets: Array> }) + .targets; + expect(replacementTargets.some(target => target['id'] === targetId)).toBeFalse(); + const replacementTarget = replacementTargets.find(target => target['contextId'] === 'second-context'); + if (!replacementTarget) throw new Error('Expected the replacement native debugger context.'); + const replacementTargetId = String(replacementTarget['id']); + expect(replacementTargetId).not.toBe(targetId); + + endpoints = [{ deviceId: 'replacement-device', port: mockDaemon.port }]; + const samePortReplacement = await request( + new URL( + `/api/devtools/target?targetId=${encodeURIComponent(replacementTargetId)}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + expect(samePortReplacement.statusCode).toBe(404); + + const samePortReplacementRegistry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const samePortReplacementTargets = ( + JSON.parse(samePortReplacementRegistry.body) as { targets: Array> } + ).targets; + const samePortReplacementTarget = samePortReplacementTargets.find( + target => target['contextId'] === 'second-context', + ); + if (!samePortReplacementTarget) throw new Error('Expected the same-port replacement debugger context.'); + const samePortReplacementTargetId = String(samePortReplacementTarget['id']); + expect(samePortReplacementTargetId).not.toBe(replacementTargetId); + + endpoints = []; + const removed = await request( + new URL( + `/api/devtools/target?targetId=${encodeURIComponent(samePortReplacementTargetId)}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + expect(removed.statusCode).toBe(404); + }); + + it('treats every configured target-discovery endpoint as read-only', async () => { + mockDaemon = await startMockDaemon(); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: { + defaultDaemonEndpoints: [ + { + autoForward: true, + deviceId: 'unsafe;device', + port: mockDaemon.port, + }, + ], + discoverAndroidDaemonEndpoints: () => Promise.resolve({ endpoints: [], error: null }), + discoverDebuggerProxyTargets: () => Promise.resolve([]), + probeDebuggerProxy: () => Promise.resolve(false), + }, + }); + + const registry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const targets = (JSON.parse(registry.body) as { targets: Array> }).targets; + + expect(registry.statusCode).toBe(200); + expect(targets.some(target => target['transport'] === 'valdi-daemon')).toBeTrue(); + }); + + it('preserves the legacy status shape and explicit-port path without registry discovery', async () => { + mockDaemon = await startMockDaemon(); + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: { + defaultDaemonEndpoints: [], + discoverAndroidDaemonEndpoints: () => Promise.reject(new Error('Status must not discover ADB endpoints.')), + discoverDebuggerProxyTargets: () => Promise.reject(new Error('Status must not discover proxy targets.')), + probeDebuggerProxy: () => Promise.reject(new Error('Status must not use registry proxy discovery.')), + }, + webPreviewUrl: 'http://127.0.0.1:54321/index.html', + }); + + const originalPath = process.env['PATH']; + process.env['PATH'] = '/nonexistent'; + let status: HttpResult; + try { + status = await request( + new URL(`/api/status?port=${mockDaemon.port.toString()}`, debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + } finally { + if (originalPath === undefined) delete process.env['PATH']; + else process.env['PATH'] = originalPath; + } + const payload = JSON.parse(status.body) as Record; + const webPreviewTarget = payload['webPreviewTarget'] as Record; + + expect(status.statusCode).toBe(200); + expect(Object.keys(payload).sort()).toEqual(['defaultPort', 'hotReloadProxy', 'ports', 'webPreviewTarget']); + expect(payload['ports']).toEqual([ + jasmine.objectContaining({ + connected: true, + port: mockDaemon.port, + }), + ]); + expect(Object.keys(webPreviewTarget).sort()).toEqual([ + 'applicationId', + 'applicationUrl', + 'debuggingPort', + 'id', + 'name', + 'owlTarget', + 'platform', + 'sessionId', + 'state', + 'transport', + ]); + }); + + it('rejects a final serialized target registry response larger than 512 KiB', async () => { + let proxyTargetCount = 1; + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: { + defaultDaemonEndpoints: [], + discoverAndroidDaemonEndpoints: () => Promise.resolve({ endpoints: [], error: null }), + discoverDebuggerProxyTargets: () => + Promise.resolve( + Array.from({ length: proxyTargetCount }, (_, index) => { + const suffix = index.toString(); + const deviceId = `device-${suffix}-${'d'.repeat(900)}`; + return { + adapterType: `_android_${deviceId}`, + appId: `application-${suffix}-${'a'.repeat(980)}`, + id: `proxy-${suffix}`, + metadata: { deviceId, deviceName: `Device ${suffix} ${'n'.repeat(980)}` }, + title: `Runtime ${suffix} ${'t'.repeat(980)}`, + webSocketDebuggerUrl: `ws://127.0.0.1:9010/${suffix}/${'w'.repeat(3900)}`, + }; + }), + ), + probeDebuggerProxy: () => Promise.resolve(true), + }, + }); + + const bounded = await request(new URL('/api/devtools/targets', debuggerServer.url).toString(), GET_REQUEST_OPTIONS); + proxyTargetCount = 80; + const oversized = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(bounded.statusCode).toBe(200); + expect(oversized.statusCode).toBe(500); + expect(JSON.parse(oversized.body)).toEqual({ + error: 'Valdi DevTools target registry response exceeded 524288 bytes.', + }); + }); + + it('rejects mixed web-preview and target-ID resolver modes without falling back', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: targetDiscoveryFor(() => []), + webPreviewUrl: 'http://127.0.0.1:54321/index.html', + }); + const mixed = await request( + new URL( + `/api/devtools/target?targetId=owl%3Aweb-preview&inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html&targetNonce=${WEB_PREVIEW_NONCE}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const duplicateNonce = await request( + new URL( + `/api/devtools/target?inspectedUrl=http%3A%2F%2F127.0.0.1%3A54321%2Findex.html&targetNonce=${WEB_PREVIEW_NONCE}&targetNonce=${WEB_PREVIEW_NONCE}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const webByIdTarget = await request( + new URL('/api/devtools/target?targetId=owl%3Aweb-preview', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const webByIdSnapshot = await request( + new URL('/api/devtools/snapshot?targetId=owl%3Aweb-preview', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(mixed.statusCode).toBe(400); + expect(duplicateNonce.statusCode).toBe(400); + expect(webByIdTarget.statusCode).toBe(400); + expect(webByIdSnapshot.statusCode).toBe(400); + expect(JSON.parse(webByIdTarget.body)).toEqual({ + error: 'The selected debugger target cannot be attached by target ID.', + }); + expect(JSON.parse(webByIdSnapshot.body)).toEqual({ + error: 'The selected debugger target cannot be attached by target ID.', + }); + }); + + it('resolves and snapshots the web preview only through its inspected-page identity', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + try { + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: targetDiscoveryFor(() => []), + webPreviewUrl: applicationUrl, + }); + const targetUrl = new URL('/api/devtools/target', debuggerServer.url); + targetUrl.searchParams.set('inspectedUrl', inspectedUrl); + targetUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + const snapshotUrl = new URL('/api/devtools/snapshot', debuggerServer.url); + snapshotUrl.searchParams.set('inspectedUrl', inspectedUrl); + snapshotUrl.searchParams.set('sessionId', 'web-preview'); + snapshotUrl.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + + const target = await request(targetUrl.toString(), GET_REQUEST_OPTIONS); + const snapshot = await request(snapshotUrl.toString(), GET_REQUEST_OPTIONS); + const snapshotBody = JSON.parse(snapshot.body) as { + target: Record; + tree: { nodes: Array<{ data: Record }> }; + }; + + expect(target.statusCode).toBe(200); + expect((JSON.parse(target.body) as { target: Record }).target).toEqual( + jasmine.objectContaining({ + id: 'owl:web-preview', + identityMode: 'inspected-page', + transport: 'chromium-cdp', + }), + ); + expect(snapshot.statusCode).withContext(snapshot.body).toBe(200); + expect(snapshotBody.target['identityMode']).toBe('inspected-page'); + expect(snapshotBody.tree.nodes[0]?.data['id']).toBe('web-root'); + } finally { + await chromium.close(); + } + }); + + it('lists proxy-only target IDs but rejects them as non-native attachments', async () => { + debuggerServer = await startDebuggerServer({ + assetRoot, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: { + defaultDaemonEndpoints: [], + discoverAndroidDaemonEndpoints: () => Promise.resolve({ endpoints: [], error: null }), + discoverDebuggerProxyTargets: () => + Promise.resolve([ + { + adapterType: '_android_emulator-5554', + appId: 'com.example.android', + id: 'proxy-only', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android/proxy-only', + }, + ]), + probeDebuggerProxy: () => Promise.resolve(true), + }, + }); + + const registry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const target = (JSON.parse(registry.body) as { targets: Array> }).targets[0]; + if (!target) throw new Error('Expected a proxy-only debugger target.'); + const resolved = await request( + new URL( + `/api/devtools/target?targetId=${encodeURIComponent(String(target['id']))}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + const snapshot = await request( + new URL( + `/api/devtools/snapshot?targetId=${encodeURIComponent(String(target['id']))}`, + debuggerServer.url, + ).toString(), + GET_REQUEST_OPTIONS, + ); + + expect(target).toEqual( + jasmine.objectContaining({ + attachable: false, + identityMode: 'target-id', + transport: 'chromium-cdp', + }), + ); + expect(resolved.statusCode).toBe(400); + expect(snapshot.statusCode).toBe(400); + expect(JSON.parse(resolved.body)).toEqual({ + error: 'The selected debugger target cannot be attached by target ID.', + }); + }); + + it('refreshes the target registry without changing active web-preview performance ownership', async () => { + const applicationUrl = 'http://127.0.0.1:54321/index.html'; + const inspectedUrl = `${applicationUrl}?valdiDevTools=1`; + const chromium = await startMockChromiumConsoleServer(applicationUrl, WEB_PREVIEW_NONCE, { + holdRuntimeEnable: false, + }); + debuggerServer = await startDebuggerServer({ + assetRoot, + chromiumDebuggingPort: chromium.port, + host: '127.0.0.1', + port: await getFreePort(), + strictPort: true, + targetDiscovery: targetDiscoveryFor(() => []), + webPreviewUrl: applicationUrl, + }); + const debuggerServerUrl = debuggerServer.url; + const traceUrl = (pathname: string): string => { + const url = new URL(pathname, debuggerServerUrl); + url.searchParams.set('inspectedUrl', inspectedUrl); + url.searchParams.set('sessionId', 'web-preview'); + url.searchParams.set('targetNonce', WEB_PREVIEW_NONCE); + return url.toString(); + }; + const postOptions: HttpRequestOptions = { + body: '{}', + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }; + try { + const started = await request(traceUrl('/api/devtools/performance/trace/start'), postOptions); + const registry = await request( + new URL('/api/devtools/targets', debuggerServer.url).toString(), + GET_REQUEST_OPTIONS, + ); + const status = await request(traceUrl('/api/devtools/performance/trace/status'), GET_REQUEST_OPTIONS); + const targetIdShortcut = new URL(traceUrl('/api/devtools/performance/trace/status')); + targetIdShortcut.searchParams.set('targetId', 'owl:web-preview'); + const rejectedShortcut = await request(targetIdShortcut.toString(), GET_REQUEST_OPTIONS); + const stopped = await request(traceUrl('/api/devtools/performance/trace/stop'), postOptions); + + expect(started.statusCode).withContext(started.body).toBe(200); + expect(registry.statusCode).toBe(200); + expect((JSON.parse(status.body) as { recording: boolean }).recording) + .withContext(status.body) + .toBeTrue(); + expect(rejectedShortcut.statusCode).toBe(400); + expect(stopped.statusCode).withContext(stopped.body).toBe(200); + } finally { + await chromium.close(); + } + }); + it('requires the exact session, inspected URL, and nonce for web preview performance routes', async () => { const applicationUrl = 'http://127.0.0.1:54321/index.html?tenant=alpha'; debuggerServer = await startDebuggerServer({ diff --git a/npm_modules/cli/src/debugger/server.ts b/npm_modules/cli/src/debugger/server.ts index aa5d015f..04ab45fd 100644 --- a/npm_modules/cli/src/debugger/server.ts +++ b/npm_modules/cli/src/debugger/server.ts @@ -9,6 +9,7 @@ import { TextDecoder } from 'node:util'; import { type DaemonConnectedClient, type DaemonConnection, + type DaemonConnectionEndpoint, DaemonProtocolError, MOBILE_PORT, type RemoteContext, @@ -27,6 +28,20 @@ import { } from '../utils/owlCdpClient'; import { type ChromiumConsoleEntry, formatChromiumConsoleEvent } from './chromiumConsole'; import { DebuggerInputType, sendDebuggerInput, validateDebuggerInputRequest } from './inputClient'; +import { + type AndroidDaemonDiscovery, + type DebuggerPortStatus, + type DebuggerProxyTarget, + DebuggerTargetCapability, + type DebuggerTargetDescriptor, + DebuggerTargetIdentityMode, + DebuggerTargetPlatform, + DebuggerTargetState, + DebuggerTargetTransport, + buildDebuggerTargetRegistry, + discoverAndroidDaemonEndpoints, + discoverDebuggerProxyTargets, +} from './targetRegistry'; import { type WebPreviewPerformanceIdentity, type WebPreviewTraceCapture, @@ -58,6 +73,14 @@ const CHROMIUM_CONSOLE_COMMAND_TIMEOUT_MS = 8000; const CHROMIUM_CONSOLE_IDENTITY_INTERVAL_MS = 15_000; const CHROMIUM_CONSOLE_DEDUPLICATION_WINDOW_MS = 500; const MAX_PENDING_CHROMIUM_CONSOLE_ENTRIES = 128; +const MAX_DISCOVERY_CLIENTS_PER_ENDPOINT = 16; +const MAX_DISCOVERY_CONTEXTS_PER_CLIENT = 64; +const MAX_DISCOVERY_DAEMON_ENDPOINTS = 10; +const MAX_CONCURRENT_DAEMON_DISCOVERIES = 4; +const DAEMON_DISCOVERY_CONFIGURE_TIMEOUT_MS = 1500; +const MAX_WEB_PREVIEW_URL_BYTES = 4096; +const MAX_WEB_PREVIEW_TARGET_NAME_BYTES = 256; +const MAX_DEVTOOLS_TARGETS_RESPONSE_BYTES = 512 * 1024; export const MAX_CONSOLE_SSE_BUFFERED_BYTES = 512 * 1024; export const MAX_CONSOLE_SSE_BUFFERED_EVENTS = 128; const PERFETTO_PROCESS_ID = 1; @@ -223,12 +246,21 @@ interface DebuggerServerOptions { logsDirectory?: string; webPreviewUrl?: string; chromiumDebuggingPort?: number; + targetDiscovery?: DebuggerTargetDiscoveryDependencies; +} + +interface DebuggerTargetDiscoveryDependencies { + readonly defaultDaemonEndpoints: readonly DaemonConnectionEndpoint[]; + discoverAndroidDaemonEndpoints(): Promise; + discoverDebuggerProxyTargets(port: number): Promise; + probeDebuggerProxy(port: number): Promise; } interface WebPreviewDebuggerTarget { applicationUrl: string; debuggingPort: number; id: string; + name: string; sessionId: string; } @@ -292,6 +324,16 @@ const debuggerActions = [ 'setDebugSetting', 'resetDebugSetting', ]; +const DEFAULT_DEBUGGER_TARGET_DISCOVERY: DebuggerTargetDiscoveryDependencies = { + defaultDaemonEndpoints: [ + { autoForward: false, port: STANDALONE_PORT }, + // Registry refreshes are observational: companion/hotreload remain the sole ADB forward owners. + { autoForward: false, port: MOBILE_PORT }, + ], + discoverAndroidDaemonEndpoints, + discoverDebuggerProxyTargets, + probeDebuggerProxy: async (port: number) => await probeTcpPort(port, 750), +}; let devRevision = 0; let debuggerEventRevision = 0; let devReloadTimer: NodeJS.Timeout | null = null; @@ -299,6 +341,7 @@ let activeHost = DEFAULT_HOST; let assetRoot = getDefaultAssetRoot(); let activeLogsDirectory: string | null = null; let activeWebPreviewTarget: WebPreviewDebuggerTarget | null = null; +let activeDebuggerTargetDiscovery = DEFAULT_DEBUGGER_TARGET_DISCOVERY; let activeProfileSession: ActiveProfileSession | null = null; let profileTransitionInProgress = false; let traceTransitionInProgress = false; @@ -1167,6 +1210,9 @@ function createWebPreviewDebuggerTarget( webPreviewUrl: string | undefined, debuggingPort: number, ): WebPreviewDebuggerTarget | null { + if (webPreviewUrl !== undefined && Buffer.byteLength(webPreviewUrl, 'utf8') > MAX_WEB_PREVIEW_URL_BYTES) { + throw new Error(`The integrated DevTools web preview URL cannot exceed ${MAX_WEB_PREVIEW_URL_BYTES} bytes.`); + } const rawUrl = webPreviewUrl?.trim(); if (!rawUrl) return null; if (!Number.isInteger(debuggingPort) || debuggingPort < 1 || debuggingPort > 65_535) { @@ -1190,23 +1236,57 @@ function createWebPreviewDebuggerTarget( throw new Error('The integrated DevTools web preview must use an unauthenticated loopback HTTP URL.'); } + const normalizedApplicationUrl = applicationUrl.toString(); + if (Buffer.byteLength(normalizedApplicationUrl, 'utf8') > MAX_WEB_PREVIEW_URL_BYTES) { + throw new Error(`The integrated DevTools web preview URL cannot exceed ${MAX_WEB_PREVIEW_URL_BYTES} bytes.`); + } + const name = applicationUrl.pathname.split('/').filter(Boolean).at(-1) ?? applicationUrl.hostname; + if (Buffer.byteLength(name, 'utf8') > MAX_WEB_PREVIEW_TARGET_NAME_BYTES) { + throw new Error( + `The integrated DevTools web preview target name cannot exceed ${MAX_WEB_PREVIEW_TARGET_NAME_BYTES} bytes.`, + ); + } + return { - applicationUrl: applicationUrl.toString(), + applicationUrl: normalizedApplicationUrl, debuggingPort, id: 'owl:web-preview', + name, sessionId: 'web-preview', }; } -function webPreviewTargetPayload(target: WebPreviewDebuggerTarget): Record { - const applicationUrl = new URL(target.applicationUrl); - const pathName = applicationUrl.pathname.split('/').filter(Boolean).at(-1) ?? applicationUrl.hostname; +function webPreviewTargetPayload(target: WebPreviewDebuggerTarget): DebuggerTargetDescriptor { + return { + applicationId: target.applicationUrl, + applicationUrl: target.applicationUrl, + attachable: true, + capabilities: [ + DebuggerTargetCapability.Components, + DebuggerTargetCapability.Snapshot, + DebuggerTargetCapability.Highlight, + DebuggerTargetCapability.Console, + DebuggerTargetCapability.Performance, + ], + debuggingPort: target.debuggingPort, + id: target.id, + identityMode: DebuggerTargetIdentityMode.InspectedPage, + name: target.name, + owlTarget: true, + platform: DebuggerTargetPlatform.Web, + sessionId: target.sessionId, + state: DebuggerTargetState.Available, + transport: DebuggerTargetTransport.ChromiumCDP, + }; +} + +function legacyWebPreviewTargetPayload(target: WebPreviewDebuggerTarget): Record { return { applicationId: target.applicationUrl, applicationUrl: target.applicationUrl, debuggingPort: target.debuggingPort, id: target.id, - name: pathName, + name: target.name, owlTarget: true, platform: 'web', sessionId: target.sessionId, @@ -1267,6 +1347,9 @@ function resolveWebPreviewPerformanceIdentity(searchParams: URLSearchParams): { identity: WebPreviewPerformanceIdentity; target: WebPreviewDebuggerTarget; } { + if (searchParams.getAll('targetId').length > 0) { + throw new ApiRequestError(400, 'Web preview performance requests do not accept debugger target IDs.'); + } const sessionId = readExactWebPreviewPerformanceParameter(searchParams, 'sessionId'); const target = resolveWebPreviewDebuggerTarget(sessionId); if (sessionId !== target.sessionId) { @@ -1297,14 +1380,38 @@ function readExactWebPreviewPerformanceParameter(searchParams: URLSearchParams, return values[0]; } +function readAtMostOneDebuggerParameter(searchParams: URLSearchParams, name: string): string | undefined { + const values = searchParams.getAll(name); + if (values.length > 1) { + throw new ApiRequestError(400, `${name} must not appear more than once in a debugger target identity.`); + } + return values[0] || undefined; +} + +function readExactDebuggerParameter(searchParams: URLSearchParams, name: string): string { + const value = readAtMostOneDebuggerParameter(searchParams, name); + if (!value) { + throw new ApiRequestError(400, `${name} must appear exactly once in a debugger target identity.`); + } + return value; +} + +function rejectDebuggerIdentityParameters(searchParams: URLSearchParams, names: readonly string[]): void { + const present = names.find(name => searchParams.getAll(name).length > 0); + if (present !== undefined) { + throw new ApiRequestError(400, `Debugger target identity modes cannot mix ${present} with this request.`); + } +} + function resolveInspectedWebPreviewTarget(searchParams: URLSearchParams): Record { if (!activeWebPreviewTarget) { throw new ApiRequestError(404, 'Start valdi debugger with --web-preview-url before opening the DevTools panel.'); } + rejectDebuggerIdentityParameters(searchParams, ['targetId', 'sessionId', 'port', 'clientId', 'contextId']); resolveInspectedWebPreviewContext( activeWebPreviewTarget, - searchParams.get('inspectedUrl') ?? undefined, - searchParams.get('targetNonce') ?? undefined, + readAtMostOneDebuggerParameter(searchParams, 'inspectedUrl'), + readAtMostOneDebuggerParameter(searchParams, 'targetNonce'), ); return { target: webPreviewTargetPayload(activeWebPreviewTarget) }; } @@ -1414,11 +1521,16 @@ function readUnknownRecord(value: unknown): Record { } async function inspectWebPreviewSnapshot(searchParams: URLSearchParams): Promise> { - const target = resolveWebPreviewDebuggerTarget(searchParams.get('sessionId') ?? undefined); + rejectDebuggerIdentityParameters(searchParams, ['targetId', 'port', 'clientId', 'contextId']); + const sessionId = readExactDebuggerParameter(searchParams, 'sessionId'); + const target = resolveWebPreviewDebuggerTarget(sessionId); + if (sessionId !== target.sessionId) { + throw new ApiRequestError(404, 'The inspected web preview session is no longer available.'); + } const context = resolveInspectedWebPreviewContext( target, - searchParams.get('inspectedUrl') ?? undefined, - searchParams.get('targetNonce') ?? undefined, + readExactDebuggerParameter(searchParams, 'inspectedUrl'), + readExactDebuggerParameter(searchParams, 'targetNonce'), ); const bridgePayload = await readOwlDebuggerSnapshot(target.debuggingPort, target.applicationUrl, context.targetNonce); if (bridgePayload['channel'] !== 'valdi-web-debugger' || bridgePayload['type'] !== 'snapshot') { @@ -2348,6 +2460,20 @@ async function withConnection(port: number, callback: (conn: DaemonConnection } } +async function withDaemonEndpointConnection( + endpoint: DaemonConnectionEndpoint, + configureTimeoutMs: number, + callback: (conn: DaemonConnection) => Promise, +): Promise { + const conn = await connectToDaemon(endpoint); + try { + await conn.configureWithTimeout(configureTimeoutMs); + return await callback(conn); + } finally { + conn.close(); + } +} + async function collectClientContexts( conn: DaemonConnection, clients: DaemonConnectedClient[], @@ -2408,6 +2534,130 @@ async function inspectPort(port: number): Promise<{ } } +async function inspectDebuggerEndpoint(endpoint: DaemonConnectionEndpoint): Promise { + try { + return await withDaemonEndpointConnection(endpoint, DAEMON_DISCOVERY_CONFIGURE_TIMEOUT_MS, async conn => { + const clients = await conn.listConnectedClients(); + if (clients.length > MAX_DISCOVERY_CLIENTS_PER_ENDPOINT) { + throw new Error( + `Valdi daemon target discovery exceeded ${MAX_DISCOVERY_CLIENTS_PER_ENDPOINT.toString()} clients.`, + ); + } + const clientsWithContexts = await Promise.all( + clients.map(async client => { + const contexts = await conn.listContextsWithTimeout(client.client_id, DAEMON_DISCOVERY_CONFIGURE_TIMEOUT_MS); + if (contexts.length > MAX_DISCOVERY_CONTEXTS_PER_CLIENT) { + throw new Error( + `Valdi daemon target discovery exceeded ${MAX_DISCOVERY_CONTEXTS_PER_CLIENT.toString()} contexts for one client.`, + ); + } + return { ...client, contexts, contextError: null }; + }), + ); + + return { + port: endpoint.port, + portName: portName(endpoint.port), + connected: true, + clients: clientsWithContexts, + ...(endpoint.deviceId === undefined ? {} : { deviceId: endpoint.deviceId }), + error: null, + }; + }); + } catch (error) { + return { + port: endpoint.port, + portName: portName(endpoint.port), + connected: false, + clients: [], + ...(endpoint.deviceId === undefined ? {} : { deviceId: endpoint.deviceId }), + error: clientErrorPayload(error).error, + }; + } +} + +function debuggerDaemonEndpoints(android: AndroidDaemonDiscovery): DaemonConnectionEndpoint[] { + const endpointsByPort = new Map(); + for (const endpoint of android.endpoints) { + const existing = endpointsByPort.get(endpoint.port); + if (existing && existing.deviceId !== endpoint.deviceId) { + throw new Error('Android debugger discovery returned an ambiguous local daemon port.'); + } + endpointsByPort.set(endpoint.port, { + autoForward: false, + ...(endpoint.deviceId === undefined ? {} : { deviceId: endpoint.deviceId }), + port: endpoint.port, + }); + } + for (const endpoint of activeDebuggerTargetDiscovery.defaultDaemonEndpoints) { + if (endpointsByPort.has(endpoint.port)) continue; + endpointsByPort.set(endpoint.port, { + ...endpoint, + // Target discovery must not create or replace ADB forwarding state. + autoForward: false, + }); + } + const endpoints = [...endpointsByPort.values()]; + if (endpoints.length > MAX_DISCOVERY_DAEMON_ENDPOINTS) { + throw new Error(`Debugger discovery exceeded ${MAX_DISCOVERY_DAEMON_ENDPOINTS.toString()} daemon endpoints.`); + } + return endpoints; +} + +async function inspectDebuggerEndpoints(endpoints: readonly DaemonConnectionEndpoint[]): Promise { + const results = new Map(); + let nextIndex = 0; + const workerCount = Math.min(MAX_CONCURRENT_DAEMON_DISCOVERIES, endpoints.length); + const workers = Array.from({ length: workerCount }, async () => { + while (nextIndex < endpoints.length) { + const index = nextIndex; + nextIndex += 1; + const endpoint = endpoints[index]; + if (!endpoint) throw new Error('Debugger endpoint discovery lost an indexed endpoint.'); + results.set(index, await inspectDebuggerEndpoint(endpoint)); + } + }); + await Promise.all(workers); + return endpoints.map((_endpoint, index) => { + const result = results.get(index); + if (!result) throw new Error('Debugger endpoint discovery did not inspect every bounded endpoint.'); + return result; + }); +} + +interface DebuggerTargetDiscoveryResult { + readonly android: AndroidDaemonDiscovery; + readonly ports: readonly DebuggerPortStatus[]; + readonly proxyConnected: boolean; + readonly proxyError: string | null; + readonly proxyTargets: readonly DebuggerProxyTarget[]; + readonly targets: readonly DebuggerTargetDescriptor[]; +} + +async function discoverDebuggerTargets(): Promise { + const [android, proxyConnected] = await Promise.all([ + activeDebuggerTargetDiscovery.discoverAndroidDaemonEndpoints(), + activeDebuggerTargetDiscovery.probeDebuggerProxy(HOT_RELOAD_PROXY_PORT), + ]); + const endpoints = debuggerDaemonEndpoints(android); + const ports = await inspectDebuggerEndpoints(endpoints); + let proxyTargets: DebuggerProxyTarget[] = []; + let proxyError: string | null = null; + if (proxyConnected) { + try { + proxyTargets = await activeDebuggerTargetDiscovery.discoverDebuggerProxyTargets(HOT_RELOAD_PROXY_PORT); + } catch (error) { + proxyError = clientErrorPayload(error).error; + } + } + const targets = buildDebuggerTargetRegistry({ + ports, + proxyTargets, + webPreviewTargets: activeWebPreviewTarget ? [webPreviewTargetPayload(activeWebPreviewTarget)] : [], + }); + return { android, ports, proxyConnected, proxyError, proxyTargets, targets }; +} + async function inspectStatus(searchParams: URLSearchParams): Promise> { const explicitPort = searchParams.get('port'); const ports = explicitPort ? [readNumber(searchParams, 'port', MOBILE_PORT)] : [STANDALONE_PORT, MOBILE_PORT]; @@ -2424,8 +2674,133 @@ async function inspectStatus(searchParams: URLSearchParams): Promise> { + const discovery = await discoverDebuggerTargets(); + const payload = { + discovery: { + android: { error: discovery.android.error, tunnelCount: discovery.android.endpoints.length }, + proxy: { error: discovery.proxyError, targetCount: discovery.proxyTargets.length }, + }, + targets: discovery.targets, + }; + if (Buffer.byteLength(JSON.stringify(payload), 'utf8') > MAX_DEVTOOLS_TARGETS_RESPONSE_BYTES) { + throw new Error(`Valdi DevTools target registry response exceeded ${MAX_DEVTOOLS_TARGETS_RESPONSE_BYTES} bytes.`); + } + return payload; +} + +async function resolveCurrentDebuggerTarget(targetId: string): Promise { + const discovery = await discoverDebuggerTargets(); + const matches = discovery.targets.filter(target => target.id === targetId); + if (matches.length === 0) { + throw new ApiRequestError(404, 'The selected Valdi debugger target is no longer available.'); + } + if (matches.length !== 1) { + throw new ApiRequestError(409, 'Valdi debugger target discovery returned an ambiguous target identity.'); + } + const target = matches[0]; + if (!target) throw new ApiRequestError(404, 'The selected Valdi debugger target is no longer available.'); + if ( + target.identityMode !== DebuggerTargetIdentityMode.TargetId || + !target.attachable || + target.transport !== DebuggerTargetTransport.ValdiDaemon + ) { + throw new ApiRequestError(400, 'The selected debugger target cannot be attached by target ID.'); + } + return target; +} + +async function resolveInspectedDebuggerTarget(searchParams: URLSearchParams): Promise> { + const targetIdValues = searchParams.getAll('targetId'); + if (targetIdValues.length > 0) { + rejectDebuggerIdentityParameters(searchParams, [ + 'sessionId', + 'inspectedUrl', + 'targetNonce', + 'port', + 'clientId', + 'contextId', + ]); + const targetId = readExactDebuggerParameter(searchParams, 'targetId'); + return { target: await resolveCurrentDebuggerTarget(targetId) }; + } + return resolveInspectedWebPreviewTarget(searchParams); +} + +async function inspectNativeDebuggerSnapshot(target: DebuggerTargetDescriptor): Promise> { + if ( + target.transport !== DebuggerTargetTransport.ValdiDaemon || + target.port === undefined || + target.clientId === undefined || + target.contextId === undefined + ) { + throw new ApiRequestError(400, 'The selected target does not expose a live native Valdi component tree.'); + } + const port = target.port; + const clientId = target.clientId; + const contextId = target.contextId; + const endpoint: DaemonConnectionEndpoint = { + autoForward: false, + ...(target.deviceId === undefined ? {} : { deviceId: target.deviceId }), + port, }; + return await withDaemonEndpointConnection(endpoint, DAEMON_DISCOVERY_CONFIGURE_TIMEOUT_MS, async conn => { + const clients = await conn.listConnectedClients(); + const client = clients.find(candidate => candidate.client_id === clientId); + if (!client || client.application_id !== target.applicationId) { + throw new ApiRequestError(404, 'The selected Valdi debugger target changed or disconnected.'); + } + const contexts = await conn.listContexts(client.client_id); + const context = contexts.find(candidate => candidate.id === contextId); + if (!context) { + throw new ApiRequestError(404, 'The selected Valdi debugger target changed or disconnected.'); + } + const currentTarget = buildDebuggerTargetRegistry({ + ports: [ + { + clients: [{ ...client, contexts: [context], contextError: null }], + connected: true, + ...(target.deviceId === undefined ? {} : { deviceId: target.deviceId }), + error: null, + port, + portName: portName(port), + }, + ], + proxyTargets: [], + webPreviewTargets: [], + })[0]; + if (!currentTarget || currentTarget.id !== target.id) { + throw new ApiRequestError(404, 'The selected Valdi debugger target changed or disconnected.'); + } + const tree = await conn.getContextTree(client.client_id, context.id, true); + return { + contexts: [target], + issues: [], + logs: [], + source: 'valdi-daemon', + target: { ...target, state: DebuggerTargetState.Attached }, + targets: [target], + tree: projectDebuggerTreeForJson(tree), + }; + }); +} + +async function inspectDevToolsSnapshot(searchParams: URLSearchParams): Promise> { + if (searchParams.getAll('targetId').length === 0) return await inspectWebPreviewSnapshot(searchParams); + rejectDebuggerIdentityParameters(searchParams, [ + 'sessionId', + 'inspectedUrl', + 'targetNonce', + 'port', + 'clientId', + 'contextId', + ]); + const targetId = readExactDebuggerParameter(searchParams, 'targetId'); + return await inspectNativeDebuggerSnapshot(await resolveCurrentDebuggerTarget(targetId)); } async function resolveTarget( @@ -3419,12 +3794,21 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url return; } + if (url.pathname === '/api/devtools/targets') { + if (request.method !== 'GET') { + sendJson(response, 405, { error: 'Valdi DevTools target discovery requires GET.' }); + return; + } + sendJson(response, 200, await inspectDebuggerTargets()); + return; + } + if (url.pathname === '/api/devtools/target') { if (request.method !== 'GET') { sendJson(response, 405, { error: 'Valdi DevTools target discovery requires GET.' }); return; } - sendJson(response, 200, resolveInspectedWebPreviewTarget(url.searchParams)); + sendJson(response, 200, await resolveInspectedDebuggerTarget(url.searchParams)); return; } @@ -3433,7 +3817,7 @@ async function handleApi(request: IncomingMessage, response: ServerResponse, url sendJson(response, 405, { error: 'Valdi DevTools snapshots require GET.' }); return; } - sendJson(response, 200, await inspectWebPreviewSnapshot(url.searchParams)); + sendJson(response, 200, await inspectDevToolsSnapshot(url.searchParams)); return; } @@ -3740,6 +4124,7 @@ async function closeDebuggerServer(server: Server): Promise { } } activeWebPreviewTarget = null; + activeDebuggerTargetDiscovery = DEFAULT_DEBUGGER_TARGET_DISCOVERY; } export async function startDebuggerServer(options: DebuggerServerOptions): Promise { @@ -3755,10 +4140,12 @@ export async function startDebuggerServer(options: DebuggerServerOptions): Promi throw new Error(`Debugger port must be an integer between 1 and 65535; received '${String(preferredPort)}'.`); } const previousWebPreviewTarget = activeWebPreviewTarget; + const previousDebuggerTargetDiscovery = activeDebuggerTargetDiscovery; activeWebPreviewTarget = createWebPreviewDebuggerTarget( options.webPreviewUrl, options.chromiumDebuggingPort ?? DEFAULT_CHROMIUM_DEBUGGING_PORT, ); + activeDebuggerTargetDiscovery = options.targetDiscovery ?? DEFAULT_DEBUGGER_TARGET_DISCOVERY; const strictPort = Boolean(options.strictPort); const maxAttempts = strictPort ? 1 : PORT_SEARCH_LIMIT; @@ -3795,6 +4182,7 @@ export async function startDebuggerServer(options: DebuggerServerOptions): Promi throw new Error(`No available port found in ${preferredPort}-${preferredPort + maxAttempts - 1}.`); } catch (error) { activeWebPreviewTarget = previousWebPreviewTarget; + activeDebuggerTargetDiscovery = previousDebuggerTargetDiscovery; throw error; } } diff --git a/npm_modules/cli/src/debugger/targetRegistry.spec.ts b/npm_modules/cli/src/debugger/targetRegistry.spec.ts new file mode 100644 index 00000000..c11439da --- /dev/null +++ b/npm_modules/cli/src/debugger/targetRegistry.spec.ts @@ -0,0 +1,513 @@ +import 'jasmine'; +import http from 'node:http'; +import { PLATFORM } from '../core/constants'; +import { MOBILE_PORT } from '../utils/daemonClient'; +import { + DebuggerTargetCapability, + type DebuggerTargetDescriptor, + DebuggerTargetIdentityMode, + DebuggerTargetPlatform, + DebuggerTargetState, + DebuggerTargetTransport, + buildDebuggerTargetRegistry, + discoverDebuggerProxyTargets, + parseAndroidDaemonForwards, +} from './targetRegistry'; + +function nativePort(applicationId = 'com.example.android') { + return { + clients: [ + { + application_id: applicationId, + client_id: 'client-1', + contextError: null, + contexts: [{ id: 'context-1', rootComponentName: 'Conversation' }], + platform: PLATFORM.ANDROID, + }, + ], + connected: true, + deviceId: 'emulator-5554', + error: null, + port: 51_001, + portName: 'mobile', + }; +} + +function proxyTarget(adapterType: string, deviceId: string | null) { + return { + adapterType, + appId: 'com.example.android', + id: adapterType, + ...(deviceId === null ? {} : { metadata: { deviceId } }), + webSocketDebuggerUrl: `ws://127.0.0.1:9010/${encodeURIComponent(adapterType)}`, + }; +} + +function webPreviewTarget(): DebuggerTargetDescriptor { + return { + applicationId: 'http://127.0.0.1:54321/index.html', + applicationUrl: 'http://127.0.0.1:54321/index.html', + attachable: true, + capabilities: [ + DebuggerTargetCapability.Components, + DebuggerTargetCapability.Snapshot, + DebuggerTargetCapability.Console, + ], + debuggingPort: 9222, + id: 'owl:web-preview', + identityMode: DebuggerTargetIdentityMode.InspectedPage, + name: 'index.html', + owlTarget: true, + platform: DebuggerTargetPlatform.Web, + sessionId: 'web-preview', + state: DebuggerTargetState.Available, + transport: DebuggerTargetTransport.ChromiumCDP, + }; +} + +async function serveProxyTargets(payload: unknown): Promise<{ port: number; close(): Promise }> { + const server = http.createServer((_request, response) => { + response.writeHead(200, { 'Content-Type': 'application/json' }); + response.end(JSON.stringify(payload)); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + const address = server.address(); + if (typeof address !== 'object' || address === null) throw new Error('Mock debugging proxy did not bind.'); + return { + close: async () => { + await new Promise((resolve, reject) => { + server.close(error => (error ? reject(error) : resolve())); + }); + }, + port: address.port, + }; +} + +describe('debugger target registry', () => { + it('discovers bounded companion-owned Android daemon tunnels without selecting JS debugger forwards', () => { + const output = [ + 'emulator-5554 tcp:51001 tcp:13592', + 'emulator-5554 tcp:51002 tcp:13594', + 'phone-123 tcp:52001 tcp:13592', + 'phone-123 tcp:52001 tcp:13592', + 'invalid tcp:99999 tcp:13592', + 'emulator-5554 localabstract:valdi tcp:13592', + ].join('\n'); + + expect(parseAndroidDaemonForwards(output)).toEqual([ + { deviceId: 'emulator-5554', port: 51_001 }, + { deviceId: 'phone-123', port: 52_001 }, + ]); + const tooMany = Array.from( + { length: 9 }, + (_, index) => `device-${index.toString()} tcp:${(51_000 + index).toString()} tcp:${MOBILE_PORT.toString()}`, + ).join('\n'); + expect(() => parseAndroidDaemonForwards(tooMany)).toThrowError(/more than 8/); + expect(() => parseAndroidDaemonForwards('device-a tcp:51001 tcp:13592\ndevice-b tcp:51001 tcp:13592')).toThrowError( + /ambiguous local/, + ); + }); + + it('uses deterministic opaque IDs over the complete native runtime identity', () => { + const options = { ports: [nativePort()], proxyTargets: [], webPreviewTargets: [webPreviewTarget()] }; + const first = buildDebuggerTargetRegistry(options); + const second = buildDebuggerTargetRegistry(options); + const native = first.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon); + const replacement = buildDebuggerTargetRegistry({ + ...options, + ports: [nativePort('com.example.replacement')], + }).find(target => target.transport === DebuggerTargetTransport.ValdiDaemon); + if (!native || !replacement) throw new Error('Expected native debugger targets.'); + + expect(native.id).toMatch(/^vdt_[\w-]{32}$/); + expect(native.id).not.toContain('51001'); + expect(native.id).not.toContain('client-1'); + expect(native.id).not.toContain('context-1'); + expect(native.id).not.toContain('emulator-5554'); + expect(second.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon)?.id).toBe(native.id); + expect(replacement.id).not.toBe(native.id); + expect(native.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]); + expect(native.identityMode).toBe(DebuggerTargetIdentityMode.TargetId); + expect(first).toContain( + jasmine.objectContaining({ + id: 'owl:web-preview', + identityMode: DebuggerTargetIdentityMode.InspectedPage, + }), + ); + }); + + it('fails closed when independent discovery inputs produce the same target identity', () => { + expect(() => + buildDebuggerTargetRegistry({ + ports: [nativePort(), nativePort()], + proxyTargets: [], + webPreviewTargets: [], + }), + ).toThrowError(/ambiguous target identity/); + }); + + it('merges a unique live JavaScript proxy without changing native attachability', () => { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [ + { + adapterType: '_android_emulator-5554', + appId: 'com.example.android', + id: 'proxy-1', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android_emulator-5554/1', + }, + ], + webPreviewTargets: [], + }); + + expect(targets).toEqual([ + jasmine.objectContaining({ + attachable: true, + capabilities: [ + DebuggerTargetCapability.Components, + DebuggerTargetCapability.Snapshot, + DebuggerTargetCapability.JavaScriptDebugger, + ], + javascriptDebuggerUrl: 'ws://127.0.0.1:9010/android_emulator-5554/1', + transport: DebuggerTargetTransport.ValdiDaemon, + }), + ]); + }); + + it('fails closed when differently attributed proxy records claim one canonical transport endpoint', () => { + const port = nativePort(); + port.clients.push({ + application_id: 'com.example.second', + client_id: 'client-2', + contextError: null, + contexts: [{ id: 'context-2', rootComponentName: 'Second' }], + platform: PLATFORM.ANDROID, + }); + const proxyTarget = { + adapterType: '_android_emulator-5554', + metadata: { deviceId: 'emulator-5554' }, + }; + const buildWithUrls = (firstUrl: string, secondUrl: string) => + buildDebuggerTargetRegistry({ + ports: [port], + proxyTargets: [ + { + ...proxyTarget, + appId: 'com.example.android', + id: 'proxy-1', + webSocketDebuggerUrl: firstUrl, + }, + { + ...proxyTarget, + appId: 'com.example.second', + id: 'proxy-2', + webSocketDebuggerUrl: secondUrl, + }, + ], + webPreviewTargets: [], + }); + + const sharedUrl = 'ws://127.0.0.1:9010/devtools/runtime'; + expect(() => buildWithUrls(sharedUrl, sharedUrl)).toThrowError(/duplicate JavaScript transport endpoint/); + expect(() => buildWithUrls(sharedUrl, `${sharedUrl}?`)).toThrowError(/duplicate JavaScript transport endpoint/); + expect(() => + buildWithUrls('ws://LOCALHOST:80/debug/../devtools/runtime', 'ws://localhost/devtools/runtime'), + ).toThrowError(/duplicate JavaScript transport endpoint/); + expect(() => buildWithUrls(`${sharedUrl}#one`, `${sharedUrl}#two`)).toThrowError(/non-loopback target URL/); + expect(() => buildWithUrls(`${sharedUrl}#`, 'ws://127.0.0.1:9010/devtools/other')).toThrowError( + /non-loopback target URL/, + ); + const distinctQueryTargets = buildWithUrls(`${sharedUrl}?runtime=one`, `${sharedUrl}?runtime=two`); + expect( + distinctQueryTargets.filter(target => target.capabilities.includes(DebuggerTargetCapability.JavaScriptDebugger)) + .length, + ).toBe(2); + }); + + it('merges Snap Android device-scoped placeholder metadata only into one unique native runtime', () => { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [ + { + adapterType: '_android_emulator-5554', + appId: 'emulator-5554', + id: 'android-emulator-5554', + metadata: { deviceId: 'emulator-5554', deviceName: 'Pixel_8_API_35' }, + title: 'Snap Android Runtime', + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android_emulator-5554/1', + }, + ], + webPreviewTargets: [], + }); + + expect(targets).toEqual([ + jasmine.objectContaining({ + applicationId: 'com.example.android', + capabilities: [ + DebuggerTargetCapability.Components, + DebuggerTargetCapability.Snapshot, + DebuggerTargetCapability.JavaScriptDebugger, + ], + identityMode: DebuggerTargetIdentityMode.TargetId, + transport: DebuggerTargetTransport.ValdiDaemon, + }), + ]); + }); + + it('does not merge an ordinary proxy application ID into a different native application', () => { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [ + { + adapterType: '_android_emulator-5554', + appId: 'com.example.other', + id: 'wrong-app', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android_emulator-5554/wrong-app', + }, + ], + webPreviewTargets: [], + }); + const native = targets.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon); + const waiting = targets.find(target => target.transport === DebuggerTargetTransport.ChromiumCDP); + + expect(native?.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]); + expect(waiting).toEqual( + jasmine.objectContaining({ + applicationId: 'com.example.other', + attachable: false, + identityMode: DebuggerTargetIdentityMode.TargetId, + }), + ); + }); + + it('does not treat top-level Android adapter metadata as a device-scoped application placeholder', () => { + for (const adapterType of ['android', '_android']) { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [ + { + adapterType, + appId: 'emulator-5554', + id: `top-level-${adapterType}`, + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: `ws://127.0.0.1:9010/android/${encodeURIComponent(adapterType)}`, + }, + ], + webPreviewTargets: [], + }); + const native = targets.find(target => target.transport === DebuggerTargetTransport.ValdiDaemon); + const waiting = targets.find(target => target.transport === DebuggerTargetTransport.ChromiumCDP); + + expect(native?.capabilities).toEqual([DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot]); + expect(waiting).toEqual( + jasmine.objectContaining({ + applicationId: 'emulator-5554', + attachable: false, + }), + ); + } + }); + + it('recognizes only exact proxy adapter identities for the bounded device metadata', () => { + const recognizedAdapterTypes = ['android', '_android', '_android_emulator-5554']; + for (const adapterType of recognizedAdapterTypes) { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [proxyTarget(adapterType, 'emulator-5554')], + webPreviewTargets: [], + }); + expect(targets[0]?.capabilities).toContain(DebuggerTargetCapability.JavaScriptDebugger); + } + + for (const adapterType of ['not_android', '_androidish_emulator-5554', '_android_other-device', 'ANDROID']) { + const targets = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [proxyTarget(adapterType, 'emulator-5554')], + webPreviewTargets: [], + }); + expect(targets).toEqual([ + jasmine.objectContaining({ + capabilities: [DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot], + transport: DebuggerTargetTransport.ValdiDaemon, + }), + ]); + } + + const missingDeviceMetadata = buildDebuggerTargetRegistry({ + ports: [nativePort()], + proxyTargets: [proxyTarget('android', null)], + webPreviewTargets: [], + }); + expect(missingDeviceMetadata).toEqual([ + jasmine.objectContaining({ + capabilities: [DebuggerTargetCapability.Components, DebuggerTargetCapability.Snapshot], + transport: DebuggerTargetTransport.ValdiDaemon, + }), + ]); + + const iosPort = nativePort('com.example.ios'); + iosPort.deviceId = 'iphone-1'; + const iosClient = iosPort.clients[0]; + if (!iosClient) throw new Error('Expected an iOS debugger client.'); + iosClient.platform = PLATFORM.IOS; + for (const adapterType of ['_ios', '_ios_iphone-1']) { + const targets = buildDebuggerTargetRegistry({ + ports: [iosPort], + proxyTargets: [ + { + ...proxyTarget(adapterType, 'iphone-1'), + appId: 'com.example.ios', + }, + ], + webPreviewTargets: [], + }); + expect(targets[0]?.capabilities).toContain(DebuggerTargetCapability.JavaScriptDebugger); + } + for (const adapterType of ['ios', 'not_ios', '_iosish_iphone-1', '_ios_other-device', 'IOS']) { + const targets = buildDebuggerTargetRegistry({ + ports: [iosPort], + proxyTargets: [ + { + ...proxyTarget(adapterType, 'iphone-1'), + appId: 'com.example.ios', + }, + ], + webPreviewTargets: [], + }); + expect(targets[0]?.capabilities).not.toContain(DebuggerTargetCapability.JavaScriptDebugger); + } + }); + + it('keeps ambiguous or proxy-only devices non-attachable instead of guessing a runtime', () => { + const port = nativePort(); + const client = port.clients[0]; + if (!client) throw new Error('Expected a native debugger client.'); + client.contexts.push({ id: 'context-2', rootComponentName: 'Second' }); + const targets = buildDebuggerTargetRegistry({ + ports: [port], + proxyTargets: [ + { + adapterType: '_android_emulator-5554', + appId: 'emulator-5554', + id: 'proxy-1', + metadata: { deviceId: 'emulator-5554', deviceName: 'Emulator' }, + webSocketDebuggerUrl: 'ws://localhost:9010/android_emulator-5554/1', + }, + ], + webPreviewTargets: [], + }); + const nativeTargets = targets.filter(target => target.transport === DebuggerTargetTransport.ValdiDaemon); + const waiting = targets.find(target => target.transport === DebuggerTargetTransport.ChromiumCDP); + if (!waiting) throw new Error('Expected an ambiguity-safe waiting proxy target.'); + + expect( + nativeTargets.every(target => !target.capabilities.includes(DebuggerTargetCapability.JavaScriptDebugger)), + ).toBeTrue(); + expect(waiting).toEqual( + jasmine.objectContaining({ + attachable: false, + capabilities: [DebuggerTargetCapability.JavaScriptDebugger], + state: DebuggerTargetState.Waiting, + }), + ); + expect(waiting.id).toMatch(/^vdt_/); + }); + + it('quarantines proxy records with a present but invalid application ID', async () => { + const maximumApplicationId = 'v'.repeat(1024); + const oversizedApplicationId = 'a'.repeat(1025); + const proxy = await serveProxyTargets([ + { + adapterType: '_android_emulator-5554', + id: 'absent-app', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android/absent-app', + }, + { + adapterType: '_android_emulator-5554', + appId: 'com.example.valid', + id: 'valid-app', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android/valid-app', + }, + { + adapterType: '_android_emulator-5554', + appId: maximumApplicationId, + id: 'maximum-app', + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/android/maximum-app', + }, + ...[ + ['', 'empty-app'], + [' ', 'blank-app'], + [17, 'numeric-app'], + [null, 'null-app'], + ['bad\0app', 'control-app'], + [oversizedApplicationId, 'oversized-app'], + ].map(([appId, id]) => ({ + adapterType: '_android_emulator-5554', + appId, + id, + metadata: { deviceId: 'emulator-5554' }, + webSocketDebuggerUrl: `ws://127.0.0.1:9010/android/${String(id)}`, + })), + ]); + try { + const targets = await discoverDebuggerProxyTargets(proxy.port); + expect(targets.map(target => target.id)).toEqual(['absent-app', 'valid-app', 'maximum-app']); + expect(targets[0]?.appId).toBeUndefined(); + expect(targets[1]?.appId).toBe('com.example.valid'); + expect(targets[2]?.appId).toBe(maximumApplicationId); + } finally { + await proxy.close(); + } + }); + + it('accepts only bounded loopback proxy metadata', async () => { + const proxy = await serveProxyTargets([ + { + adapterType: '_ios_device', + appId: 'com.example.ios', + id: 'local', + metadata: { deviceId: 'iphone-1' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/ios/1', + }, + { + adapterType: '_android_device', + id: 'remote', + webSocketDebuggerUrl: 'ws://example.com/private', + }, + ]); + try { + expect(await discoverDebuggerProxyTargets(proxy.port)).toEqual([ + { + adapterType: '_ios_device', + appId: 'com.example.ios', + id: 'local', + metadata: { deviceId: 'iphone-1' }, + webSocketDebuggerUrl: 'ws://127.0.0.1:9010/ios/1', + }, + ]); + } finally { + await proxy.close(); + } + + const oversized = await serveProxyTargets( + Array.from({ length: 129 }, (_, index) => ({ + adapterType: '_ios_device', + id: index.toString(), + webSocketDebuggerUrl: `ws://127.0.0.1:9010/ios/${index.toString()}`, + })), + ); + try { + await expectAsync(discoverDebuggerProxyTargets(oversized.port)).toBeRejectedWithError(/more than 128/); + } finally { + await oversized.close(); + } + }); +}); diff --git a/npm_modules/cli/src/debugger/targetRegistry.ts b/npm_modules/cli/src/debugger/targetRegistry.ts new file mode 100644 index 00000000..ca6ffe9a --- /dev/null +++ b/npm_modules/cli/src/debugger/targetRegistry.ts @@ -0,0 +1,524 @@ +import { execFile as execFileCallback } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import http from 'node:http'; +import { promisify } from 'node:util'; +import { PLATFORM } from '../core/constants'; +import { type DaemonConnectedClient, MOBILE_PORT, type RemoteContext } from '../utils/daemonClient'; +import { isLoopbackHost } from '../utils/loopbackHost'; + +/** String values are part of the debugger's local HTTP wire contract. */ +export enum DebuggerTargetPlatform { + Android = 'android', + IOS = 'ios', + MacOS = 'macos', + Web = 'web', + Unknown = 'unknown', +} + +/** Transports describe how the debugger reaches a target, not its operating system. */ +export enum DebuggerTargetTransport { + ChromiumCDP = 'chromium-cdp', + ValdiDaemon = 'valdi-daemon', +} + +/** Capability names are serialized so frontends can hide unsupported tools. */ +export enum DebuggerTargetCapability { + Components = 'components', + Console = 'console', + Highlight = 'highlight', + JavaScriptDebugger = 'javascript-debugger', + Performance = 'performance', + Snapshot = 'snapshot', + Storage = 'storage', +} + +/** Availability is deliberately separate from merely discovering a device. */ +export enum DebuggerTargetState { + Attached = 'attached', + Available = 'available', + Waiting = 'waiting', +} + +/** The serialized mode tells clients which complete identity contract a target requires. */ +export enum DebuggerTargetIdentityMode { + InspectedPage = 'inspected-page', + TargetId = 'target-id', +} + +export interface DebuggerDaemonEndpoint { + readonly deviceId?: string; + readonly port: number; +} + +export interface DebuggerDaemonClient extends DaemonConnectedClient { + readonly contextError: string | null; + readonly contexts: readonly RemoteContext[]; +} + +export interface DebuggerPortStatus { + readonly clients: readonly DebuggerDaemonClient[]; + readonly connected: boolean; + readonly deviceId?: string; + readonly error: string | null; + readonly port: number; + readonly portName: string; +} + +export interface DebuggerProxyTargetMetadata { + readonly deviceId?: string; + readonly deviceName?: string; +} + +export interface DebuggerProxyTarget { + readonly adapterType?: string; + readonly appId?: string; + readonly id: string; + readonly metadata?: DebuggerProxyTargetMetadata; + readonly title?: string; + readonly webSocketDebuggerUrl: string; +} + +export interface DebuggerTargetDescriptor { + readonly applicationId: string; + readonly applicationUrl?: string; + readonly attachable: boolean; + readonly capabilities: readonly DebuggerTargetCapability[]; + readonly clientId?: string; + readonly contextId?: string; + readonly debuggingPort?: number; + readonly deviceId?: string; + readonly id: string; + readonly identityMode: DebuggerTargetIdentityMode; + readonly javascriptDebuggerUrl?: string; + readonly name: string; + readonly owlTarget?: boolean; + readonly platform: DebuggerTargetPlatform; + readonly port?: number; + readonly proxyPort?: number; + readonly sessionId?: string; + readonly state: DebuggerTargetState; + readonly tracingEnabled?: boolean; + readonly transport: DebuggerTargetTransport; +} + +export interface DebuggerTargetRegistryOptions { + readonly ports: readonly DebuggerPortStatus[]; + readonly proxyTargets: readonly DebuggerProxyTarget[]; + readonly webPreviewTargets: readonly DebuggerTargetDescriptor[]; +} + +export interface AndroidDaemonDiscovery { + readonly endpoints: readonly DebuggerDaemonEndpoint[]; + readonly error: string | null; +} + +const execFile = promisify(execFileCallback); +const MAX_ANDROID_DAEMON_ENDPOINTS = 8; +const MAX_ADB_FORWARD_OUTPUT_BYTES = 256 * 1024; +const MAX_PROXY_RESPONSE_BYTES = 512 * 1024; +const MAX_PROXY_TARGETS = 128; +const MAX_PROXY_URL_BYTES = 4096; +const MAX_PROXY_STRING_BYTES = 1024; +const MAX_REGISTRY_PORTS = 10; +const MAX_REGISTRY_TARGETS = 256; +const OPAQUE_TARGET_ID_DOMAIN = 'valdi-debugger-target-v1'; + +const NATIVE_CAPABILITIES: readonly DebuggerTargetCapability[] = [ + DebuggerTargetCapability.Components, + DebuggerTargetCapability.Snapshot, +]; + +function containsControlCharacter(value: string): boolean { + for (const character of value) { + const codePoint = character.codePointAt(0); + if (codePoint !== undefined && (codePoint <= 31 || codePoint === 127)) return true; + } + return false; +} + +function boundedString(value: unknown, maximumBytes: number): string | undefined { + if ( + typeof value !== 'string' || + value.length === 0 || + Buffer.byteLength(value, 'utf8') > maximumBytes || + containsControlCharacter(value) + ) { + return undefined; + } + return value; +} + +function assertBoundedIdentityString(value: unknown, name: string, allowEmpty: boolean): asserts value is string { + if ( + typeof value !== 'string' || + (!allowEmpty && value.length === 0) || + Buffer.byteLength(value, 'utf8') > MAX_PROXY_STRING_BYTES || + containsControlCharacter(value) + ) { + throw new Error(`Debugger target discovery returned an invalid ${name}.`); + } +} + +function platformFromValue(value: string): DebuggerTargetPlatform { + switch (value.toLowerCase()) { + case PLATFORM.ANDROID: { + return DebuggerTargetPlatform.Android; + } + case PLATFORM.IOS: { + return DebuggerTargetPlatform.IOS; + } + case PLATFORM.MACOS: + case 'standalone': { + return DebuggerTargetPlatform.MacOS; + } + default: { + return DebuggerTargetPlatform.Unknown; + } + } +} + +function opaqueTargetId(kind: string, identity: readonly unknown[]): string { + const serializedIdentity = JSON.stringify([OPAQUE_TARGET_ID_DOMAIN, kind, identity]); + return `vdt_${createHash('sha256').update(serializedIdentity).digest('base64url').slice(0, 32)}`; +} + +function loopbackWebSocketTransportKey(value: string): string | undefined { + // ChromiumDevToolsConnection omits URL fragments from the WebSocket transport request. + // Reject the delimiter itself so named and empty fragments cannot bypass endpoint ownership. + if (value.includes('#') || Buffer.byteLength(value, 'utf8') > MAX_PROXY_URL_BYTES) return undefined; + try { + const parsed = new URL(value); + if (parsed.protocol !== 'ws:' || !isLoopbackHost(parsed.hostname) || parsed.username || parsed.password) { + return undefined; + } + // ChromiumDevToolsConnection sends pathname + search and ignores an empty query delimiter. + return `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`; + } catch { + return undefined; + } +} + +function isLoopbackWebSocketUrl(value: string): boolean { + return loopbackWebSocketTransportKey(value) !== undefined; +} + +/** Parse companion-owned ADB forwards without modifying or replacing any tunnel. */ +export function parseAndroidDaemonForwards(output: string): DebuggerDaemonEndpoint[] { + if (Buffer.byteLength(output, 'utf8') > MAX_ADB_FORWARD_OUTPUT_BYTES) { + throw new Error('ADB returned an oversized forwarding table.'); + } + const endpoints: DebuggerDaemonEndpoint[] = []; + const knownEndpoints = new Set(); + const deviceByPort = new Map(); + for (const line of output.split(/\r?\n/)) { + const [deviceId, local, remote, ...extra] = line.trim().split(/\s+/); + if ( + !deviceId || + !/^[\w.:-]{1,128}$/.test(deviceId) || + !local || + !remote || + extra.length > 0 || + remote !== `tcp:${MOBILE_PORT}` + ) { + continue; + } + const match = /^tcp:(\d+)$/.exec(local); + if (!match) continue; + const portText = match[1]; + if (!portText) continue; + const port = Number.parseInt(portText, 10); + if (!Number.isSafeInteger(port) || port < 1 || port > 65_535) continue; + const otherDevice = deviceByPort.get(port); + if (otherDevice !== undefined && otherDevice !== deviceId) { + throw new Error('ADB reported an ambiguous local Valdi daemon forwarding port.'); + } + deviceByPort.set(port, deviceId); + const key = `${deviceId}\0${port.toString()}`; + if (knownEndpoints.has(key)) continue; + if (endpoints.length >= MAX_ANDROID_DAEMON_ENDPOINTS) { + throw new Error(`ADB reported more than ${MAX_ANDROID_DAEMON_ENDPOINTS.toString()} Valdi daemon forwards.`); + } + knownEndpoints.add(key); + endpoints.push({ deviceId, port }); + } + return endpoints.sort( + (left, right) => (left.deviceId ?? '').localeCompare(right.deviceId ?? '') || left.port - right.port, + ); +} + +/** Discover existing tunnels. The companion remains their sole lifecycle owner. */ +export async function discoverAndroidDaemonEndpoints(): Promise { + try { + const { stdout } = await execFile('adb', ['forward', '--list'], { + maxBuffer: MAX_ADB_FORWARD_OUTPUT_BYTES, + timeout: 2500, + }); + return { endpoints: parseAndroidDaemonForwards(stdout), error: null }; + } catch (error) { + return { + endpoints: [], + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function parseDebuggerProxyTarget(candidate: unknown): DebuggerProxyTarget | undefined { + if (typeof candidate !== 'object' || candidate === null || Array.isArray(candidate)) return undefined; + const value = candidate as Record; + const id = boundedString(value['id'], MAX_PROXY_STRING_BYTES); + const webSocketDebuggerUrl = boundedString(value['webSocketDebuggerUrl'], MAX_PROXY_URL_BYTES); + if (!id || !webSocketDebuggerUrl || !isLoopbackWebSocketUrl(webSocketDebuggerUrl)) return undefined; + + const rawMetadata = value['metadata']; + const metadataValue = + typeof rawMetadata === 'object' && rawMetadata !== null && !Array.isArray(rawMetadata) + ? (rawMetadata as Record) + : undefined; + const adapterType = boundedString(value['adapterType'], MAX_PROXY_STRING_BYTES); + const hasAppId = Object.prototype.hasOwnProperty.call(value, 'appId'); + const rawAppId = hasAppId ? value['appId'] : undefined; + const appId = boundedString(rawAppId, MAX_PROXY_STRING_BYTES); + if (hasAppId && (appId === undefined || appId.trim().length === 0)) return undefined; + const title = boundedString(value['title'], MAX_PROXY_STRING_BYTES); + const deviceId = boundedString(metadataValue?.['deviceId'], MAX_PROXY_STRING_BYTES); + const deviceName = boundedString(metadataValue?.['deviceName'], MAX_PROXY_STRING_BYTES); + return { + id, + webSocketDebuggerUrl, + ...(adapterType === undefined ? {} : { adapterType }), + ...(appId === undefined ? {} : { appId }), + ...(title === undefined ? {} : { title }), + ...(deviceId === undefined && deviceName === undefined + ? {} + : { + metadata: { + ...(deviceId === undefined ? {} : { deviceId }), + ...(deviceName === undefined ? {} : { deviceName }), + }, + }), + }; +} + +/** Only consume loopback proxy metadata; never trust arbitrary URLs advertised by a device. */ +export async function discoverDebuggerProxyTargets(port: number): Promise { + return await new Promise((resolve, reject) => { + let settled = false; + let body = ''; + let bodyBytes = 0; + const finishWithError = (error: Error): void => { + if (settled) return; + settled = true; + reject(error); + }; + const request = http.get(`http://127.0.0.1:${port.toString()}/json/list`, response => { + response.setEncoding('utf8'); + response.on('data', (chunk: string) => { + bodyBytes += Buffer.byteLength(chunk, 'utf8'); + if (bodyBytes > MAX_PROXY_RESPONSE_BYTES) { + request.destroy(new Error('The Valdi debugging proxy returned an oversized target list.')); + return; + } + body += chunk; + }); + response.once('error', error => finishWithError(error)); + response.once('end', () => { + if (settled) return; + if (response.statusCode !== 200) { + finishWithError(new Error(`The Valdi debugging proxy returned HTTP ${String(response.statusCode)}.`)); + return; + } + try { + const parsed: unknown = JSON.parse(body); + if (!Array.isArray(parsed)) { + finishWithError(new Error('The Valdi debugging proxy did not return a target list.')); + return; + } + if (parsed.length > MAX_PROXY_TARGETS) { + finishWithError( + new Error(`The Valdi debugging proxy returned more than ${MAX_PROXY_TARGETS.toString()} targets.`), + ); + return; + } + const targets = parsed + .map(candidate => parseDebuggerProxyTarget(candidate)) + .filter((candidate): candidate is DebuggerProxyTarget => candidate !== undefined); + settled = true; + resolve(targets); + } catch (error) { + finishWithError(error instanceof Error ? error : new Error('The debugging proxy returned invalid JSON.')); + } + }); + }); + request.setTimeout(2500, () => request.destroy(new Error('Timed out discovering Valdi debugging proxy targets.'))); + request.once('error', error => finishWithError(error)); + }); +} + +function nativeTarget( + port: DebuggerPortStatus, + client: DebuggerDaemonClient, + context: RemoteContext, +): DebuggerTargetDescriptor { + if (!Number.isSafeInteger(port.port) || port.port < 1 || port.port > 65_535) { + throw new Error('Debugger target discovery returned an invalid daemon port.'); + } + if (port.deviceId !== undefined) assertBoundedIdentityString(port.deviceId, 'device ID', false); + assertBoundedIdentityString(client.client_id, 'client ID', false); + assertBoundedIdentityString(client.application_id, 'application ID', false); + assertBoundedIdentityString(client.platform, 'platform', true); + assertBoundedIdentityString(context.id, 'context ID', false); + assertBoundedIdentityString(context.rootComponentName, 'root component name', true); + const platform = platformFromValue(client.platform || port.portName); + return { + applicationId: client.application_id, + attachable: true, + capabilities: NATIVE_CAPABILITIES, + clientId: client.client_id, + contextId: context.id, + ...(port.deviceId === undefined ? {} : { deviceId: port.deviceId }), + id: opaqueTargetId('valdi-daemon', [ + platform, + client.platform, + port.portName, + port.deviceId ?? null, + port.port, + client.client_id, + client.application_id, + context.id, + ]), + identityMode: DebuggerTargetIdentityMode.TargetId, + name: context.rootComponentName || client.application_id || `Client ${client.client_id}`, + platform, + port: port.port, + proxyPort: port.port, + state: DebuggerTargetState.Available, + transport: DebuggerTargetTransport.ValdiDaemon, + }; +} + +function proxyTargetPlatform(target: DebuggerProxyTarget): DebuggerTargetPlatform { + const deviceId = target.metadata?.deviceId; + if (deviceId === undefined) return DebuggerTargetPlatform.Unknown; + assertBoundedIdentityString(deviceId, 'proxy device ID', false); + + const adapterType = target.adapterType; + if (adapterType === 'android' || adapterType === '_android' || adapterType === `_android_${deviceId}`) { + return DebuggerTargetPlatform.Android; + } + if (adapterType === '_ios' || adapterType === `_ios_${deviceId}`) { + return DebuggerTargetPlatform.IOS; + } + return DebuggerTargetPlatform.Unknown; +} + +function waitingProxyTarget(target: DebuggerProxyTarget, platform: DebuggerTargetPlatform): DebuggerTargetDescriptor { + const deviceId = target.metadata?.deviceId; + const applicationId = target.appId ?? deviceId ?? 'unknown'; + return { + applicationId, + attachable: false, + capabilities: [DebuggerTargetCapability.JavaScriptDebugger], + ...(deviceId === undefined ? {} : { deviceId }), + id: opaqueTargetId('javascript-proxy', [ + platform, + target.adapterType ?? null, + target.id, + target.appId ?? null, + deviceId ?? null, + target.webSocketDebuggerUrl, + ]), + identityMode: DebuggerTargetIdentityMode.TargetId, + javascriptDebuggerUrl: target.webSocketDebuggerUrl, + name: target.title ?? target.metadata?.deviceName ?? deviceId ?? target.appId ?? 'JavaScript runtime', + platform, + state: DebuggerTargetState.Waiting, + transport: DebuggerTargetTransport.ChromiumCDP, + }; +} + +function assertUniqueTargetIds(targets: readonly DebuggerTargetDescriptor[]): void { + const ids = new Set(); + for (const target of targets) { + if (ids.has(target.id)) { + throw new Error('Debugger target discovery produced an ambiguous target identity.'); + } + ids.add(target.id); + } +} + +/** Merge independently discovered native, explicit web-preview, and JS-debugger targets. */ +export function buildDebuggerTargetRegistry(options: DebuggerTargetRegistryOptions): DebuggerTargetDescriptor[] { + if (options.ports.length > MAX_REGISTRY_PORTS) { + throw new Error(`Debugger target discovery exceeded ${MAX_REGISTRY_PORTS.toString()} daemon endpoints.`); + } + if (options.proxyTargets.length > MAX_PROXY_TARGETS || options.webPreviewTargets.length > 1) { + throw new Error('Debugger target discovery exceeded its bounded target sources.'); + } + const targets: DebuggerTargetDescriptor[] = []; + for (const port of options.ports) { + if (!port.connected) continue; + for (const client of port.clients) { + for (const context of client.contexts) { + if (targets.length >= MAX_REGISTRY_TARGETS) { + throw new Error(`Debugger target discovery exceeded ${MAX_REGISTRY_TARGETS.toString()} targets.`); + } + targets.push(nativeTarget(port, client, context)); + } + } + } + + if (targets.length + options.webPreviewTargets.length > MAX_REGISTRY_TARGETS) { + throw new Error(`Debugger target discovery exceeded ${MAX_REGISTRY_TARGETS.toString()} targets.`); + } + targets.push(...options.webPreviewTargets); + + const claimedProxyTransportKeys = new Set(); + for (const proxyTarget of options.proxyTargets) { + const proxyTransportKey = loopbackWebSocketTransportKey(proxyTarget.webSocketDebuggerUrl); + if (proxyTransportKey === undefined) { + throw new Error('Debugger proxy discovery returned a non-loopback target URL.'); + } + if (claimedProxyTransportKeys.has(proxyTransportKey)) { + throw new Error('Debugger proxy discovery produced duplicate JavaScript transport endpoint ownership.'); + } + claimedProxyTransportKeys.add(proxyTransportKey); + const platform = proxyTargetPlatform(proxyTarget); + if (platform === DebuggerTargetPlatform.Unknown) continue; + const deviceId = proxyTarget.metadata?.deviceId; + const deviceScopedAndroidPlaceholder = + platform === DebuggerTargetPlatform.Android && + deviceId !== undefined && + proxyTarget.adapterType === `_android_${deviceId}` && + proxyTarget.appId !== undefined && + proxyTarget.appId === deviceId; + const matchingNativeTargets = targets.filter(candidate => { + if (candidate.transport !== DebuggerTargetTransport.ValdiDaemon || deviceId === undefined) return false; + if (candidate.deviceId !== deviceId || candidate.platform !== platform) return false; + if (deviceScopedAndroidPlaceholder) return true; + return proxyTarget.appId === undefined || candidate.applicationId === proxyTarget.appId; + }); + if (matchingNativeTargets.length === 1) { + const existing = matchingNativeTargets[0]; + if (!existing) throw new Error('Debugger proxy discovery lost a unique native target match.'); + if (existing.capabilities.includes(DebuggerTargetCapability.JavaScriptDebugger)) { + throw new Error('Debugger proxy discovery produced an ambiguous JavaScript target identity.'); + } + const index = targets.indexOf(existing); + targets[index] = { + ...existing, + capabilities: [...existing.capabilities, DebuggerTargetCapability.JavaScriptDebugger], + javascriptDebuggerUrl: proxyTarget.webSocketDebuggerUrl, + }; + continue; + } + if (targets.length >= MAX_REGISTRY_TARGETS) { + throw new Error(`Debugger target discovery exceeded ${MAX_REGISTRY_TARGETS.toString()} targets.`); + } + targets.push(waitingProxyTarget(proxyTarget, platform)); + } + + assertUniqueTargetIds(targets); + return targets.sort( + (left, right) => left.platform.localeCompare(right.platform) || left.name.localeCompare(right.name), + ); +} diff --git a/npm_modules/cli/src/utils/daemonClient.spec.ts b/npm_modules/cli/src/utils/daemonClient.spec.ts index 3267c774..bf2fa967 100644 --- a/npm_modules/cli/src/utils/daemonClient.spec.ts +++ b/npm_modules/cli/src/utils/daemonClient.spec.ts @@ -1,6 +1,6 @@ import 'jasmine'; import { EventEmitter } from 'node:events'; -import type { Socket } from 'node:net'; +import net, { type Socket } from 'node:net'; import { DaemonConnection, DaemonMsgType, @@ -8,6 +8,8 @@ import { MAX_DAEMON_INNER_PAYLOAD_BYTES, MAX_DAEMON_PACKET_PAYLOAD_BYTES, MAX_DAEMON_TRACE_PAYLOAD_BYTES, + MOBILE_PORT, + connectToDaemon, } from './daemonClient'; const TEST_MAGIC = Buffer.from([0x33, 0xc6, 0x00, 0x01]); @@ -206,6 +208,63 @@ function makeTraceStopResponse(trace: Record): (messageType: nu }); } +interface MockDaemonServer { + readonly port: number; + readonly server: net.Server; + readonly sockets: Set; +} + +async function startMockDaemonServer(): Promise { + const sockets = new Set(); + const server = net.createServer(socket => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + return await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + if (typeof address !== 'object' || address === null) { + server.close(() => reject(new Error('Could not allocate a mock Valdi daemon port.'))); + return; + } + resolve({ port: address.port, server, sockets }); + }); + }); +} + +async function stopMockDaemonServer(mock: MockDaemonServer): Promise { + for (const socket of mock.sockets) socket.destroy(); + await new Promise((resolve, reject) => { + mock.server.close(error => (error ? reject(error) : resolve())); + }); +} + +describe('Valdi daemon connection endpoints', () => { + it('connects through existing per-device tunnels without replacing their ADB forwards', async () => { + const first = await startMockDaemonServer(); + const second = await startMockDaemonServer(); + try { + const [firstConnection, secondConnection] = await Promise.all([ + connectToDaemon({ autoForward: false, deviceId: 'emulator-5554', port: first.port }), + connectToDaemon({ autoForward: false, deviceId: 'phone-123', port: second.port }), + ]); + firstConnection.close(); + secondConnection.close(); + + expect(first.port).not.toBe(second.port); + } finally { + await Promise.all([stopMockDaemonServer(first), stopMockDaemonServer(second)]); + } + }); + + it('rejects unsafe Android serials before constructing an ADB command', async () => { + await expectAsync( + connectToDaemon({ autoForward: true, deviceId: 'emulator-5554;unsafe', port: MOBILE_PORT }), + ).toBeRejectedWithError('Android device serial contains unsupported characters.'); + }); +}); + describe('DaemonConnection', () => { it('surfaces runtime error responses from debugger requests', async () => { const socket = new TestResponseSocket(-1, { message: 'Heap dump failed.' }); diff --git a/npm_modules/cli/src/utils/daemonClient.ts b/npm_modules/cli/src/utils/daemonClient.ts index e8b2a568..ea047e25 100644 --- a/npm_modules/cli/src/utils/daemonClient.ts +++ b/npm_modules/cli/src/utils/daemonClient.ts @@ -94,6 +94,13 @@ export interface RemoteContext { rootComponentName: string; } +/** An existing platform tunnel must not be replaced by generic ADB forwarding. */ +export interface DaemonConnectionEndpoint { + readonly autoForward: boolean; + readonly deviceId?: string; + readonly port: number; +} + export interface PerformanceTraceStatusRequestBody extends Record { contextId?: string; } @@ -167,8 +174,8 @@ function encodePacket(json: object): Buffer { // In the direct-to-device protocol we are "client 1" (non-zero required by device JS check). const DIRECT_CLIENT_ID = 1; const ADB_FORWARD_REFRESH_INTERVAL_MS = 5000; -const adbForwardedAtByPort = new Map(); -const adbForwardRefreshByPort = new Map>(); +const adbForwardedAtByEndpoint = new Map(); +const adbForwardRefreshByEndpoint = new Map>(); interface PendingRequest { resolve: (value: Record) => void; @@ -509,6 +516,13 @@ export class DaemonConnection { * request immediately on connect; we respond automatically and this resolves when done). */ configure(): Promise { + return this.configureWithTimeout(5000); + } + + configureWithTimeout(timeoutMs: number): Promise { + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 5000) { + return Promise.reject(new Error('Valdi daemon configure timeout must be between 1 and 5000 milliseconds.')); + } if (this.configureData) return Promise.resolve(); return new Promise((resolve, reject) => { const timer = setTimeout(() => { @@ -519,7 +533,7 @@ export class DaemonConnection { `Is the hot-reloader actually running and connected?`, ), ); - }, 5_000); + }, timeoutMs); this.configureReady = { resolve: () => { clearTimeout(timer); @@ -627,7 +641,11 @@ export class DaemonConnection { } async listContexts(clientId: string): Promise { - const resp = await this.forwardAndWait(clientId, DaemonMsgType.LIST_CONTEXTS_REQUEST, {}); + return await this.listContextsWithTimeout(clientId, 15_000); + } + + async listContextsWithTimeout(clientId: string, timeoutMs: number): Promise { + const resp = await this.forwardAndWait(clientId, DaemonMsgType.LIST_CONTEXTS_REQUEST, {}, timeoutMs); return (resp['body'] ?? []) as RemoteContext[]; } @@ -753,39 +771,54 @@ export class DaemonConnection { // ─── Factory ───────────────────────────────────────────────────────────────── -async function tryAdbForward(port: number): Promise { - const lastForwardedAt = adbForwardedAtByPort.get(port); +function adbForwardKey(endpoint: DaemonConnectionEndpoint): string { + return `${endpoint.deviceId ?? ''}\0${endpoint.port.toString()}`; +} + +async function tryAdbForward(endpoint: DaemonConnectionEndpoint): Promise { + if (endpoint.deviceId !== undefined && !/^[\w.:-]{1,128}$/.test(endpoint.deviceId)) { + throw new CliError('Android device serial contains unsupported characters.'); + } + const key = adbForwardKey(endpoint); + const lastForwardedAt = adbForwardedAtByEndpoint.get(key); if (lastForwardedAt !== undefined && Date.now() - lastForwardedAt < ADB_FORWARD_REFRESH_INTERVAL_MS) return; - const pendingRefresh = adbForwardRefreshByPort.get(port); + const pendingRefresh = adbForwardRefreshByEndpoint.get(key); if (pendingRefresh) { await pendingRefresh; return; } - const refresh = refreshAdbForward(port); - adbForwardRefreshByPort.set(port, refresh); + const refresh = refreshAdbForward(endpoint, key); + adbForwardRefreshByEndpoint.set(key, refresh); try { await refresh; } finally { - if (adbForwardRefreshByPort.get(port) === refresh) adbForwardRefreshByPort.delete(port); + if (adbForwardRefreshByEndpoint.get(key) === refresh) adbForwardRefreshByEndpoint.delete(key); } } -async function refreshAdbForward(port: number): Promise { +async function refreshAdbForward(endpoint: DaemonConnectionEndpoint, key: string): Promise { try { - await runCliCommand(`adb forward tcp:${port} tcp:${port}`); - adbForwardedAtByPort.set(port, Date.now()); + const deviceSelector = endpoint.deviceId === undefined ? '' : `-s ${endpoint.deviceId} `; + await runCliCommand(`adb ${deviceSelector}forward tcp:${endpoint.port} tcp:${endpoint.port}`); + adbForwardedAtByEndpoint.set(key, Date.now()); } catch { // ADB is optional for standalone targets; retry the next time a mobile connection is requested. } } -export async function connectToDaemon(port: number = DEFAULT_PORT): Promise { +export async function connectToDaemon(target?: number | DaemonConnectionEndpoint): Promise { + const resolvedTarget = target ?? DEFAULT_PORT; + const endpoint: DaemonConnectionEndpoint = + typeof resolvedTarget === 'number' + ? { autoForward: resolvedTarget !== STANDALONE_PORT, port: resolvedTarget } + : resolvedTarget; + const port = endpoint.port; // Only set up adb forwarding for mobile ports — standalone macOS apps listen - // directly on localhost and adb forward would shadow them. - if (port !== STANDALONE_PORT) { - await tryAdbForward(port); + // directly on localhost and companion-owned device tunnels must remain intact. + if (endpoint.autoForward && port !== STANDALONE_PORT) { + await tryAdbForward(endpoint); } return new Promise((resolve, reject) => { const socket = net.createConnection({ port, host: '127.0.0.1' });