diff --git a/README.md b/README.md index 19c86a889..5dfcd8f44 100644 --- a/README.md +++ b/README.md @@ -59,15 +59,6 @@ In practice, most work follows the same pattern: In non-JSON mode, core mutating commands print a short success acknowledgment so agents and humans can distinguish successful actions from dropped or silent no-ops. -## Performance Metrics - -`agent-device perf --json` (alias: `metrics --json`) returns session-scoped metrics data. - -- Startup timing is available on iOS and Android from `open` command round-trip sampling. -- Android app sessions also sample CPU (`adb shell dumpsys cpuinfo`) and memory (`adb shell dumpsys meminfo `) when the session has an active app package context. -- Apple app sessions on macOS and iOS simulators sample CPU and memory from process snapshots resolved from the active app bundle ID. -- Physical iOS devices sample CPU and memory from a short `xcrun xctrace` Activity Monitor capture against the connected device, so `perf` can take a few seconds longer there than on simulators or macOS. - ## Where To Go Next For people: diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index 91a5068e5..ad63629c6 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -70,3 +70,4 @@ Use this skill as a router with mandatory defaults. Read this file first. For no - Need screenshots, diff, recording, replay maintenance, or perf data: [references/verification.md](references/verification.md) - Need desktop surfaces, menu bar behavior, or macOS-specific interaction rules: [references/macos-desktop.md](references/macos-desktop.md) - Need remote HTTP transport, `--remote-config` launches, or tenant leases on a remote macOS host: [references/remote-tenancy.md](references/remote-tenancy.md) + This includes remote React Native runs where `agent-device` now prepares Metro locally and manages the local Metro companion tunnel automatically. diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 6ca54a854..5e07cfabc 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -24,6 +24,8 @@ agent-device open com.example.myapp --remote-config ./agent-device.remote.json - ``` - This is the preferred remote launch path for sandbox or cloud agents. +- `agent-device` prepares local Metro and auto-starts the local Metro companion tunnel when the remote bridge needs a path back to the developer machine. +- `close --remote-config ...` cleans up the managed companion process for that project/profile, but leaves the developer’s Metro server running. - For Android React Native relaunch flows, install or reinstall the APK first, then relaunch by installed package name. - Do not use `open --relaunch`; remote runtime hints are applied through the installed app sandbox. diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 17b016e6f..62184e8a4 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -461,6 +461,9 @@ test('open with --remote-config prepares Metro and forwards inline runtime hints publicBaseUrl: 'https://sandbox.example.test', proxyBaseUrl: 'https://proxy.example.test', bearerToken: undefined, + launchUrl: undefined, + companionProfileKey: remoteConfigPath, + companionConsumerKey: undefined, port: 9090, listenHost: undefined, statusHost: undefined, diff --git a/src/__tests__/client-metro-auto-companion.test.ts b/src/__tests__/client-metro-auto-companion.test.ts new file mode 100644 index 000000000..8cca28b6f --- /dev/null +++ b/src/__tests__/client-metro-auto-companion.test.ts @@ -0,0 +1,259 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../client-metro-companion.ts', () => ({ + ensureMetroCompanion: vi.fn(), +})); + +import { ensureMetroCompanion } from '../client-metro-companion.ts'; +import { prepareMetroRuntime } from '../client-metro.ts'; + +afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); +}); + +test('prepareMetroRuntime starts the local companion only after bridge setup needs it', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-auto-companion-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + vi.mocked(ensureMetroCompanion).mockResolvedValue({ + pid: 123, + spawned: true, + statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'), + logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'), + }); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }), + }); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => + JSON.stringify({ + ok: true, + data: { + enabled: true, + base_url: 'https://proxy.example.test', + status_url: 'https://proxy.example.test/status', + bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + ios_runtime: { + metro_host: '127.0.0.1', + metro_port: 8081, + metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios', + }, + android_runtime: { + metro_host: '10.0.2.2', + metro_port: 8081, + metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android', + }, + upstream: { + bundle_url: + 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', + host: '127.0.0.1', + port: 8081, + status_url: 'http://127.0.0.1:8081/status', + }, + probe: { + reachable: true, + status_code: 200, + latency_ms: 5, + detail: 'ok', + }, + }, + }), + }); + vi.stubGlobal('fetch', fetchMock); + + try { + const result = await prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + }); + + assert.equal(result.started, false); + assert.equal(result.reused, true); + assert.equal(result.bridge?.enabled, true); + assert.equal( + result.iosRuntime.bundleUrl, + 'https://proxy.example.test/index.bundle?platform=ios', + ); + assert.equal( + result.androidRuntime.bundleUrl, + 'https://proxy.example.test/index.bundle?platform=android', + ); + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], { + projectRoot, + serverBaseUrl: 'https://proxy.example.test', + bearerToken: 'shared-token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: undefined, + profileKey: undefined, + consumerKey: undefined, + env: process.env, + }); + assert.equal(fetchMock.mock.calls.length, 3); + assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge'); + assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('prepareMetroRuntime preserves the initial bridge error if companion startup fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-error-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-auto-companion-error-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + vi.mocked(ensureMetroCompanion).mockRejectedValue(new Error('companion startup failed')); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockRejectedValueOnce(new Error('initial bridge auth failed')); + vi.stubGlobal('fetch', fetchMock); + + try { + await assert.rejects( + () => + prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }), + (error: unknown) => { + assert(error instanceof Error); + assert.match(error.message, /bridgeError=companion startup failed/); + assert.match(error.message, /initialBridgeError=initial bridge auth failed/); + assert.doesNotMatch(error.message, /metroCompanionLog=/); + return true; + }, + ); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-auto-companion-non-retryable-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + vi.mocked(ensureMetroCompanion).mockResolvedValue({ + pid: 123, + spawned: true, + statePath: path.join(projectRoot, '.agent-device', 'metro-companion.json'), + logPath: path.join(projectRoot, '.agent-device', 'metro-companion.log'), + }); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 409, + text: async () => JSON.stringify({ ok: false, error: 'Metro companion is not connected' }), + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => JSON.stringify({ ok: false, error: 'invalid token' }), + }); + vi.stubGlobal('fetch', fetchMock); + vi.useFakeTimers(); + + try { + let settled: unknown = 'pending'; + const preparePromise = prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }); + void preparePromise.then( + () => { + settled = 'resolved'; + }, + (error) => { + settled = error; + }, + ); + + await vi.advanceTimersByTimeAsync(1); + + assert.notEqual(settled, 'pending'); + assert(settled instanceof Error); + assert.match(settled.message, /bridgeError=\/api\/metro\/bridge failed \(401\)/); + assert.match(settled.message, /initialBridgeError=\/api\/metro\/bridge failed \(409\)/); + assert.match(settled.message, /metroCompanionLog=.*metro-companion\.log/); + assert.equal(fetchMock.mock.calls.length, 3); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/client-metro-companion-worker.test.ts b/src/__tests__/client-metro-companion-worker.test.ts new file mode 100644 index 000000000..48a498ed5 --- /dev/null +++ b/src/__tests__/client-metro-companion-worker.test.ts @@ -0,0 +1,498 @@ +import { spawn } from 'node:child_process'; +import assert from 'node:assert/strict'; +import crypto from 'node:crypto'; +import http from 'node:http'; +import type { Duplex } from 'node:stream'; +import { setTimeout as delay } from 'node:timers/promises'; +import { afterEach, test } from 'vitest'; + +type Deferred = { + promise: Promise; + resolve: (value: T) => void; + reject: (error: unknown) => void; +}; + +type CloseFrame = { + code?: number; + reason?: string; +}; + +function createDeferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { promise, resolve, reject }; +} + +function waitFor(promise: Promise, timeoutMs: number, label: string): Promise { + return Promise.race([ + promise, + delay(timeoutMs).then(() => { + throw new Error(`Timed out waiting for ${label}.`); + }), + ]); +} + +function encodeTextFrame(text: string): Buffer { + const payload = Buffer.from(text, 'utf8'); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x81, payload.length]), payload]); + } + return Buffer.concat([ + Buffer.from([0x81, 126, payload.length >> 8, payload.length & 0xff]), + payload, + ]); +} + +function encodeCloseFrame(code = 1000, reason = ''): Buffer { + const reasonBuffer = Buffer.from(reason, 'utf8'); + const payload = Buffer.allocUnsafe(2 + reasonBuffer.length); + payload.writeUInt16BE(code, 0); + reasonBuffer.copy(payload, 2); + if (payload.length < 126) { + return Buffer.concat([Buffer.from([0x88, payload.length]), payload]); + } + return Buffer.concat([ + Buffer.from([0x88, 126, payload.length >> 8, payload.length & 0xff]), + payload, + ]); +} + +function attachWebSocketFrameParser( + socket: NodeJS.WritableStream & NodeJS.EventEmitter, + onText: (text: string) => void, + onClose?: (frame: CloseFrame) => void, +): void { + let pending = Buffer.alloc(0); + socket.on('data', (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + let offset = 0; + while (offset + 2 <= pending.length) { + const first = pending[offset++]; + const second = pending[offset++]; + const opcode = first & 0x0f; + let length = second & 0x7f; + if (length === 126) { + if (offset + 2 > pending.length) { + offset -= 4; + break; + } + length = pending.readUInt16BE(offset); + offset += 2; + } else if (length === 127) { + throw new Error('Large WebSocket frames are not supported in this test.'); + } + const masked = (second & 0x80) !== 0; + const maskLength = masked ? 4 : 0; + if (offset + maskLength + length > pending.length) { + offset -= length === 126 ? 4 : 2; + break; + } + const mask = masked ? pending.subarray(offset, offset + 4) : null; + offset += maskLength; + let payload = pending.subarray(offset, offset + length); + offset += length; + if (masked && mask) { + payload = Buffer.from(payload); + for (let index = 0; index < payload.length; index += 1) { + payload[index] ^= mask[index % 4]; + } + } + if (opcode === 0x1) { + onText(payload.toString('utf8')); + continue; + } + if (opcode === 0x8) { + if (!onClose) continue; + if (payload.length >= 2) { + onClose({ + code: payload.readUInt16BE(0), + reason: payload.subarray(2).toString('utf8'), + }); + } else { + onClose({}); + } + } + } + pending = pending.subarray(offset); + }); +} + +async function listen(server: http.Server): Promise { + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + server.off('error', reject); + resolve(); + }); + }); + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Expected TCP server address.'); + } + return address.port; +} + +async function closeServer(server: http.Server): Promise { + if (!server.listening) return; + await new Promise((resolve, reject) => { + server.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); +} + +async function stopChild(child: ReturnType): Promise { + if (child.exitCode !== null || child.killed) return; + child.kill('SIGTERM'); + const exited = await Promise.race([ + new Promise((resolve) => child.once('close', () => resolve(true))), + delay(2_000).then(() => false), + ]); + if (exited) return; + child.kill('SIGKILL'); + await new Promise((resolve) => child.once('close', () => resolve())); +} + +const cleanupTasks: Array<() => Promise> = []; + +afterEach(async () => { + while (cleanupTasks.length > 0) { + const task = cleanupTasks.pop(); + if (!task) continue; + await task(); + } +}); + +test('metro companion worker proxies websocket frames to the local upstream server', async () => { + const upstreamMessage = createDeferred(); + const bridgePong = createDeferred(); + const bridgeSocketReady = createDeferred(); + const bridgeOpen = createDeferred(); + const bridgeFrame = createDeferred(); + const bridgeClose = createDeferred(); + let upstreamSocketRef: Duplex | null = null; + let bridgeSocketRef: Duplex | null = null; + + const upstreamServer = http.createServer((_, res) => { + res.writeHead(404); + res.end('not found'); + }); + upstreamServer.on('upgrade', (req, socket) => { + if (req.url !== '/echo') { + socket.destroy(); + return; + } + upstreamSocketRef = socket; + const key = req.headers['sec-websocket-key']; + if (typeof key !== 'string') { + socket.destroy(); + return; + } + const accept = crypto + .createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '\r\n', + ].join('\r\n'), + ); + attachWebSocketFrameParser( + socket, + (text) => { + upstreamMessage.resolve(text); + socket.write(encodeTextFrame(text)); + }, + () => { + socket.write(encodeCloseFrame(1000, 'upstream done')); + socket.end(); + }, + ); + }); + cleanupTasks.push(() => closeServer(upstreamServer)); + cleanupTasks.push(async () => { + upstreamSocketRef?.destroy(); + }); + const upstreamPort = await listen(upstreamServer); + + const bridgeServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + ok: true, + data: { ws_url: `ws://127.0.0.1:${bridgePort}/bridge` }, + }), + ); + }); + return; + } + res.writeHead(404); + res.end('not found'); + }); + bridgeServer.on('upgrade', (req, socket) => { + if (req.url !== '/bridge') { + socket.destroy(); + return; + } + bridgeSocketRef = socket; + const key = req.headers['sec-websocket-key']; + if (typeof key !== 'string') { + socket.destroy(); + return; + } + const accept = crypto + .createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '\r\n', + ].join('\r\n'), + ); + bridgeSocketReady.resolve(socket); + + attachWebSocketFrameParser(socket, (text) => { + const message = JSON.parse(text) as + | { type: 'pong'; timestamp: number } + | { type: 'ws-open-result'; streamId: string; success: boolean } + | { type: 'ws-frame'; streamId: string; dataBase64: string } + | { type: 'ws-close'; streamId: string; code?: number; reason?: string }; + if (message.type === 'pong') { + bridgePong.resolve(); + return; + } + if (message.type === 'ws-open-result' && message.success) { + bridgeOpen.resolve(); + return; + } + if (message.type === 'ws-frame') { + bridgeFrame.resolve(Buffer.from(message.dataBase64, 'base64').toString('utf8')); + return; + } + if (message.type === 'ws-close') { + bridgeClose.resolve({ code: message.code, reason: message.reason }); + } + }); + + socket.write(encodeTextFrame(JSON.stringify({ type: 'ping', timestamp: Date.now() }))); + socket.write( + encodeTextFrame( + JSON.stringify({ + type: 'ws-open', + streamId: 'stream-1', + path: '/echo', + headers: {}, + }), + ), + ); + }); + cleanupTasks.push(() => closeServer(bridgeServer)); + cleanupTasks.push(async () => { + bridgeSocketRef?.destroy(); + }); + const bridgePort = await listen(bridgeServer); + + const companion = spawn( + process.execPath, + [ + '--experimental-strip-types', + 'src/client-metro-companion.ts', + '--agent-device-run-metro-companion', + ], + { + cwd: process.cwd(), + env: { + ...process.env, + AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`, + AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', + AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let stderr = ''; + companion.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + cleanupTasks.push(() => stopChild(companion)); + + const earlyExit = new Promise((_, reject) => { + companion.once('exit', (code, signal) => { + reject( + new Error( + `Metro companion exited unexpectedly with code=${String(code)} signal=${String(signal)} stderr=${stderr}`, + ), + ); + }); + }); + + const bridgeSocket = await Promise.race([ + waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'), + earlyExit, + ]); + await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]); + await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]); + bridgeSocket.write( + encodeTextFrame( + JSON.stringify({ + type: 'ws-frame', + streamId: 'stream-1', + dataBase64: Buffer.from('hello websocket', 'utf8').toString('base64'), + binary: false, + }), + ), + ); + await Promise.race([waitFor(upstreamMessage.promise, 5_000, 'upstream message'), earlyExit]); + const echoedMessage = await Promise.race([ + waitFor(bridgeFrame.promise, 5_000, 'bridge echoed frame'), + earlyExit, + ]); + bridgeSocket.write( + encodeTextFrame( + JSON.stringify({ + type: 'ws-close', + streamId: 'stream-1', + code: 1000, + reason: 'bridge done', + }), + ), + ); + const closeFrame = await Promise.race([ + waitFor(bridgeClose.promise, 5_000, 'bridge close frame'), + earlyExit, + ]); + + assert.equal(echoedMessage, 'hello websocket'); + assert.equal(closeFrame.code, 1000); +}); + +test('metro companion worker reconnects after the bridge closes immediately after open', async () => { + const bridgeReconnect = createDeferred(); + let bridgeConnections = 0; + let bridgePort = 0; + let bridgeSocketRef: Duplex | null = null; + + const localServer = http.createServer((_, res) => { + res.writeHead(404); + res.end('not found'); + }); + cleanupTasks.push(() => closeServer(localServer)); + const localPort = await listen(localServer); + + const bridgeServer = http.createServer((req, res) => { + const url = new URL(req.url || '/', 'http://127.0.0.1'); + if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') { + req.resume(); + req.on('end', () => { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end( + JSON.stringify({ + ok: true, + data: { ws_url: `ws://127.0.0.1:${bridgePort}/bridge` }, + }), + ); + }); + return; + } + res.writeHead(404); + res.end('not found'); + }); + bridgeServer.on('upgrade', (req, socket) => { + if (req.url !== '/bridge') { + socket.destroy(); + return; + } + socket.on('error', () => { + // The first bridge socket is expected to drop immediately to exercise reconnect handling. + }); + const key = req.headers['sec-websocket-key']; + if (typeof key !== 'string') { + socket.destroy(); + return; + } + const accept = crypto + .createHash('sha1') + .update(`${key}258EAFA5-E914-47DA-95CA-C5AB0DC85B11`) + .digest('base64'); + socket.write( + [ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '\r\n', + ].join('\r\n'), + ); + bridgeSocketRef = socket; + bridgeConnections += 1; + if (bridgeConnections === 1) { + socket.end(); + return; + } + bridgeReconnect.resolve(); + }); + cleanupTasks.push(() => closeServer(bridgeServer)); + cleanupTasks.push(async () => { + bridgeSocketRef?.destroy(); + }); + const listenedBridgePort = await listen(bridgeServer); + bridgePort = listenedBridgePort; + + const companion = spawn( + process.execPath, + [ + '--experimental-strip-types', + 'src/client-metro-companion.ts', + '--agent-device-run-metro-companion', + ], + { + cwd: process.cwd(), + env: { + ...process.env, + AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`, + AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', + AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + let stderr = ''; + companion.stderr.on('data', (chunk) => { + stderr += chunk.toString(); + }); + cleanupTasks.push(() => stopChild(companion)); + + const earlyExit = new Promise((_, reject) => { + companion.once('exit', (code, signal) => { + reject( + new Error( + `Metro companion exited unexpectedly with code=${String(code)} signal=${String(signal)} stderr=${stderr}`, + ), + ); + }); + }); + + await Promise.race([waitFor(bridgeReconnect.promise, 5_000, 'bridge reconnect'), earlyExit]); + + assert.equal(bridgeConnections, 2); +}); diff --git a/src/__tests__/client-metro-companion.test.ts b/src/__tests__/client-metro-companion.test.ts new file mode 100644 index 000000000..c83d1100c --- /dev/null +++ b/src/__tests__/client-metro-companion.test.ts @@ -0,0 +1,167 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../utils/exec.ts', () => ({ + runCmdDetached: vi.fn(), +})); + +vi.mock('../utils/process-identity.ts', () => ({ + isProcessAlive: vi.fn(), + readProcessCommand: vi.fn(), + readProcessStartTime: vi.fn(), + waitForProcessExit: vi.fn(), +})); + +import { runCmdDetached } from '../utils/exec.ts'; +import { + isProcessAlive, + readProcessCommand, + readProcessStartTime, + waitForProcessExit, +} from '../utils/process-identity.ts'; +import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts'; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +test('companion ownership is profile-scoped and consumer-counted', async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-state-')); + try { + vi.mocked(runCmdDetached).mockReturnValueOnce(111).mockReturnValueOnce(222); + vi.mocked(isProcessAlive).mockReturnValue(true); + vi.mocked(readProcessStartTime).mockImplementation((pid) => + pid === 111 ? 'start-111' : 'start-222', + ); + vi.mocked(readProcessCommand).mockImplementation( + () => `${process.execPath} src/client-metro-companion.ts --agent-device-run-metro-companion`, + ); + vi.mocked(waitForProcessExit).mockResolvedValue(true); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + const stagingFirst = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: 'myapp://staging', + profileKey: '/tmp/staging.json', + consumerKey: 'session-a', + }); + const stagingSecond = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: 'myapp://staging', + profileKey: '/tmp/staging.json', + consumerKey: 'session-b', + }); + const prod = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: 'myapp://prod', + profileKey: '/tmp/prod.json', + consumerKey: 'session-prod', + }); + + assert.equal(stagingFirst.spawned, true); + assert.equal(stagingSecond.spawned, false); + assert.notEqual(stagingFirst.statePath, prod.statePath); + assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 2); + + const stagingState = JSON.parse(fs.readFileSync(stagingFirst.statePath, 'utf8')) as { + consumers: string[]; + }; + assert.deepEqual(stagingState.consumers.sort(), ['session-a', 'session-b']); + + const partialStop = await stopMetroCompanion({ + projectRoot, + profileKey: '/tmp/staging.json', + consumerKey: 'session-a', + }); + assert.equal(partialStop.stopped, false); + assert.equal(killSpy.mock.calls.length, 0); + + const remainingState = JSON.parse(fs.readFileSync(stagingFirst.statePath, 'utf8')) as { + consumers: string[]; + }; + assert.deepEqual(remainingState.consumers, ['session-b']); + + const finalStop = await stopMetroCompanion({ + projectRoot, + profileKey: '/tmp/staging.json', + consumerKey: 'session-b', + }); + assert.equal(finalStop.stopped, true); + assert.equal(killSpy.mock.calls.length, 1); + assert.deepEqual(killSpy.mock.calls[0], [111, 'SIGTERM']); + + const prodStop = await stopMetroCompanion({ + projectRoot, + profileKey: '/tmp/prod.json', + consumerKey: 'session-prod', + }); + assert.equal(prodStop.stopped, true); + assert.equal(killSpy.mock.calls.length, 2); + assert.deepEqual(killSpy.mock.calls[1], [222, 'SIGTERM']); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +}); + +test('launchUrl changes force a companion respawn for the same profile', async () => { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-metro-companion-launch-'), + ); + try { + vi.mocked(runCmdDetached).mockReturnValueOnce(333).mockReturnValueOnce(444); + vi.mocked(isProcessAlive).mockReturnValue(true); + vi.mocked(readProcessStartTime).mockImplementation((pid) => + pid === 333 ? 'start-333' : 'start-444', + ); + vi.mocked(readProcessCommand).mockImplementation( + () => `${process.execPath} src/client-metro-companion.ts --agent-device-run-metro-companion`, + ); + vi.mocked(waitForProcessExit).mockResolvedValue(true); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + const first = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: 'myapp://first', + profileKey: '/tmp/profile.json', + consumerKey: 'session-a', + }); + const second = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + launchUrl: 'myapp://second', + profileKey: '/tmp/profile.json', + consumerKey: 'session-a', + }); + + assert.equal(first.spawned, true); + assert.equal(second.spawned, true); + assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 2); + assert.equal(killSpy.mock.calls.length, 1); + assert.deepEqual(killSpy.mock.calls[0], [333, 'SIGTERM']); + + const state = JSON.parse(fs.readFileSync(second.statePath, 'utf8')) as { + launchUrl?: string; + }; + assert.equal(state.launchUrl, 'myapp://second'); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/close-remote-metro.test.ts b/src/__tests__/close-remote-metro.test.ts new file mode 100644 index 000000000..681db3b5d --- /dev/null +++ b/src/__tests__/close-remote-metro.test.ts @@ -0,0 +1,389 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +vi.mock('../client-metro-companion.ts', () => ({ + stopMetroCompanion: vi.fn(), +})); + +import { closeCommand } from '../cli/commands/open.ts'; +import { stopMetroCompanion } from '../client-metro-companion.ts'; +import type { AgentDeviceClient } from '../client.ts'; +import { resolveCliOptions } from '../utils/cli-options.ts'; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +test('close with remote-config stops the managed Metro companion for that project', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-close-remote-metro-')); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + try { + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test/agent-device', + session: 'adc-android', + platform: 'android', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + const parsed = resolveCliOptions(['close', '--remote-config', remoteConfigPath], { + cwd: tempRoot, + env: process.env, + }); + + const client: AgentDeviceClient = { + devices: { list: async () => [] }, + sessions: { + list: async () => [], + close: async () => ({ + session: 'adc-android', + identifiers: { session: 'adc-android' }, + }), + }, + simulators: { + ensure: async () => { + throw new Error('unexpected call'); + }, + }, + apps: { + install: async () => { + throw new Error('unexpected call'); + }, + reinstall: async () => { + throw new Error('unexpected call'); + }, + installFromSource: async () => { + throw new Error('unexpected call'); + }, + open: async () => { + throw new Error('unexpected call'); + }, + close: async () => { + throw new Error('unexpected call'); + }, + }, + materializations: { + release: async () => { + throw new Error('unexpected call'); + }, + }, + metro: { + prepare: async () => { + throw new Error('unexpected call'); + }, + }, + capture: { + snapshot: async () => { + throw new Error('unexpected call'); + }, + screenshot: async () => { + throw new Error('unexpected call'); + }, + }, + }; + + vi.mocked(stopMetroCompanion).mockResolvedValue({ + stopped: true, + statePath: '/tmp/project/.agent-device/metro-companion.json', + }); + + const handled = await closeCommand({ + positionals: [], + flags: { ...parsed.flags, json: true, shutdown: true }, + client, + }); + + assert.equal(handled, true); + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('close with remote-config still stops the managed Metro companion when close fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-close-remote-metro-fail-')); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + try { + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test/agent-device', + session: 'adc-android', + platform: 'android', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + const parsed = resolveCliOptions(['close', '--remote-config', remoteConfigPath], { + cwd: tempRoot, + env: process.env, + }); + + const client: AgentDeviceClient = { + devices: { list: async () => [] }, + sessions: { + list: async () => [], + close: async () => { + throw new Error('session close failed'); + }, + }, + simulators: { + ensure: async () => { + throw new Error('unexpected call'); + }, + }, + apps: { + install: async () => { + throw new Error('unexpected call'); + }, + reinstall: async () => { + throw new Error('unexpected call'); + }, + installFromSource: async () => { + throw new Error('unexpected call'); + }, + open: async () => { + throw new Error('unexpected call'); + }, + close: async () => { + throw new Error('unexpected call'); + }, + }, + materializations: { + release: async () => { + throw new Error('unexpected call'); + }, + }, + metro: { + prepare: async () => { + throw new Error('unexpected call'); + }, + }, + capture: { + snapshot: async () => { + throw new Error('unexpected call'); + }, + screenshot: async () => { + throw new Error('unexpected call'); + }, + }, + }; + + vi.mocked(stopMetroCompanion).mockResolvedValue({ + stopped: true, + statePath: '/tmp/project/.agent-device/metro-companion.json', + }); + + await assert.rejects( + () => + closeCommand({ + positionals: [], + flags: { ...parsed.flags, json: true, shutdown: true }, + client, + }), + /session close failed/, + ); + + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('close app with remote-config stops the managed Metro companion for that session', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-close-app-remote-metro-')); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + try { + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test/agent-device', + session: 'adc-android', + platform: 'android', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + const parsed = resolveCliOptions( + ['close', 'com.example.demo', '--remote-config', remoteConfigPath], + { + cwd: tempRoot, + env: process.env, + }, + ); + + const client: AgentDeviceClient = { + devices: { list: async () => [] }, + sessions: { + list: async () => [], + close: async () => { + throw new Error('unexpected call'); + }, + }, + simulators: { + ensure: async () => { + throw new Error('unexpected call'); + }, + }, + apps: { + install: async () => { + throw new Error('unexpected call'); + }, + reinstall: async () => { + throw new Error('unexpected call'); + }, + installFromSource: async () => { + throw new Error('unexpected call'); + }, + open: async () => { + throw new Error('unexpected call'); + }, + close: async () => ({ + session: 'adc-android', + identifiers: { session: 'adc-android' }, + }), + }, + materializations: { + release: async () => { + throw new Error('unexpected call'); + }, + }, + metro: { + prepare: async () => { + throw new Error('unexpected call'); + }, + }, + capture: { + snapshot: async () => { + throw new Error('unexpected call'); + }, + screenshot: async () => { + throw new Error('unexpected call'); + }, + }, + }; + + vi.mocked(stopMetroCompanion).mockResolvedValue({ + stopped: true, + statePath: '/tmp/project/.agent-device/metro-companion.json', + }); + + const handled = await closeCommand({ + positionals: ['com.example.demo'], + flags: { ...parsed.flags, json: true, shutdown: true }, + client, + }); + + assert.equal(handled, true); + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('close with remote-config still succeeds when the config file is gone before cleanup', async () => { + const tempRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-close-remote-metro-missing-config-'), + ); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + try { + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test/agent-device', + session: 'adc-android', + platform: 'android', + metroProjectRoot: '/tmp/project', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + const parsed = resolveCliOptions(['close', '--remote-config', remoteConfigPath], { + cwd: tempRoot, + env: process.env, + }); + fs.rmSync(remoteConfigPath); + + const client: AgentDeviceClient = { + devices: { list: async () => [] }, + sessions: { + list: async () => [], + close: async () => ({ + session: 'adc-android', + identifiers: { session: 'adc-android' }, + }), + }, + simulators: { + ensure: async () => { + throw new Error('unexpected call'); + }, + }, + apps: { + install: async () => { + throw new Error('unexpected call'); + }, + reinstall: async () => { + throw new Error('unexpected call'); + }, + installFromSource: async () => { + throw new Error('unexpected call'); + }, + open: async () => { + throw new Error('unexpected call'); + }, + close: async () => { + throw new Error('unexpected call'); + }, + }, + materializations: { + release: async () => { + throw new Error('unexpected call'); + }, + }, + metro: { + prepare: async () => { + throw new Error('unexpected call'); + }, + }, + capture: { + snapshot: async () => { + throw new Error('unexpected call'); + }, + screenshot: async () => { + throw new Error('unexpected call'); + }, + }, + }; + + const handled = await closeCommand({ + positionals: [], + flags: { ...parsed.flags, json: true, shutdown: true }, + client, + }); + + assert.equal(handled, true); + assert.equal(vi.mocked(stopMetroCompanion).mock.calls.length, 0); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 8302de5a7..8ac6202cd 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -1,6 +1,9 @@ import { serializeCloseResult, serializeOpenResult } from '../../client-shared.ts'; +import { stopMetroCompanion } from '../../client-metro-companion.ts'; import { resolveRemoteOpenRuntime } from '../../core/remote-open.ts'; +import { loadRemoteConfigFile, resolveRemoteConfigPath } from '../../utils/remote-config.ts'; import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; +import type { StopMetroCompanionOptions } from '../../client-metro-companion.ts'; import type { ClientCommandHandler } from './router.ts'; export const openCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { @@ -25,9 +28,56 @@ export const openCommand: ClientCommandHandler = async ({ positionals, flags, cl }; export const closeCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const result = positionals[0] - ? await client.apps.close({ app: positionals[0], shutdown: flags.shutdown }) - : await client.sessions.close({ shutdown: flags.shutdown }); + const resolveManagedMetroCompanionStopOptions = (): StopMetroCompanionOptions | null => { + if (!flags.remoteConfig) return null; + const profileKey = resolveRemoteConfigPath({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }); + let remoteConfig; + try { + remoteConfig = loadRemoteConfigFile({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }); + } catch { + return null; + } + if (!remoteConfig.metroProjectRoot || !remoteConfig.metroProxyBaseUrl) { + return null; + } + return { + projectRoot: remoteConfig.metroProjectRoot, + profileKey, + consumerKey: flags.session, + }; + }; + + const managedMetroCompanionStopOptions = resolveManagedMetroCompanionStopOptions(); + + const runWithCompanionCleanup = async (runClose: () => Promise): Promise => { + try { + return await runClose(); + } finally { + try { + if (managedMetroCompanionStopOptions) { + await stopMetroCompanion(managedMetroCompanionStopOptions); + } + } catch { + // Companion cleanup is best-effort and must not turn a successful close into a failure. + } + } + }; + + const result = await runWithCompanionCleanup(async () => { + if (positionals[0]) { + return await client.apps.close({ app: positionals[0], shutdown: flags.shutdown }); + } + return await client.sessions.close({ shutdown: flags.shutdown }); + }); + const data = serializeCloseResult(result); writeCommandMessage(flags, data); return true; diff --git a/src/client-metro-companion-contract.ts b/src/client-metro-companion-contract.ts new file mode 100644 index 000000000..23ba55908 --- /dev/null +++ b/src/client-metro-companion-contract.ts @@ -0,0 +1,44 @@ +export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion'; +export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000; +export const WS_READY_STATE_OPEN = 1; + +export const ENV_SERVER_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL'; +export const ENV_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN'; +export const ENV_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL'; +export const ENV_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL'; + +export type MetroCompanionRequest = + | { type: 'ping'; timestamp: number } + | { + type: 'http-request'; + requestId: string; + method: string; + path: string; + headers?: Record; + bodyBase64?: string; + } + | { + type: 'ws-open'; + streamId: string; + path: string; + headers?: Record; + } + | { + type: 'ws-frame'; + streamId: string; + dataBase64: string; + binary: boolean; + } + | { + type: 'ws-close'; + streamId: string; + code?: number; + reason?: string; + }; + +export type CompanionOptions = { + serverBaseUrl: string; + bearerToken: string; + localBaseUrl: string; + launchUrl?: string; +}; diff --git a/src/client-metro-companion-worker.ts b/src/client-metro-companion-worker.ts new file mode 100644 index 000000000..76e15d9c8 --- /dev/null +++ b/src/client-metro-companion-worker.ts @@ -0,0 +1,305 @@ +import { setTimeout as delay } from 'node:timers/promises'; +import { + ENV_BEARER_TOKEN, + ENV_LAUNCH_URL, + ENV_LOCAL_BASE_URL, + ENV_SERVER_BASE_URL, + METRO_COMPANION_RECONNECT_DELAY_MS, + METRO_COMPANION_RUN_ARG, + WS_READY_STATE_OPEN, +} from './client-metro-companion-contract.ts'; +import type { CompanionOptions, MetroCompanionRequest } from './client-metro-companion-contract.ts'; +import { normalizeBaseUrl } from './utils/url.ts'; + +function createHeaders(serverBaseUrl: string, token: string): Record { + return { + authorization: `Bearer ${token}`, + 'content-type': 'application/json', + ...(serverBaseUrl.includes('ngrok') ? { 'ngrok-skip-browser-warning': '1' } : {}), + }; +} + +async function registerCompanion(options: CompanionOptions): Promise<{ wsUrl: string }> { + const response = await fetch( + `${normalizeBaseUrl(options.serverBaseUrl)}/api/metro/companion/register`, + { + method: 'POST', + headers: createHeaders(options.serverBaseUrl, options.bearerToken), + body: JSON.stringify({ + local_base_url: normalizeBaseUrl(options.localBaseUrl), + ...(options.launchUrl ? { launch_url: options.launchUrl } : {}), + }), + }, + ); + const payload = (await response.json()) as { + ok?: boolean; + data?: { ws_url?: string }; + }; + if (!response.ok || payload.ok !== true || typeof payload.data?.ws_url !== 'string') { + throw new Error(`Failed to register Metro companion: ${JSON.stringify(payload)}`); + } + return { wsUrl: payload.data.ws_url }; +} + +async function bufferFromWebSocketData(data: unknown): Promise { + if (typeof data === 'string') return Buffer.from(data, 'utf8'); + if (data instanceof ArrayBuffer) return Buffer.from(data); + if (ArrayBuffer.isView(data)) { + return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return Buffer.from(await data.arrayBuffer()); + } + return Buffer.from(String(data), 'utf8'); +} + +async function parseBridgeMessage(event: MessageEvent): Promise { + const text = (await bufferFromWebSocketData(event.data)).toString('utf8'); + return JSON.parse(text) as MetroCompanionRequest; +} + +function toUpstreamWebSocketUrl(localBaseUrl: string, requestPath: string): string { + const upstream = new URL(requestPath, `${normalizeBaseUrl(localBaseUrl)}/`); + upstream.protocol = upstream.protocol === 'https:' ? 'wss:' : 'ws:'; + return upstream.toString(); +} + +function normalizeCloseCode(code: number | undefined): number { + if (typeof code !== 'number' || !Number.isInteger(code)) return 1011; + if (code === 1000) return code; + if (code >= 3000 && code <= 4999) return code; + if (code >= 1001 && code <= 1015 && code !== 1004 && code !== 1005 && code !== 1006) { + return code; + } + return 1011; +} + +function sendJson(socket: WebSocket, payload: object): void { + if (socket.readyState !== WS_READY_STATE_OPEN) return; + socket.send(JSON.stringify(payload)); +} + +async function waitForSocketOpen(socket: WebSocket, label: string): Promise { + if (socket.readyState === WS_READY_STATE_OPEN) return; + await new Promise((resolve, reject) => { + const handleOpen = () => { + cleanup(); + resolve(); + }; + const handleError = () => { + cleanup(); + reject(new Error(`${label} WebSocket failed before opening.`)); + }; + const handleClose = () => { + cleanup(); + reject(new Error(`${label} WebSocket closed before opening.`)); + }; + const cleanup = () => { + socket.removeEventListener('open', handleOpen); + socket.removeEventListener('error', handleError); + socket.removeEventListener('close', handleClose); + }; + socket.addEventListener('open', handleOpen, { once: true }); + socket.addEventListener('error', handleError, { once: true }); + socket.addEventListener('close', handleClose, { once: true }); + }); +} + +async function waitForSocketShutdown(socket: WebSocket): Promise { + if (socket.readyState >= WebSocket.CLOSING) return; + await new Promise((resolve) => { + const finish = () => { + cleanup(); + resolve(); + }; + const cleanup = () => { + socket.removeEventListener('close', finish); + socket.removeEventListener('error', finish); + }; + socket.addEventListener('close', finish, { once: true }); + socket.addEventListener('error', finish, { once: true }); + if (socket.readyState >= WebSocket.CLOSING) { + finish(); + } + }); +} + +function closeSocketQuietly(socket: WebSocket, code: number, reason: string): void { + try { + socket.close(code, reason); + } catch { + // ignore shutdown races + } +} + +async function handleBridgeMessage( + bridgeSocket: WebSocket, + message: MetroCompanionRequest, + options: CompanionOptions, + upstreamSockets: Map, +): Promise { + switch (message.type) { + case 'ping': { + sendJson(bridgeSocket, { type: 'pong', timestamp: message.timestamp }); + return; + } + case 'http-request': { + try { + const response = await fetch( + new URL(message.path, `${normalizeBaseUrl(options.localBaseUrl)}/`), + { + method: message.method, + headers: message.headers, + ...(message.bodyBase64 ? { body: Buffer.from(message.bodyBase64, 'base64') } : {}), + }, + ); + const body = Buffer.from(await response.arrayBuffer()); + sendJson(bridgeSocket, { + type: 'http-response', + requestId: message.requestId, + status: response.status, + headers: Object.fromEntries(response.headers.entries()), + ...(body.length > 0 ? { bodyBase64: body.toString('base64') } : {}), + }); + } catch (error) { + sendJson(bridgeSocket, { + type: 'http-error', + requestId: message.requestId, + message: error instanceof Error ? error.message : String(error), + }); + } + return; + } + case 'ws-open': { + const upstreamSocket = new WebSocket( + toUpstreamWebSocketUrl(options.localBaseUrl, message.path), + ); + upstreamSocket.binaryType = 'arraybuffer'; + let opened = false; + upstreamSocket.addEventListener('message', (event) => { + void (async () => { + if (!opened) return; + const payload = await bufferFromWebSocketData(event.data); + sendJson(bridgeSocket, { + type: 'ws-frame', + streamId: message.streamId, + dataBase64: payload.toString('base64'), + binary: typeof event.data !== 'string', + }); + })().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + }); + }); + upstreamSocket.addEventListener('close', (event) => { + upstreamSockets.delete(message.streamId); + if (!opened) return; + sendJson(bridgeSocket, { + type: 'ws-close', + streamId: message.streamId, + code: event.code, + reason: event.reason, + }); + }); + upstreamSocket.addEventListener('error', () => { + if (!opened) return; + sendJson(bridgeSocket, { + type: 'ws-close', + streamId: message.streamId, + code: 1011, + reason: 'Upstream WebSocket error.', + }); + }); + upstreamSockets.set(message.streamId, upstreamSocket); + try { + await waitForSocketOpen(upstreamSocket, 'Upstream'); + opened = true; + sendJson(bridgeSocket, { + type: 'ws-open-result', + streamId: message.streamId, + success: true, + headers: {}, + }); + } catch (error) { + upstreamSockets.delete(message.streamId); + closeSocketQuietly(upstreamSocket, 1011, 'open failed'); + sendJson(bridgeSocket, { + type: 'ws-open-result', + streamId: message.streamId, + success: false, + error: error instanceof Error ? error.message : String(error), + }); + } + return; + } + case 'ws-frame': { + const upstreamSocket = upstreamSockets.get(message.streamId); + if (!upstreamSocket || upstreamSocket.readyState !== WS_READY_STATE_OPEN) return; + const payload = Buffer.from(message.dataBase64, 'base64'); + upstreamSocket.send(message.binary ? payload : payload.toString('utf8')); + return; + } + case 'ws-close': { + const upstreamSocket = upstreamSockets.get(message.streamId); + if (!upstreamSocket) return; + upstreamSockets.delete(message.streamId); + closeSocketQuietly( + upstreamSocket, + normalizeCloseCode(message.code), + message.reason ?? 'bridge requested close', + ); + return; + } + } +} + +export async function runMetroCompanionWorker(options: CompanionOptions): Promise { + const upstreamSockets = new Map(); + while (true) { + try { + const registration = await registerCompanion(options); + const bridgeSocket = new WebSocket(registration.wsUrl); + bridgeSocket.binaryType = 'arraybuffer'; + await waitForSocketOpen(bridgeSocket, 'Bridge'); + bridgeSocket.addEventListener('message', (event) => { + void (async () => { + const message = await parseBridgeMessage(event); + await handleBridgeMessage(bridgeSocket, message, options, upstreamSockets); + })().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + }); + }); + await waitForSocketShutdown(bridgeSocket); + upstreamSockets.forEach((socket) => closeSocketQuietly(socket, 1012, 'bridge disconnected')); + upstreamSockets.clear(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + } + await delay(METRO_COMPANION_RECONNECT_DELAY_MS); + } +} + +function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOptions | null { + if (argv[0] !== METRO_COMPANION_RUN_ARG) return null; + const serverBaseUrl = env[ENV_SERVER_BASE_URL]?.trim(); + const bearerToken = env[ENV_BEARER_TOKEN]?.trim(); + const localBaseUrl = env[ENV_LOCAL_BASE_URL]?.trim(); + if (!serverBaseUrl || !bearerToken || !localBaseUrl) { + throw new Error('Metro companion worker is missing required environment configuration.'); + } + return { + serverBaseUrl, + bearerToken, + localBaseUrl, + launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined, + }; +} + +export async function runMetroCompanionProcessFromEnv( + argv: string[], + env: NodeJS.ProcessEnv, +): Promise { + const options = readWorkerOptions(argv, env); + if (!options) return false; + await runMetroCompanionWorker(options); + return true; +} diff --git a/src/client-metro-companion.ts b/src/client-metro-companion.ts new file mode 100644 index 000000000..3e2825b72 --- /dev/null +++ b/src/client-metro-companion.ts @@ -0,0 +1,329 @@ +import { createHash } from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { + ENV_BEARER_TOKEN, + ENV_LAUNCH_URL, + ENV_LOCAL_BASE_URL, + ENV_SERVER_BASE_URL, + METRO_COMPANION_RUN_ARG, +} from './client-metro-companion-contract.ts'; +import { runMetroCompanionProcessFromEnv } from './client-metro-companion-worker.ts'; +import { normalizeBaseUrl } from './utils/url.ts'; +import { runCmdDetached } from './utils/exec.ts'; +import { + isProcessAlive, + readProcessCommand, + readProcessStartTime, + waitForProcessExit, +} from './utils/process-identity.ts'; + +const METRO_COMPANION_TERM_TIMEOUT_MS = 1_000; +const METRO_COMPANION_KILL_TIMEOUT_MS = 1_000; +const METRO_COMPANION_STATE_FILE = 'metro-companion.json'; +const METRO_COMPANION_LOG_FILE = 'metro-companion.log'; +const METRO_COMPANION_STATE_DIR = 'metro-companion'; + +type CompanionState = { + pid: number; + startTime?: string; + command?: string; + serverBaseUrl: string; + localBaseUrl: string; + launchUrl?: string; + tokenHash: string; + consumers: string[]; +}; + +export type EnsureMetroCompanionOptions = { + projectRoot: string; + serverBaseUrl: string; + bearerToken: string; + localBaseUrl: string; + launchUrl?: string; + profileKey?: string; + consumerKey?: string; + env?: NodeJS.ProcessEnv; +}; + +export type EnsureMetroCompanionResult = { + pid: number; + spawned: boolean; + statePath: string; + logPath: string; +}; + +export type StopMetroCompanionOptions = { + projectRoot: string; + profileKey?: string; + consumerKey?: string; +}; + +function hashString(token: string): string { + return createHash('sha256').update(token).digest('hex'); +} + +function normalizeOptionalString(input: string | undefined): string | undefined { + return input?.trim() ? input.trim() : undefined; +} + +function resolveCompanionPaths( + projectRoot: string, + profileKey?: string, +): { statePath: string; logPath: string } { + const dir = path.join(projectRoot, '.agent-device'); + if (!profileKey) { + return { + statePath: path.join(dir, METRO_COMPANION_STATE_FILE), + logPath: path.join(dir, METRO_COMPANION_LOG_FILE), + }; + } + const profileHash = hashString(profileKey).slice(0, 12); + const profileDir = path.join(dir, METRO_COMPANION_STATE_DIR); + return { + statePath: path.join(profileDir, `metro-companion-${profileHash}.json`), + logPath: path.join(profileDir, `metro-companion-${profileHash}.log`), + }; +} + +function readCompanionState(statePath: string): CompanionState | null { + try { + const parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')) as Partial; + if (!Number.isInteger(parsed.pid) || Number(parsed.pid) <= 0) return null; + if (typeof parsed.serverBaseUrl !== 'string' || typeof parsed.localBaseUrl !== 'string') { + return null; + } + if (typeof parsed.tokenHash !== 'string' || parsed.tokenHash.length === 0) return null; + const consumers = Array.isArray(parsed.consumers) + ? parsed.consumers.filter( + (entry): entry is string => typeof entry === 'string' && entry.length > 0, + ) + : []; + return { + pid: Number(parsed.pid), + startTime: typeof parsed.startTime === 'string' ? parsed.startTime : undefined, + command: typeof parsed.command === 'string' ? parsed.command : undefined, + serverBaseUrl: parsed.serverBaseUrl, + localBaseUrl: parsed.localBaseUrl, + launchUrl: normalizeOptionalString( + typeof parsed.launchUrl === 'string' ? parsed.launchUrl : undefined, + ), + tokenHash: parsed.tokenHash, + consumers, + }; + } catch { + return null; + } +} + +function writeCompanionState(statePath: string, state: CompanionState): void { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync(statePath, `${JSON.stringify(state, null, 2)}\n`, 'utf8'); +} + +function clearCompanionState(statePath: string): void { + try { + fs.unlinkSync(statePath); + } catch { + // best effort cleanup + } +} + +function isMetroCompanionCommand(command: string): boolean { + return command.includes(METRO_COMPANION_RUN_ARG); +} + +function shouldReuseCompanion( + state: CompanionState, + options: EnsureMetroCompanionOptions, +): boolean { + if (!isProcessAlive(state.pid)) return false; + if (state.startTime) { + const currentStartTime = readProcessStartTime(state.pid); + if (!currentStartTime || currentStartTime !== state.startTime) return false; + } + const command = readProcessCommand(state.pid); + if (!command || !isMetroCompanionCommand(command)) return false; + return ( + state.serverBaseUrl === normalizeBaseUrl(options.serverBaseUrl) && + state.localBaseUrl === normalizeBaseUrl(options.localBaseUrl) && + state.launchUrl === normalizeOptionalString(options.launchUrl) && + state.tokenHash === hashString(options.bearerToken) + ); +} + +function resolveConsumerKey(options: { profileKey?: string; consumerKey?: string }): string | null { + return ( + normalizeOptionalString(options.consumerKey) ?? + normalizeOptionalString(options.profileKey) ?? + null + ); +} + +function withConsumer(state: CompanionState, consumerKey: string | null): CompanionState { + if (!consumerKey || state.consumers.includes(consumerKey)) { + return state; + } + return { + ...state, + consumers: [...state.consumers, consumerKey], + }; +} + +function withoutConsumer(state: CompanionState, consumerKey: string | null): CompanionState { + if (!consumerKey) { + return { + ...state, + consumers: [], + }; + } + return { + ...state, + consumers: state.consumers.filter((entry) => entry !== consumerKey), + }; +} + +async function stopCompanionProcess(state: CompanionState): Promise { + if (!isProcessAlive(state.pid)) return; + const command = readProcessCommand(state.pid); + if (!command || !isMetroCompanionCommand(command)) return; + try { + process.kill(state.pid, 'SIGTERM'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return; + throw error; + } + if (await waitForProcessExit(state.pid, METRO_COMPANION_TERM_TIMEOUT_MS)) return; + try { + process.kill(state.pid, 'SIGKILL'); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ESRCH' || code === 'EPERM') return; + throw error; + } + await waitForProcessExit(state.pid, METRO_COMPANION_KILL_TIMEOUT_MS); +} + +function buildCompanionEnv( + options: EnsureMetroCompanionOptions, + env: NodeJS.ProcessEnv, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { + ...env, + [ENV_SERVER_BASE_URL]: normalizeBaseUrl(options.serverBaseUrl), + [ENV_BEARER_TOKEN]: options.bearerToken, + [ENV_LOCAL_BASE_URL]: normalizeBaseUrl(options.localBaseUrl), + }; + if (options.launchUrl?.trim()) { + nextEnv[ENV_LAUNCH_URL] = options.launchUrl.trim(); + } else { + delete nextEnv[ENV_LAUNCH_URL]; + } + return nextEnv; +} + +function spawnCompanionProcess( + options: EnsureMetroCompanionOptions, + logPath: string, +): CompanionState { + const modulePath = fileURLToPath(import.meta.url); + const execArgs = modulePath.endsWith('.ts') ? ['--experimental-strip-types'] : []; + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + const logFd = fs.openSync(logPath, 'a'); + let pid = 0; + try { + pid = runCmdDetached(process.execPath, [...execArgs, modulePath, METRO_COMPANION_RUN_ARG], { + env: buildCompanionEnv(options, options.env ?? process.env), + stdio: ['ignore', logFd, logFd], + }); + } finally { + fs.closeSync(logFd); + } + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error('Failed to start Metro companion process.'); + } + return { + pid, + startTime: readProcessStartTime(pid) ?? undefined, + command: readProcessCommand(pid) ?? undefined, + serverBaseUrl: normalizeBaseUrl(options.serverBaseUrl), + localBaseUrl: normalizeBaseUrl(options.localBaseUrl), + launchUrl: normalizeOptionalString(options.launchUrl), + tokenHash: hashString(options.bearerToken), + consumers: [], + }; +} + +export async function ensureMetroCompanion( + options: EnsureMetroCompanionOptions, +): Promise { + const consumerKey = resolveConsumerKey(options); + const paths = resolveCompanionPaths(options.projectRoot, options.profileKey); + const existing = readCompanionState(paths.statePath); + if (existing && shouldReuseCompanion(existing, options)) { + const nextState = withConsumer(existing, consumerKey); + if (nextState !== existing) { + writeCompanionState(paths.statePath, nextState); + } + return { + pid: existing.pid, + spawned: false, + statePath: paths.statePath, + logPath: paths.logPath, + }; + } + + if (existing) { + await stopCompanionProcess(existing); + clearCompanionState(paths.statePath); + } + + const spawned = spawnCompanionProcess(options, paths.logPath); + writeCompanionState(paths.statePath, withConsumer(spawned, consumerKey)); + return { + pid: spawned.pid, + spawned: true, + statePath: paths.statePath, + logPath: paths.logPath, + }; +} + +export async function stopMetroCompanion( + options: StopMetroCompanionOptions, +): Promise<{ stopped: boolean; statePath: string }> { + const consumerKey = resolveConsumerKey(options); + const paths = resolveCompanionPaths(options.projectRoot, options.profileKey); + const existing = readCompanionState(paths.statePath); + if (!existing) { + clearCompanionState(paths.statePath); + return { stopped: false, statePath: paths.statePath }; + } + const nextState = withoutConsumer(existing, consumerKey); + if (nextState.consumers.length > 0) { + writeCompanionState(paths.statePath, nextState); + return { stopped: false, statePath: paths.statePath }; + } + await stopCompanionProcess(existing); + clearCompanionState(paths.statePath); + return { stopped: true, statePath: paths.statePath }; +} + +function isCurrentModuleProcessEntry(): boolean { + const entryArg = process.argv[1]; + if (!entryArg) return false; + return pathToFileURL(path.resolve(entryArg)).href === import.meta.url; +} + +if (isCurrentModuleProcessEntry()) { + void runMetroCompanionProcessFromEnv(process.argv.slice(2), process.env).catch((error) => { + if (error instanceof Error && error.message.includes('missing required environment')) { + console.error(error.message); + process.exitCode = 1; + return; + } + console.error(error instanceof Error ? (error.stack ?? error.message) : String(error)); + process.exitCode = 1; + }); +} diff --git a/src/client-metro.ts b/src/client-metro.ts index 83a118781..3c0097864 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -1,5 +1,7 @@ import fs from 'node:fs'; import path from 'node:path'; +import { ensureMetroCompanion } from './client-metro-companion.ts'; +import { normalizeBaseUrl } from './utils/url.ts'; import { AppError } from './utils/errors.ts'; import { runCmdSync, runCmdDetached } from './utils/exec.ts'; import { resolveUserPath } from './utils/path-resolution.ts'; @@ -88,6 +90,9 @@ export type PrepareMetroRuntimeOptions = { publicBaseUrl?: string; proxyBaseUrl?: string; proxyBearerToken?: string; + launchUrl?: string; + companionProfileKey?: string; + companionConsumerKey?: string; startupTimeoutMs?: number | string; probeTimeoutMs?: number | string; reuseExisting?: boolean; @@ -120,9 +125,9 @@ type ProxyBridgeRequestOptions = { timeoutMs: number; }; -function normalizeBaseUrl(input: string): string { - return input.replace(/\/+$/, ''); -} +type MetroBridgeRequestError = Error & { + retryable?: boolean; +}; function normalizeOptionalBaseUrl(input: unknown): string { return typeof input === 'string' && input.trim() ? normalizeBaseUrl(input.trim()) : ''; @@ -349,6 +354,35 @@ function createProxyHeaders(baseUrl: string, bearerToken: string): Record= 500 || statusCode === 408 || statusCode === 425 || statusCode === 429) { + return true; + } + const responseText = JSON.stringify(responsePayload); + if (responseText.includes('Metro companion is not connected')) { + return true; + } + return false; +} + +function isRetryableBridgeError(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'retryable' in error && + (error as MetroBridgeRequestError).retryable === true, + ); +} + async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise { let response: Response; @@ -364,19 +398,24 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise { + const deadline = Date.now() + options.startupTimeoutMs; + let lastBridge: MetroBridgeResult | null = null; + let lastBridgeError: string | null = null; + + while (Date.now() < deadline) { + try { + const bridge = await configureMetroBridge({ + baseUrl: options.baseUrl, + bearerToken: options.bearerToken, + runtime: options.runtime, + timeoutMs: options.probeTimeoutMs, + }); + if (bridge.probe.reachable !== false) { + return bridge; + } + lastBridge = bridge; + lastBridgeError = null; + } catch (error) { + lastBridgeError = error instanceof Error ? error.message : String(error); + if (!isRetryableBridgeError(error)) { + throw new Error( + describeBridgeFailure( + options.baseUrl, + lastBridgeError, + lastBridge, + options.initialBridgeError, + options.companionLogPath, + ), + ); + } + } + + const sleepMs = Math.min(1_000, Math.max(deadline - Date.now(), 0)); + if (sleepMs > 0) { + await wait(sleepMs); + } + } + + throw new Error( + describeBridgeFailure( + options.baseUrl, + lastBridgeError, + lastBridge, + options.initialBridgeError, + options.companionLogPath, + ), + ); +} + export async function prepareMetroRuntime( input: PrepareMetroRuntimeOptions = {}, ): Promise { @@ -542,7 +647,7 @@ export async function prepareMetroRuntime( const publicAndroidRuntime = buildPublicRuntime(publicBaseUrl, 'android'); let bridge: MetroBridgeResult | null = null; - let bridgeError: string | null = null; + let initialBridgeError: string | null = null; if (proxyEnabled) { try { @@ -555,12 +660,49 @@ export async function prepareMetroRuntime( timeoutMs: probeTimeoutMs, }); } catch (error) { - bridgeError = error instanceof Error ? error.message : String(error); + initialBridgeError = error instanceof Error ? error.message : String(error); } } if (proxyEnabled && (!bridge || bridge.probe.reachable === false)) { - throw new Error(describeBridgeFailure(proxyBaseUrl, bridgeError, bridge)); + let companionLogPath: string | undefined; + try { + const companion = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: proxyBaseUrl, + bearerToken: proxyBearerToken, + localBaseUrl: `http://${statusHost}:${metroPort}`, + launchUrl: normalizeOptionalString(input.launchUrl), + profileKey: normalizeOptionalString(input.companionProfileKey), + consumerKey: normalizeOptionalString(input.companionConsumerKey), + env: env as NodeJS.ProcessEnv, + }); + companionLogPath = companion.logPath; + } catch (error) { + throw new Error( + describeBridgeFailure( + proxyBaseUrl, + error instanceof Error ? error.message : String(error), + bridge, + initialBridgeError, + ), + ); + } + try { + bridge = await configureMetroBridgeUntilReady({ + baseUrl: proxyBaseUrl, + bearerToken: proxyBearerToken, + runtime: { + metro_bundle_url: publicIosRuntime.bundleUrl, + }, + probeTimeoutMs, + startupTimeoutMs, + initialBridgeError, + companionLogPath, + }); + } catch (error) { + throw error instanceof Error ? error : new Error(String(error)); + } } const iosRuntime = bridge?.iosRuntime ?? publicIosRuntime; diff --git a/src/client-types.ts b/src/client-types.ts index dadeeca8f..061f665ed 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -227,6 +227,9 @@ export type MetroPrepareOptions = { publicBaseUrl: string; proxyBaseUrl?: string; bearerToken?: string; + launchUrl?: string; + companionProfileKey?: string; + companionConsumerKey?: string; port?: number; listenHost?: string; statusHost?: string; diff --git a/src/client.ts b/src/client.ts index 3c9385cff..d3beb2f0a 100644 --- a/src/client.ts +++ b/src/client.ts @@ -200,6 +200,9 @@ export function createAgentDeviceClient( publicBaseUrl: options.publicBaseUrl, proxyBaseUrl: options.proxyBaseUrl, proxyBearerToken: options.bearerToken, + launchUrl: options.launchUrl, + companionProfileKey: options.companionProfileKey, + companionConsumerKey: options.companionConsumerKey, metroPort: options.port, listenHost: options.listenHost, statusHost: options.statusHost, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index bfd041abf..e5de56139 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -73,7 +73,10 @@ const COMMAND_CAPABILITY_MATRIX: Record = { android: { emulator: true, device: true, unknown: true }, linux: LINUX_DEVICE, supports: (device) => - device.platform === 'android' || device.platform === 'linux' || device.platform === 'macos' || device.kind === 'simulator', + device.platform === 'android' || + device.platform === 'linux' || + device.platform === 'macos' || + device.kind === 'simulator', }, keyboard: { // iOS only supports keyboard dismiss; status/get remains Android-only. diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index efcf1e202..c6420bb5b 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -267,10 +267,7 @@ async function handleOpenCommand( } if (url !== undefined) { if (device.platform === 'android') { - throw new AppError( - 'INVALID_ARGS', - 'open is supported only on Apple platforms', - ); + throw new AppError('INVALID_ARGS', 'open is supported only on Apple platforms'); } if (isDeepLinkTarget(app)) { throw new AppError( @@ -303,8 +300,7 @@ async function handlePressCommand( _runnerCtx: RunnerContext, ): Promise> { const [x, y] = positionals.map(Number); - if (Number.isNaN(x) || Number.isNaN(y)) - throw new AppError('INVALID_ARGS', 'press requires x y'); + if (Number.isNaN(x) || Number.isNaN(y)) throw new AppError('INVALID_ARGS', 'press requires x y'); if (device.platform === 'macos' && context?.surface && context.surface !== 'app') { const clickButton = resolveClickButton(context); @@ -625,10 +621,7 @@ async function handleClipboardCommand( return { action, text }; } if (positionals.length < 2) { - throw new AppError( - 'INVALID_ARGS', - 'clipboard write requires text (use "" to clear clipboard)', - ); + throw new AppError('INVALID_ARGS', 'clipboard write requires text (use "" to clear clipboard)'); } const text = positionals.slice(1).join(' '); await interactor.writeClipboard(text); @@ -648,10 +641,7 @@ async function handleKeyboardCommand( ): Promise> { const action = (positionals[0] ?? 'status').toLowerCase(); if (action !== 'status' && action !== 'get' && action !== 'dismiss') { - throw new AppError( - 'INVALID_ARGS', - 'keyboard requires a subcommand: status, get, or dismiss', - ); + throw new AppError('INVALID_ARGS', 'keyboard requires a subcommand: status, get, or dismiss'); } if (positionals.length > 1) { throw new AppError('INVALID_ARGS', 'keyboard accepts at most one subcommand argument'); @@ -750,10 +740,7 @@ async function handlePushCommand( const target = positionals[0]?.trim(); const payloadArg = positionals[1]?.trim(); if (!target || !payloadArg) { - throw new AppError( - 'INVALID_ARGS', - 'push requires ', - ); + throw new AppError('INVALID_ARGS', 'push requires '); } const payload = await readNotificationPayload(payloadArg); if (device.platform === 'ios') { @@ -783,8 +770,7 @@ async function handleSnapshotCommand( if (device.platform === 'linux') { const linuxResult = await withDiagnosticTimer( 'snapshot_capture', - async () => - await snapshotLinux(context?.surface), + async () => await snapshotLinux(context?.surface), { backend: 'linux-atspi' }, ); return { @@ -821,10 +807,7 @@ async function handleSnapshotCommand( )) as { nodes?: RawSnapshotNode[]; truncated?: boolean }; const nodes = result.nodes ?? []; if (nodes.length === 0 && device.kind === 'simulator') { - throw new AppError( - 'COMMAND_FAILED', - 'XCTest snapshot returned 0 nodes on iOS simulator.', - ); + throw new AppError('COMMAND_FAILED', 'XCTest snapshot returned 0 nodes on iOS simulator.'); } return { nodes, truncated: result.truncated ?? false, backend: 'xctest' }; } diff --git a/src/core/remote-open.ts b/src/core/remote-open.ts index ee2d218b4..7607deaf3 100644 --- a/src/core/remote-open.ts +++ b/src/core/remote-open.ts @@ -1,6 +1,7 @@ import type { AgentDeviceClient } from '../client.ts'; import type { CliFlags } from '../utils/command-schema.ts'; import { AppError } from '../utils/errors.ts'; +import { resolveRemoteConfigPath } from '../utils/remote-config.ts'; export async function resolveRemoteOpenRuntime( flags: CliFlags, @@ -35,6 +36,13 @@ export async function resolveRemoteOpenRuntime( publicBaseUrl: flags.metroPublicBaseUrl, proxyBaseUrl: flags.metroProxyBaseUrl, bearerToken: flags.metroBearerToken, + launchUrl: flags.launchUrl, + companionProfileKey: resolveRemoteConfigPath({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }), + companionConsumerKey: flags.session, port: flags.metroPreparePort, listenHost: flags.metroListenHost, statusHost: flags.metroStatusHost, diff --git a/src/daemon/handlers/snapshot-capture.ts b/src/daemon/handlers/snapshot-capture.ts index f1a674167..c93e66a8b 100644 --- a/src/daemon/handlers/snapshot-capture.ts +++ b/src/daemon/handlers/snapshot-capture.ts @@ -71,7 +71,11 @@ export async function captureSnapshotData(params: CaptureSnapshotParams): Promis const linuxResult = await snapshotLinux(session?.surface); return shapeDesktopSurfaceSnapshot( { nodes: linuxResult.nodes, truncated: linuxResult.truncated, backend: 'linux-atspi' }, - { snapshotDepth: flags?.snapshotDepth, snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, snapshotScope }, + { + snapshotDepth: flags?.snapshotDepth, + snapshotInteractiveOnly: flags?.snapshotInteractiveOnly, + snapshotScope, + }, ); } if (device.platform === 'macos' && session?.surface && session.surface !== 'app') { diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 84a090417..ebebd4ec6 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -393,13 +393,9 @@ async function dispatchGenericCommand(params: { ...contextFromFlags(logPath, req.flags, session.appBundleId, session.trace?.outPath), surface: session.surface, }; - const data = await dispatchCommand( - session.device, - command, - resolvedPositionals, - resolvedOut, - { ...dispatchContext }, - ); + const data = await dispatchCommand(session.device, command, resolvedPositionals, resolvedOut, { + ...dispatchContext, + }); if (command === 'screenshot' && req.flags?.overlayRefs && typeof data?.path === 'string') { await applyScreenshotOverlay(session, data, logPath); @@ -440,9 +436,7 @@ function resolveCommandPositionals(req: DaemonRequest): { ? [SessionStore.expandHome(positionals[0], req.meta?.cwd), ...positionals.slice(1)] : positionals; const resolvedOut = - command === 'screenshot' && outFlag - ? SessionStore.expandHome(outFlag, req.meta?.cwd) - : outFlag; + command === 'screenshot' && outFlag ? SessionStore.expandHome(outFlag, req.meta?.cwd) : outFlag; const recordedPositionals = command === 'screenshot' ? resolvedPositionals : positionals; const recordedFlags = command === 'screenshot' && resolvedOut diff --git a/src/daemon/selectors-parse.ts b/src/daemon/selectors-parse.ts index 4053f8b8d..38d707a3c 100644 --- a/src/daemon/selectors-parse.ts +++ b/src/daemon/selectors-parse.ts @@ -30,7 +30,15 @@ export type SelectorChain = { selectors: Selector[]; }; -const TEXT_KEYS = new Set(['id', 'role', 'text', 'label', 'value', 'appname', 'windowtitle']); +const TEXT_KEYS = new Set([ + 'id', + 'role', + 'text', + 'label', + 'value', + 'appname', + 'windowtitle', +]); const BOOLEAN_KEYS = new Set([ 'visible', 'hidden', diff --git a/src/platforms/SNAPSHOT_CONTRACT.md b/src/platforms/SNAPSHOT_CONTRACT.md index 71d77eee0..dcab0b469 100644 --- a/src/platforms/SNAPSHOT_CONTRACT.md +++ b/src/platforms/SNAPSHOT_CONTRACT.md @@ -9,32 +9,33 @@ all backends must conform to this shared contract so that the downstream pipelin Every node in the `nodes` array must include: -| Field | Type | Required | Notes | -|---------------|-----------------------------------|----------|------------------------------------------| -| `index` | `number` | yes | Sequential, 0-based, pre-order DFS | -| `type` | `string` | yes | Normalized role (see table below) | -| `role` | `string` | no | Raw platform role for debugging | -| `label` | `string \| undefined` | no | Accessible name or description | -| `value` | `string \| undefined` | no | Text content or numeric value | -| `rect` | `{x, y, width, height} \| undef` | no | Screen-absolute bounding rect | -| `enabled` | `boolean \| undefined` | no | | -| `selected` | `boolean \| undefined` | no | | -| `hittable` | `boolean \| undefined` | no | Can receive pointer/touch events | -| `depth` | `number` | yes | Tree depth (root = 0) | -| `parentIndex` | `number \| undefined` | no | Index of parent node; undefined for roots | +| Field | Type | Required | Notes | +| ------------- | -------------------------------- | -------- | ----------------------------------------- | +| `index` | `number` | yes | Sequential, 0-based, pre-order DFS | +| `type` | `string` | yes | Normalized role (see table below) | +| `role` | `string` | no | Raw platform role for debugging | +| `label` | `string \| undefined` | no | Accessible name or description | +| `value` | `string \| undefined` | no | Text content or numeric value | +| `rect` | `{x, y, width, height} \| undef` | no | Screen-absolute bounding rect | +| `enabled` | `boolean \| undefined` | no | | +| `selected` | `boolean \| undefined` | no | | +| `hittable` | `boolean \| undefined` | no | Can receive pointer/touch events | +| `depth` | `number` | yes | Tree depth (root = 0) | +| `parentIndex` | `number \| undefined` | no | Index of parent node; undefined for roots | Platform-specific fields (optional, passed through): + - `pid`, `appName`, `windowTitle` (Linux, macOS desktop) - `identifier`, `subrole` (iOS/macOS) - `resourceId`, `className` (Android) ## Traversal rules -| Parameter | Default | Description | -|-----------------|---------|----------------------------------------------| -| `maxNodes` | 1500 | Stop traversal after this many nodes | -| `maxDepth` | 12 | Do not descend beyond this tree depth | -| `maxApps` | 24 | Desktop only: max top-level apps to traverse | +| Parameter | Default | Description | +| ---------- | ------- | -------------------------------------------- | +| `maxNodes` | 1500 | Stop traversal after this many nodes | +| `maxDepth` | 12 | Do not descend beyond this tree depth | +| `maxApps` | 24 | Desktop only: max top-level apps to traverse | - Traversal order is **pre-order depth-first**. - `index` values are assigned in traversal order (0, 1, 2, …). @@ -44,17 +45,18 @@ Platform-specific fields (optional, passed through): ## Surface semantics -| Surface | Behavior | -|------------------|-------------------------------------------------------| -| `app` | Snapshot the target application's UI tree | -| `frontmost-app` | Snapshot the focused/frontmost application (desktop) | -| `desktop` | Snapshot all visible applications on the desktop | -| `menubar` | macOS only: snapshot the system menu bar | +| Surface | Behavior | +| --------------- | ---------------------------------------------------- | +| `app` | Snapshot the target application's UI tree | +| `frontmost-app` | Snapshot the focused/frontmost application (desktop) | +| `desktop` | Snapshot all visible applications on the desktop | +| `menubar` | macOS only: snapshot the system menu bar | ## Normalized role types All backends must map platform-specific roles to these normalized strings. The canonical mapping is maintained in: + - **iOS/macOS**: `ios-runner/…/SnapshotTraversal.swift` → `normalizedSnapshotType` - **Android**: `src/platforms/android/ui-hierarchy.ts` → `normalizeAndroidType` - **Linux**: `src/platforms/linux/role-map.ts` → `normalizeAtspiRole` @@ -74,12 +76,12 @@ Unmapped roles should be PascalCased (e.g., `"extended table"` → `"ExtendedTab Linux maps session surfaces to AT-SPI2 as follows: -| Session surface | AT-SPI2 behaviour | -|------------------|---------------------------------------------| -| `app` | Maps to `frontmost-app` (focused window) | -| `frontmost-app` | Traverses the focused application's tree | -| `desktop` | Traverses all visible applications | -| `menubar` | **Not supported** — falls back to `desktop` with a diagnostic warning | +| Session surface | AT-SPI2 behaviour | +| --------------- | --------------------------------------------------------------------- | +| `app` | Maps to `frontmost-app` (focused window) | +| `frontmost-app` | Traverses the focused application's tree | +| `desktop` | Traverses all visible applications | +| `menubar` | **Not supported** — falls back to `desktop` with a diagnostic warning | ### Supported commands diff --git a/src/platforms/linux/__tests__/atspi-bridge.test.ts b/src/platforms/linux/__tests__/atspi-bridge.test.ts index ab1609a3c..c9bd7ccf6 100644 --- a/src/platforms/linux/__tests__/atspi-bridge.test.ts +++ b/src/platforms/linux/__tests__/atspi-bridge.test.ts @@ -180,7 +180,9 @@ test('throws COMMAND_FAILED on invalid JSON output', async () => { test('throws COMMAND_FAILED when Python returns an error field', async () => { mockRunCmd.mockResolvedValue({ exitCode: 0, - stdout: JSON.stringify({ error: 'Could not get desktop accessible. Is the accessibility bus running?' }), + stdout: JSON.stringify({ + error: 'Could not get desktop accessible. Is the accessibility bus running?', + }), stderr: '', }); diff --git a/src/platforms/linux/__tests__/input-actions.test.ts b/src/platforms/linux/__tests__/input-actions.test.ts index 7ebb69f41..a1099210c 100644 --- a/src/platforms/linux/__tests__/input-actions.test.ts +++ b/src/platforms/linux/__tests__/input-actions.test.ts @@ -67,43 +67,70 @@ test('pressLinux uses xdotool mousemove + click on X11', async () => { setupXdotool(); await pressLinux(100, 200); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('mousemove') && args.includes('100') && args.includes('200'))); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1'))); + assert.ok( + c.some( + ([cmd, args]) => + cmd === 'xdotool' && + args.includes('mousemove') && + args.includes('100') && + args.includes('200'), + ), + ); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1')), + ); }); test('rightClickLinux sends button 3 via xdotool', async () => { setupXdotool(); await rightClickLinux(50, 60); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('3'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('3')), + ); }); test('middleClickLinux sends button 2 via xdotool', async () => { setupXdotool(); await middleClickLinux(50, 60); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('2'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('2')), + ); }); test('doubleClickLinux sends --repeat 2 via xdotool', async () => { setupXdotool(); await doubleClickLinux(10, 20); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('--repeat') && args.includes('2'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('--repeat') && args.includes('2')), + ); }); test('sendKey uses xdotool key with combo', async () => { setupXdotool(); await sendKey('alt+Left', ['56:1', '105:1', '105:0', '56:0']); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('key') && args.includes('alt+Left'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('key') && args.includes('alt+Left')), + ); }); test('typeLinux uses xdotool type with delay', async () => { setupXdotool(); await typeLinux('hello', 50); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('type') && args.includes('--delay') && args.includes('50') && args.includes('hello'))); + assert.ok( + c.some( + ([cmd, args]) => + cmd === 'xdotool' && + args.includes('type') && + args.includes('--delay') && + args.includes('50') && + args.includes('hello'), + ), + ); }); test('typeLinux omits --delay when delayMs is 0', async () => { @@ -119,14 +146,18 @@ test('scrollLinux uses xdotool button 4 for up, 5 for down', async () => { setupXdotool(); await scrollLinux('up'); let c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('4'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('4')), + ); mockRunCmd.mockClear(); resetInputToolCache(); setupXdotool(); await scrollLinux('down'); c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('5'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('5')), + ); }); test('scrollLinux converts pixels to click count', async () => { @@ -134,7 +165,9 @@ test('scrollLinux converts pixels to click count', async () => { await scrollLinux('down', { pixels: 150 }); const c = calls(); // 150 / 15 = 10 clicks - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('--repeat') && args.includes('10'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('--repeat') && args.includes('10')), + ); }); test('swipeLinux performs mousedown, mousemove, mouseup via xdotool', async () => { @@ -142,7 +175,11 @@ test('swipeLinux performs mousedown, mousemove, mouseup via xdotool', async () = await swipeLinux(0, 0, 100, 100, 10); const c = calls(); assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('mousedown'))); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('mousemove') && args.includes('100'))); + assert.ok( + c.some( + ([cmd, args]) => cmd === 'xdotool' && args.includes('mousemove') && args.includes('100'), + ), + ); assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('mouseup'))); }); @@ -150,8 +187,18 @@ test('focusLinux delegates to pressLinux', async () => { setupXdotool(); await focusLinux(30, 40); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('mousemove') && args.includes('30') && args.includes('40'))); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1'))); + assert.ok( + c.some( + ([cmd, args]) => + cmd === 'xdotool' && + args.includes('mousemove') && + args.includes('30') && + args.includes('40'), + ), + ); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1')), + ); }); // ── ydotool tests ──────────────────────────────────────────────────────── @@ -160,29 +207,48 @@ test('pressLinux uses ydotool mousemove + click on Wayland', async () => { setupYdotool(); await pressLinux(100, 200); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('mousemove') && args.includes('--absolute'))); - assert.ok(c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('click') && args.includes('0xC0'))); + assert.ok( + c.some( + ([cmd, args]) => + cmd === 'ydotool' && args.includes('mousemove') && args.includes('--absolute'), + ), + ); + assert.ok( + c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('click') && args.includes('0xC0')), + ); }); test('sendKey uses ydotool with scancodes', async () => { setupYdotool(); await sendKey('alt+Left', ['56:1', '105:1', '105:0', '56:0']); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('key') && args.includes('56:1'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('key') && args.includes('56:1')), + ); }); test('typeLinux uses ydotool type', async () => { setupYdotool(); await typeLinux('hello'); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('type') && args.includes('hello'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('type') && args.includes('hello')), + ); }); test('scrollLinux uses ydotool mousemove --wheel for vertical scroll', async () => { setupYdotool(); await scrollLinux('up'); const c = calls(); - assert.ok(c.some(([cmd, args]) => cmd === 'ydotool' && args.includes('mousemove') && args.includes('--wheel') && args.includes('-y'))); + assert.ok( + c.some( + ([cmd, args]) => + cmd === 'ydotool' && + args.includes('mousemove') && + args.includes('--wheel') && + args.includes('-y'), + ), + ); }); // ── fillLinux tests ────────────────────────────────────────────────────── @@ -192,7 +258,15 @@ test('fillLinux clicks, selects all, then types on X11', async () => { await fillLinux(50, 50, 'new text', 0); const c = calls(); // Should click, then ctrl+a, then type - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1'))); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('key') && args.includes('ctrl+a'))); - assert.ok(c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('type') && args.includes('new text'))); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('click') && args.includes('1')), + ); + assert.ok( + c.some(([cmd, args]) => cmd === 'xdotool' && args.includes('key') && args.includes('ctrl+a')), + ); + assert.ok( + c.some( + ([cmd, args]) => cmd === 'xdotool' && args.includes('type') && args.includes('new text'), + ), + ); }); diff --git a/src/platforms/linux/atspi-bridge.ts b/src/platforms/linux/atspi-bridge.ts index ad8d79c61..cb59983b5 100644 --- a/src/platforms/linux/atspi-bridge.ts +++ b/src/platforms/linux/atspi-bridge.ts @@ -36,11 +36,17 @@ function resolveScriptPath(): string { let dir = thisDir; for (let i = 0; i < 5; i++) { const candidate = path.join(dir, 'src', 'platforms', 'linux', SCRIPT_NAME); - if (fs.existsSync(candidate)) { cachedScriptPath = candidate; return candidate; } + if (fs.existsSync(candidate)) { + cachedScriptPath = candidate; + return candidate; + } // Also check same-directory (running from source dir directly) if (i === 0) { const sameDir = path.join(dir, SCRIPT_NAME); - if (fs.existsSync(sameDir)) { cachedScriptPath = sameDir; return sameDir; } + if (fs.existsSync(sameDir)) { + cachedScriptPath = sameDir; + return sameDir; + } } dir = path.dirname(dir); } @@ -95,10 +101,7 @@ export async function captureAccessibilityTree( surface: SnapshotSurface; }> { if (process.platform !== 'linux') { - throw new AppError( - 'UNSUPPORTED_PLATFORM', - 'AT-SPI2 bridge is only available on Linux', - ); + throw new AppError('UNSUPPORTED_PLATFORM', 'AT-SPI2 bridge is only available on Linux'); } if (!(await whichCmd('python3'))) { @@ -115,10 +118,14 @@ export async function captureAccessibilityTree( const scriptPath = resolveScriptPath(); const args = [ scriptPath, - '--surface', surface, - '--max-nodes', String(maxNodes), - '--max-depth', String(maxDepth), - '--max-apps', String(maxApps), + '--surface', + surface, + '--max-nodes', + String(maxNodes), + '--max-depth', + String(maxDepth), + '--max-apps', + String(maxApps), ]; const result = await runCmd('python3', args, { diff --git a/src/platforms/linux/clipboard.ts b/src/platforms/linux/clipboard.ts index ff706012a..385ff5e8b 100644 --- a/src/platforms/linux/clipboard.ts +++ b/src/platforms/linux/clipboard.ts @@ -6,20 +6,32 @@ type ClipboardTool = 'wl-clipboard' | 'xclip' | 'xsel'; let cachedTool: { tool: ClipboardTool; display: 'wayland' | 'x11' } | null = null; -async function resolveClipboardTool(): Promise<{ tool: ClipboardTool; display: 'wayland' | 'x11' }> { +async function resolveClipboardTool(): Promise<{ + tool: ClipboardTool; + display: 'wayland' | 'x11'; +}> { if (cachedTool) return cachedTool; if (isWayland()) { // wl-clipboard provides both wl-paste and wl-copy - if (await whichCmd('wl-paste')) { cachedTool = { tool: 'wl-clipboard', display: 'wayland' }; return cachedTool; } + if (await whichCmd('wl-paste')) { + cachedTool = { tool: 'wl-clipboard', display: 'wayland' }; + return cachedTool; + } throw new AppError( 'TOOL_MISSING', 'wl-paste (wl-clipboard) is required for clipboard access on Wayland. Install via your package manager.', ); } - if (await whichCmd('xclip')) { cachedTool = { tool: 'xclip', display: 'x11' }; return cachedTool; } - if (await whichCmd('xsel')) { cachedTool = { tool: 'xsel', display: 'x11' }; return cachedTool; } + if (await whichCmd('xclip')) { + cachedTool = { tool: 'xclip', display: 'x11' }; + return cachedTool; + } + if (await whichCmd('xsel')) { + cachedTool = { tool: 'xsel', display: 'x11' }; + return cachedTool; + } throw new AppError( 'TOOL_MISSING', 'xclip or xsel is required for clipboard access on X11. Install via your package manager.', @@ -36,15 +48,24 @@ export async function readLinuxClipboard(): Promise { switch (tool) { case 'wl-clipboard': { - const result = await runCmd('wl-paste', ['--no-newline'], { allowFailure: true, timeoutMs: 5000 }); + const result = await runCmd('wl-paste', ['--no-newline'], { + allowFailure: true, + timeoutMs: 5000, + }); return result.stdout; } case 'xclip': { - const result = await runCmd('xclip', ['-selection', 'clipboard', '-o'], { allowFailure: true, timeoutMs: 5000 }); + const result = await runCmd('xclip', ['-selection', 'clipboard', '-o'], { + allowFailure: true, + timeoutMs: 5000, + }); return result.stdout; } case 'xsel': { - const result = await runCmd('xsel', ['--clipboard', '--output'], { allowFailure: true, timeoutMs: 5000 }); + const result = await runCmd('xsel', ['--clipboard', '--output'], { + allowFailure: true, + timeoutMs: 5000, + }); return result.stdout; } } @@ -58,10 +79,18 @@ export async function writeLinuxClipboard(text: string): Promise { await runCmd('wl-copy', ['--', text], { allowFailure: false, timeoutMs: 5000 }); break; case 'xclip': - await runCmd('xclip', ['-selection', 'clipboard'], { allowFailure: false, timeoutMs: 5000, stdin: text }); + await runCmd('xclip', ['-selection', 'clipboard'], { + allowFailure: false, + timeoutMs: 5000, + stdin: text, + }); break; case 'xsel': - await runCmd('xsel', ['--clipboard', '--input'], { allowFailure: false, timeoutMs: 5000, stdin: text }); + await runCmd('xsel', ['--clipboard', '--input'], { + allowFailure: false, + timeoutMs: 5000, + stdin: text, + }); break; } } diff --git a/src/platforms/linux/input-actions.ts b/src/platforms/linux/input-actions.ts index de4445f57..cd3dbc6eb 100644 --- a/src/platforms/linux/input-actions.ts +++ b/src/platforms/linux/input-actions.ts @@ -78,11 +78,7 @@ export async function doubleClickLinux(x: number, y: number): Promise { } } -export async function longPressLinux( - x: number, - y: number, - durationMs = 800, -): Promise { +export async function longPressLinux(x: number, y: number, durationMs = 800): Promise { const { tool } = await ensureInputTool(); await moveTo(x, y); if (tool === 'xdotool') { @@ -139,9 +135,10 @@ export async function scrollLinux( // ydotool wheel units are ~40px each. let scrollCount = DEFAULT_SCROLL_CLICKS; if (options?.pixels != null) { - scrollCount = tool === 'xdotool' - ? Math.max(1, Math.round(options.pixels / 15)) - : Math.max(1, Math.round(options.pixels / 40)); + scrollCount = + tool === 'xdotool' + ? Math.max(1, Math.round(options.pixels / 15)) + : Math.max(1, Math.round(options.pixels / 40)); } else if (options?.amount != null) { // amount is a fraction (0–1+) of the viewport; scale relative to default scrollCount = Math.max(1, Math.round(DEFAULT_SCROLL_CLICKS * (options.amount / 0.6))); @@ -149,7 +146,8 @@ export async function scrollLinux( // xdotool: button 4=up, 5=down, 6=left, 7=right if (tool === 'xdotool') { - const button = direction === 'up' ? '4' : direction === 'down' ? '5' : direction === 'left' ? '6' : '7'; + const button = + direction === 'up' ? '4' : direction === 'down' ? '5' : direction === 'left' ? '6' : '7'; await xdotool('click', '--repeat', String(scrollCount), button); } else { // ydotool: wheel events use positive/negative values @@ -177,12 +175,7 @@ export async function typeLinux(text: string, delayMs = 0): Promise { } } -export async function fillLinux( - x: number, - y: number, - text: string, - delayMs = 0, -): Promise { +export async function fillLinux(x: number, y: number, text: string, delayMs = 0): Promise { // Click to focus the field await pressLinux(x, y); await sleep(100); diff --git a/src/platforms/linux/screenshot.ts b/src/platforms/linux/screenshot.ts index d27ddfc91..1d5985999 100644 --- a/src/platforms/linux/screenshot.ts +++ b/src/platforms/linux/screenshot.ts @@ -6,21 +6,39 @@ type ScreenshotTool = 'grim' | 'gnome-screenshot' | 'scrot' | 'import'; let cachedTool: { tool: ScreenshotTool; display: 'wayland' | 'x11' } | null = null; -async function resolveScreenshotTool(): Promise<{ tool: ScreenshotTool; display: 'wayland' | 'x11' }> { +async function resolveScreenshotTool(): Promise<{ + tool: ScreenshotTool; + display: 'wayland' | 'x11'; +}> { if (cachedTool) return cachedTool; if (isWayland()) { - if (await whichCmd('grim')) { cachedTool = { tool: 'grim', display: 'wayland' }; return cachedTool; } - if (await whichCmd('gnome-screenshot')) { cachedTool = { tool: 'gnome-screenshot', display: 'wayland' }; return cachedTool; } + if (await whichCmd('grim')) { + cachedTool = { tool: 'grim', display: 'wayland' }; + return cachedTool; + } + if (await whichCmd('gnome-screenshot')) { + cachedTool = { tool: 'gnome-screenshot', display: 'wayland' }; + return cachedTool; + } throw new AppError( 'TOOL_MISSING', 'grim or gnome-screenshot is required for screenshots on Wayland. Install via your package manager.', ); } - if (await whichCmd('scrot')) { cachedTool = { tool: 'scrot', display: 'x11' }; return cachedTool; } - if (await whichCmd('import')) { cachedTool = { tool: 'import', display: 'x11' }; return cachedTool; } - if (await whichCmd('gnome-screenshot')) { cachedTool = { tool: 'gnome-screenshot', display: 'x11' }; return cachedTool; } + if (await whichCmd('scrot')) { + cachedTool = { tool: 'scrot', display: 'x11' }; + return cachedTool; + } + if (await whichCmd('import')) { + cachedTool = { tool: 'import', display: 'x11' }; + return cachedTool; + } + if (await whichCmd('gnome-screenshot')) { + cachedTool = { tool: 'gnome-screenshot', display: 'x11' }; + return cachedTool; + } throw new AppError( 'TOOL_MISSING', 'scrot, import (ImageMagick), or gnome-screenshot is required for screenshots on X11. Install via your package manager.', diff --git a/src/platforms/linux/snapshot.ts b/src/platforms/linux/snapshot.ts index 6231d4c72..e0dbe85e4 100644 --- a/src/platforms/linux/snapshot.ts +++ b/src/platforms/linux/snapshot.ts @@ -22,9 +22,7 @@ function resolveLinuxSurface(surface: SessionSurface | undefined): SnapshotSurfa return 'desktop'; } -export async function snapshotLinux( - surface: SessionSurface | undefined, -): Promise<{ +export async function snapshotLinux(surface: SessionSurface | undefined): Promise<{ nodes: RawSnapshotNode[]; truncated?: boolean; }> { diff --git a/src/utils/__tests__/mobile-snapshot-semantics.test.ts b/src/utils/__tests__/mobile-snapshot-semantics.test.ts index 5d77d1706..90722f71f 100644 --- a/src/utils/__tests__/mobile-snapshot-semantics.test.ts +++ b/src/utils/__tests__/mobile-snapshot-semantics.test.ts @@ -226,9 +226,7 @@ test('mobile presentation handles nodes with negative coordinates', () => { ]; const presentation = buildMobileSnapshotPresentation(nodes); - const visibleLabels = presentation.nodes - .filter((n) => n.label) - .map((n) => n.label); + const visibleLabels = presentation.nodes.filter((n) => n.label).map((n) => n.label); assert.ok(visibleLabels.includes('Visible')); }); diff --git a/src/utils/remote-config.ts b/src/utils/remote-config.ts index 89eda193f..dd9cf312b 100644 --- a/src/utils/remote-config.ts +++ b/src/utils/remote-config.ts @@ -77,7 +77,11 @@ export function loadRemoteConfigFile(options: { env?: EnvMap; }): Partial { const env = options.env ?? process.env; - const resolvedPath = resolveUserPath(options.configPath, { cwd: options.cwd, env }); + const resolvedPath = resolveRemoteConfigPath({ + configPath: options.configPath, + cwd: options.cwd, + env, + }); if (!fs.existsSync(resolvedPath)) { throw new AppError('INVALID_ARGS', `Remote config file not found: ${resolvedPath}`); } @@ -139,6 +143,15 @@ export function loadRemoteConfigFile(options: { return flags; } +export function resolveRemoteConfigPath(options: { + configPath: string; + cwd: string; + env?: EnvMap; +}): string { + const env = options.env ?? process.env; + return resolveUserPath(options.configPath, { cwd: options.cwd, env }); +} + export function resolveRemoteConfigDefaults(options: { cliFlags: CliFlags; cwd: string; diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 000000000..4d9e6781d --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,3 @@ +export function normalizeBaseUrl(input: string): string { + return input.replace(/\/+$/, ''); +} diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bf52cc19a..70a89d152 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -54,7 +54,7 @@ agent-device app-switcher - Tenant-scoped daemon runs can pass `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` to enforce lease admission. - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. -- `open --remote-config --relaunch` is the canonical remote Metro-backed launch flow for sandbox agents. The remote profile supplies the remote host + Metro settings, `open` prepares Metro locally when needed, derives platform runtime hints, and forwards them inline to the remote daemon before launch. +- `open --remote-config --relaunch` is the canonical remote Metro-backed launch flow for sandbox agents. The remote profile supplies the remote host + Metro settings, `open` prepares Metro locally when needed, auto-starts the local Metro companion tunnel when the remote bridge requires it, derives platform runtime hints, and forwards them inline to the remote daemon before launch. - `metro prepare --remote-config ` remains available for inspection and debugging. It prints JSON runtime hints to stdout, `--json` wraps them in the standard `{ success, data }` envelope, and `--runtime-file ` persists the same payload when callers need an artifact. - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. - Remote daemon screenshots and recordings are downloaded back to the caller path, so `screenshot page.png` and `record start session.mp4` remain usable when the daemon runs on another host. @@ -627,9 +627,11 @@ agent-device metro prepare --remote-config ./agent-device.remote.json --json ``` - `--remote-config ` points to a remote workflow profile that captures stable host + Metro settings. -- `open --remote-config ... --relaunch` is the main agent flow. It prepares Metro locally, derives platform runtime hints, and forwards them inline to the remote daemon before launch. +- `open --remote-config ... --relaunch` is the main agent flow. It prepares Metro locally, auto-manages the local Metro companion when the remote bridge is not already connected, derives platform runtime hints, and forwards them inline to the remote daemon before launch. - `snapshot`, `press`, `fill`, `screenshot`, and other normal commands can reuse the same `--remote-config` profile so agents do not need to repeat remote host/session selectors inline. - `metro prepare --remote-config ...` remains the inspection/debug path and can still write a `--runtime-file ` artifact when needed. +- The local Metro companion runs on the same machine as the React Native project and Metro. Users no longer need to launch it as a separate manual command for standard `agent-device` remote Metro flows. +- `close --remote-config ...` tears down the managed Metro companion for that profile, but it does not stop the user’s Metro server. ## Session inspection diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index 518bd5b62..c90fb41e9 100644 --- a/website/docs/docs/sessions.md +++ b/website/docs/docs/sessions.md @@ -34,7 +34,7 @@ Notes: - `open ` in iOS sessions opens deep links. - On iOS devices, `http(s)://` URLs open in Safari when no app is active. Custom scheme URLs require an active app in the session. - On iOS, `appstate` is session-scoped and requires a matching active session on the target device. -- `open --remote-config --relaunch` is the recommended remote Metro-backed session flow. It prepares Metro locally when needed, forwards the effective runtime hints inline on `open`, and keeps the session launch state internal. +- `open --remote-config --relaunch` is the recommended remote Metro-backed session flow. It prepares Metro locally when needed, auto-starts the local Metro companion tunnel when the remote bridge needs it, forwards the effective runtime hints inline on `open`, and keeps the session launch state internal. - Use `--session ` to run multiple sessions in parallel. For replay scripts and deterministic E2E guidance, see [Replay & E2E (Experimental)](/docs/replay-e2e).