diff --git a/skills/agent-device/SKILL.md b/skills/agent-device/SKILL.md index ca3d83f4..9e23c586 100644 --- a/skills/agent-device/SKILL.md +++ b/skills/agent-device/SKILL.md @@ -69,5 +69,5 @@ Use this skill as a router with mandatory defaults. Read this file first. For no - Need logs, network, alerts, permissions, or failure triage: [references/debugging.md](references/debugging.md) - 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) +- Need remote HTTP transport, `connect --remote-config`, 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 5e07cfab..cc15b8ee 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -2,95 +2,97 @@ ## When to open this file -Open this file for remote daemon HTTP flows, including `--remote-config` launches, that let an agent running in a Linux sandbox talk to another `agent-device` instance on a remote macOS host in order to control devices that are not available locally. This file covers daemon URL setup, authentication, lease allocation, and tenant-scoped command admission. +Open this file for remote daemon HTTP flows that let an agent running in a Linux sandbox talk to another `agent-device` instance on a remote macOS host in order to control devices that are not available locally. This file covers daemon URL setup, authentication, `connect`, tenant lease scope, and remote Metro companion lifecycle. ## Main commands to reach for first -- `agent-device open --remote-config --relaunch` -- `AGENT_DEVICE_DAEMON_BASE_URL=...` +- `agent-device connect --remote-config ` +- `agent-device connection status` +- `agent-device disconnect` - `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...` -- `agent-device --tenant ... --session-isolation tenant --run-id ... --lease-id ...` ## Most common mistake to avoid -Do not run a tenant-isolated command without matching `tenant`, `run`, and `lease` scope. Admission checks require all three to line up. +Do not run remote tenant work by repeating `--remote-config` on every command. `--remote-config` is a `connect` input. After connecting, use normal `agent-device` commands; the active connection supplies daemon URL, tenant, run, lease, and prepared Metro runtime context. -## Preferred remote launch path +## Preferred remote flow Use this when the agent needs the simplest remote control flow: a Linux sandbox agent talks over HTTP to `agent-device` on a remote macOS host and launches the target app through a checked-in `--remote-config` profile. ```bash -agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch +export AGENT_DEVICE_DAEMON_AUTH_TOKEN="YOUR_TOKEN" +export AGENT_DEVICE_PROXY_TOKEN="$AGENT_DEVICE_DAEMON_AUTH_TOKEN" + +agent-device connect \ + --remote-config ./remote-config.json + +agent-device install com.example.app ./app.apk +agent-device open com.example.app --relaunch +agent-device snapshot -i +agent-device fill @e3 "test@example.com" +agent-device disconnect ``` -- 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. +`connect` resolves the remote profile, verifies daemon reachability through the normal client path, allocates or refreshes the tenant lease, prepares local Metro when the profile has Metro fields, starts the local Metro companion when the bridge needs it, and writes local non-secret connection state for later commands. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease, and removes local connection state. -## Lease flow example +Use `agent-device connection status --session adc-android` to inspect the active connection without reading JSON state manually. Status output must not include auth tokens. -```bash -export AGENT_DEVICE_DAEMON_BASE_URL= -export AGENT_DEVICE_DAEMON_AUTH_TOKEN= - -agent-device \ - --tenant acme \ - --session-isolation tenant \ - --run-id run-123 \ - --lease-id \ - session list --json -``` +## Remote config shape -Low-level lease operations exist for host-side automation, but do not point them at arbitrary hosts. The remote daemon executes device-control commands, so only use a trusted daemon base URL and an auth token managed by the same operator boundary. +Example `remote-config.json` shape: -Lease lifecycle methods exposed by the daemon: +```json +{ + "daemonBaseUrl": "https://bridge.example.com/agent-device", + "daemonTransport": "http", + "tenant": "acme", + "runId": "run-123", + "sessionIsolation": "tenant", + "session": "adc-android", + "platform": "android", + "leaseBackend": "android-instance", + "metroProjectRoot": ".", + "metroPublicBaseUrl": "http://127.0.0.1:8081", + "metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123" +} +``` -- `agent_device.lease.allocate` -- `agent_device.lease.heartbeat` -- `agent_device.lease.release` -- `agent_device.command` +- Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state. +- Omit Metro fields for non-React Native flows. +- Put `tenant`, `runId`, `session`, `sessionIsolation`, `platform`, and `leaseBackend` in the remote profile when possible so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. +- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. +- For React Native Metro runs with `metroProxyBaseUrl`, `agent-device >= 0.11.12` can manage the local companion tunnel, but Metro itself still needs to be running locally. +- Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override. ## Transport prerequisites -- Start the daemon in HTTP mode with `AGENT_DEVICE_DAEMON_SERVER_MODE=http|dual`. -- Point the client at the remote host with `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]`. +- Start the daemon in HTTP mode with `AGENT_DEVICE_DAEMON_SERVER_MODE=http|dual` on the host. +- Point the profile or env at the remote host with `daemonBaseUrl` or `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]`. - For non-loopback remote hosts, set `AGENT_DEVICE_DAEMON_AUTH_TOKEN` or `--daemon-auth-token`. The client rejects non-loopback remote daemon URLs without auth. - Direct JSON-RPC callers can authenticate with request params, `Authorization: Bearer `, or `x-agent-device-token`. - Prefer an auth hook such as `AGENT_DEVICE_HTTP_AUTH_HOOK` when the host needs caller validation or tenant injection. -## Lease lifecycle - -Use JSON-RPC methods on `POST /rpc`: - -- `agent_device.lease.allocate` -- `agent_device.lease.heartbeat` -- `agent_device.lease.release` - -Keep the lease alive for the duration of the run and release it when the tenant-scoped work is complete. - -Host-level lease knobs: - -- `AGENT_DEVICE_MAX_SIMULATOR_LEASES` -- `AGENT_DEVICE_LEASE_TTL_MS` -- `AGENT_DEVICE_LEASE_MIN_TTL_MS` -- `AGENT_DEVICE_LEASE_MAX_TTL_MS` - -## Command admission contract +## Manual lease debug fallback -For tenant-isolated command execution, pass all four CLI flags together: +The main agent flow should use `connect`. Use manual JSON-RPC only for host-side automation or daemon-side auth/scope debugging, and only against trusted daemon hosts. ```bash -agent-device \ - --tenant acme \ - --session-isolation tenant \ - --run-id run-123 \ - --lease-id \ - session list --json +curl -fsS "$AGENT_DEVICE_DAEMON_BASE_URL/rpc" \ + -H "Authorization: Bearer $AGENT_DEVICE_DAEMON_AUTH_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": "lease-1", + "method": "agent_device.lease.allocate", + "params": { + "tenantId": "acme", + "runId": "run-123", + "backend": "android-instance" + } + }' ``` -The CLI sends `AGENT_DEVICE_DAEMON_AUTH_TOKEN` in both the JSON-RPC request token field and HTTP auth headers so existing daemon auth paths continue to work. +Related daemon methods are `agent_device.lease.allocate`, `agent_device.lease.heartbeat`, `agent_device.lease.release`, and `agent_device.command`. ## Failure semantics and trust notes diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 850d2382..b15ec39e 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -433,213 +433,6 @@ test('metro prepare with --remote-config loads profile defaults', async () => { assert.equal(payload.kind, 'react-native'); }); -test('open with --remote-config prepares Metro and forwards inline runtime hints', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-')); - const configDir = path.join(tmpRoot, 'config'); - fs.mkdirSync(configDir, { recursive: true }); - const remoteConfigPath = path.join(configDir, 'remote.json'); - fs.writeFileSync( - remoteConfigPath, - JSON.stringify({ - platform: 'android', - metroProjectRoot: './apps/demo', - metroRuntimeFile: './.agent-device-cloud/metro-runtime.json', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', - metroPreparePort: 9090, - }), - ); - const parsed = resolveCliOptions( - ['open', 'com.example.app', '--remote-config', remoteConfigPath], - { - cwd: tmpRoot, - env: process.env, - }, - ); - - let observedPrepare: MetroPrepareOptions | undefined; - let observedOpen: AppOpenOptions | undefined; - const client = createStubClient({ - installFromSource: async () => { - throw new Error('unexpected install call'); - }, - prepareMetro: async (options) => { - observedPrepare = options; - return { - projectRoot: '/tmp/project', - kind: 'react-native', - dependenciesInstalled: false, - packageManager: null, - started: false, - reused: true, - pid: 0, - logPath: '/tmp/project/.agent-device/metro.log', - statusUrl: 'http://127.0.0.1:8081/status', - runtimeFilePath: null, - iosRuntime: { - platform: 'ios', - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios', - }, - androidRuntime: { - platform: 'android', - metroHost: '10.0.2.2', - metroPort: 9090, - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', - launchUrl: 'myapp://dev', - }, - bridge: null, - }; - }, - open: async (options) => { - observedOpen = options; - return { - session: options.session ?? 'default', - runtime: options.runtime, - identifiers: { session: options.session ?? 'default' }, - }; - }, - }); - - const handled = await tryRunClientBackedCommand({ - command: 'open', - positionals: ['com.example.app'], - flags: { ...parsed.flags, relaunch: true }, - client, - }); - - assert.equal(handled, true); - assert.deepEqual(observedPrepare, { - projectRoot: path.join(configDir, 'apps/demo'), - kind: undefined, - publicBaseUrl: 'https://sandbox.example.test', - proxyBaseUrl: 'https://proxy.example.test', - bearerToken: undefined, - launchUrl: undefined, - companionProfileKey: remoteConfigPath, - companionConsumerKey: undefined, - port: 9090, - listenHost: undefined, - statusHost: undefined, - startupTimeoutMs: undefined, - probeTimeoutMs: undefined, - reuseExisting: undefined, - installDependenciesIfNeeded: undefined, - runtimeFilePath: path.join(configDir, '.agent-device-cloud/metro-runtime.json'), - }); - assert.deepEqual(observedOpen?.runtime, { - platform: 'android', - metroHost: '10.0.2.2', - metroPort: 9090, - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', - launchUrl: 'myapp://dev', - }); -}); - -test('open with --remote-config does not reload the profile after CLI parsing', async () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-path-')); - const configDir = path.join(tmpRoot, 'config'); - fs.mkdirSync(configDir, { recursive: true }); - const remoteConfigPath = path.join(configDir, 'remote.json'); - fs.writeFileSync( - remoteConfigPath, - JSON.stringify({ - platform: 'android', - metroProjectRoot: './apps/demo', - metroPublicBaseUrl: 'https://sandbox.example.test', - }), - ); - - const parsed = resolveCliOptions( - ['open', 'com.example.app', '--remote-config', remoteConfigPath], - { - cwd: tmpRoot, - env: process.env, - }, - ); - fs.unlinkSync(remoteConfigPath); - - let observedPrepare: MetroPrepareOptions | undefined; - const client = createStubClient({ - installFromSource: async () => { - throw new Error('unexpected install call'); - }, - prepareMetro: async (options) => { - observedPrepare = options; - return { - projectRoot: '/tmp/project', - kind: 'react-native', - dependenciesInstalled: false, - packageManager: null, - started: false, - reused: true, - pid: 0, - logPath: '/tmp/project/.agent-device/metro.log', - statusUrl: 'http://127.0.0.1:8081/status', - runtimeFilePath: null, - iosRuntime: { - platform: 'ios', - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios', - }, - androidRuntime: { - platform: 'android', - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', - }, - bridge: null, - }; - }, - }); - - const handled = await tryRunClientBackedCommand({ - command: 'open', - positionals: ['com.example.app'], - flags: parsed.flags, - client, - }); - - assert.equal(handled, true); - assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath); -}); - -test('open with --remote-config preserves CLI overrides over profile defaults', () => { - const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-override-')); - const configDir = path.join(tmpRoot, 'config'); - fs.mkdirSync(configDir, { recursive: true }); - const remoteConfigPath = path.join(configDir, 'remote.json'); - fs.writeFileSync( - remoteConfigPath, - JSON.stringify({ - session: 'remote-session', - platform: 'android', - daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', - metroPublicBaseUrl: 'https://sandbox.example.test', - }), - ); - - const parsed = resolveCliOptions( - [ - 'open', - 'com.example.app', - '--remote-config', - remoteConfigPath, - '--session', - 'cli-session', - '--platform', - 'ios', - '--daemon-base-url', - 'http://cli-mac.example.test:9124/agent-device', - ], - { - cwd: tmpRoot, - env: process.env, - }, - ); - - assert.equal(parsed.flags.session, 'cli-session'); - assert.equal(parsed.flags.platform, 'ios'); - assert.equal(parsed.flags.daemonBaseUrl, 'http://cli-mac.example.test:9124/agent-device'); - assert.equal(parsed.flags.metroPublicBaseUrl, 'https://sandbox.example.test'); -}); - test('install prints command-owned success output in human mode', async () => { const client = createStubClient({ installFromSource: async () => { @@ -750,6 +543,21 @@ function createStubClient(params: { identifiers: { session: options.session ?? 'default' }, }), }, + leases: { + allocate: async (options) => ({ + leaseId: 'lease-1', + tenantId: options.tenant, + runId: options.runId, + backend: options.leaseBackend ?? 'ios-simulator', + }), + heartbeat: async (options) => ({ + leaseId: options.leaseId, + tenantId: options.tenant ?? 'tenant', + runId: options.runId ?? 'run', + backend: options.leaseBackend ?? 'ios-simulator', + }), + release: async () => ({ released: true }), + }, metro: { prepare: params.prepareMetro ?? diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index b9f80bd4..57f280f2 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -4,6 +4,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import { runCli } from '../cli.ts'; +import { hashRemoteConfigFile, readRemoteConnectionState } from '../remote-connection-state.ts'; import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts'; class ExitSignal extends Error { @@ -228,9 +229,11 @@ test('AGENT_DEVICE_CONFIG loads an explicit config path', async () => { fs.rmSync(root, { recursive: true, force: true }); }); -test('remote config defaults override generic config and env for remote workflow bindings', async () => { +test('active remote connection defaults override generic config and env for remote commands', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); + const stateDir = path.join(root, 'state'); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); fs.writeFileSync( path.join(project, 'agent-device.json'), JSON.stringify({ session: 'project-session', platform: 'ios' }), @@ -246,8 +249,25 @@ test('remote config defaults override generic config and env for remote workflow }), 'utf8', ); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'env-session.json'), + JSON.stringify({ + version: 1, + session: 'env-session', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-123', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + 'utf8', + ); - const result = await runCliCapture(['snapshot', '--remote-config', remoteConfig, '--json'], { + const result = await runCliCapture(['snapshot', '--state-dir', stateDir, '--json'], { cwd: project, env: { HOME: home, @@ -258,18 +278,22 @@ test('remote config defaults override generic config and env for remote workflow assert.equal(result.code, null); assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.session, 'remote-session'); + assert.equal(result.calls[0]?.session, 'env-session'); assert.equal(result.calls[0]?.flags?.platform, 'android'); assert.equal( result.calls[0]?.flags?.daemonBaseUrl, 'http://remote-mac.example.test:9124/agent-device', ); + assert.equal(result.calls[0]?.meta?.tenantId, 'acme'); + assert.equal(result.calls[0]?.meta?.leaseId, 'lease-123'); fs.rmSync(root, { recursive: true, force: true }); }); -test('install-from-source forwards remote-config run id with explicit lease binding', async () => { +test('install-from-source uses active remote connection lease binding', async () => { const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); const remoteConfig = path.join(project, 'agent-device.remote.json'); fs.writeFileSync( remoteConfig, @@ -282,18 +306,27 @@ test('install-from-source forwards remote-config run id with explicit lease bind }), 'utf8', ); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'default.json'), + JSON.stringify({ + version: 1, + session: 'default', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'micha-pierzcha-a', + runId: 'demo-run-001', + leaseId: 'lease-demo-001', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + 'utf8', + ); const calls: Array> = []; const result = await runCliCapture( - [ - 'install-from-source', - 'https://example.com/app.apk', - '--remote-config', - remoteConfig, - '--lease-id', - 'lease-demo-001', - '--json', - ], + ['install-from-source', 'https://example.com/app.apk', '--state-dir', stateDir, '--json'], { cwd: project, env: { HOME: home }, @@ -330,7 +363,7 @@ test('install-from-source forwards remote-config run id with explicit lease bind test('missing explicit remote config path returns parse error before daemon dispatch', async () => { const { root, home, project } = makeTempWorkspace(); - const result = await runCliCapture(['snapshot', '--remote-config', './missing.remote.json'], { + const result = await runCliCapture(['connect', '--remote-config', './missing.remote.json'], { cwd: project, env: { HOME: home }, }); @@ -342,6 +375,165 @@ test('missing explicit remote config path returns parse error before daemon disp fs.rmSync(root, { recursive: true, force: true }); }); +test('normal commands reject direct remote-config usage', async () => { + const { root, home, project } = makeTempWorkspace(); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ daemonBaseUrl: 'http://127.0.0.1:9124' }), + 'utf8', + ); + + const result = await runCliCapture(['snapshot', '--remote-config', remoteConfig], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(result.code, 1); + assert.match(result.stderr, /--remote-config is only supported by connect and metro prepare/); + assert.equal(result.calls.length, 0); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('remote config hash drift blocks normal commands but not disconnect cleanup', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + platform: 'android', + }), + 'utf8', + ); + const originalHash = hashRemoteConfigFile(remoteConfig); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'default.json'), + JSON.stringify({ + version: 1, + session: 'default', + remoteConfigPath: remoteConfig, + remoteConfigHash: originalHash, + daemon: { + baseUrl: 'http://remote-mac.example.test:9124/agent-device', + transport: 'http', + }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-123', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + 'utf8', + ); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + platform: 'android', + metroPublicBaseUrl: 'http://127.0.0.1:8081', + }), + 'utf8', + ); + + const blocked = await runCliCapture(['snapshot', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + }); + assert.equal(blocked.code, 1); + assert.match(blocked.stdout, /Active remote connection config changed/); + + const disconnected = await runCliCapture(['disconnect', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + }); + assert.equal(disconnected.code, null); + assert.equal(disconnected.calls.at(-1)?.command, 'lease_release'); + assert.equal(readRemoteConnectionState({ stateDir, session: 'default' }), null); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('disconnect cleans connection state when remote config file is gone', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + platform: 'android', + }), + 'utf8', + ); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'default.json'), + JSON.stringify({ + version: 1, + session: 'default', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + daemon: { + baseUrl: 'http://remote-mac.example.test:9124/agent-device', + transport: 'http', + }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-123', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + 'utf8', + ); + fs.rmSync(remoteConfig, { force: true }); + + const disconnected = await runCliCapture(['disconnect', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(disconnected.code, null); + assert.equal(disconnected.calls.at(-1)?.command, 'lease_release'); + assert.equal( + disconnected.calls.at(-1)?.flags?.daemonBaseUrl, + 'http://remote-mac.example.test:9124/agent-device', + ); + assert.equal(readRemoteConnectionState({ stateDir, session: 'default' }), null); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('disconnect removes malformed connection state', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const connectionsDir = path.join(stateDir, 'remote-connections'); + const statePath = path.join(connectionsDir, 'default.json'); + const activePath = path.join(connectionsDir, '.active-session.json'); + fs.mkdirSync(connectionsDir, { recursive: true }); + fs.writeFileSync(statePath, '{not json', 'utf8'); + fs.writeFileSync(activePath, JSON.stringify({ session: 'default' }), 'utf8'); + + const disconnected = await runCliCapture(['disconnect', '--state-dir', stateDir, '--json'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(disconnected.code, null); + assert.equal(disconnected.calls.length, 0); + assert.equal(fs.existsSync(statePath), false); + assert.equal(fs.existsSync(activePath), false); + + fs.rmSync(root, { recursive: true, force: true }); +}); + test('config and env defaults include session lock policy flags', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); diff --git a/src/__tests__/client.test.ts b/src/__tests__/client.test.ts index 90491f2a..b133dbcb 100644 --- a/src/__tests__/client.test.ts +++ b/src/__tests__/client.test.ts @@ -1,8 +1,5 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts'; import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; import { AppError } from '../utils/errors.ts'; @@ -401,58 +398,42 @@ test('client.command.wait prepares selector options and rejects invalid selector assert.equal(setup.calls.length, 1); }); -test('remote-config defaults apply across daemon-backed client methods', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-client-remote-scope-')); - try { - const remoteConfig = path.join(tempRoot, 'remote.json'); - fs.writeFileSync( - remoteConfig, - JSON.stringify({ - session: 'remote-session', - platform: 'android', - daemonBaseUrl: 'http://127.0.0.1:9124/agent-device', - tenant: 'remote-tenant', - sessionIsolation: 'tenant', - runId: 'remote-run', - leaseId: 'remote-lease', - }), - ); - const setup = createTransport(async () => ({ - ok: true, - data: {}, - })); - const client = createAgentDeviceClient( - { - remoteConfig, - cwd: tempRoot, - }, - { transport: setup.transport }, - ); - fs.writeFileSync(remoteConfig, '{'); - - await client.devices.list(); - await client.command.home(); - const snapshot = await client.capture.snapshot(); - - assert.equal(setup.calls[0]?.session, 'remote-session'); - assert.equal(setup.calls[0]?.command, 'devices'); - assert.equal(setup.calls[0]?.flags?.platform, 'android'); - assert.equal(setup.calls[0]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device'); - assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant'); - assert.equal(setup.calls[1]?.session, 'remote-session'); - assert.equal(setup.calls[1]?.command, 'home'); - assert.equal(setup.calls[1]?.flags?.platform, 'android'); - assert.equal(setup.calls[1]?.flags?.daemonBaseUrl, 'http://127.0.0.1:9124/agent-device'); - assert.equal(setup.calls[1]?.meta?.tenantId, 'remote-tenant'); - assert.equal(setup.calls[1]?.meta?.runId, 'remote-run'); - assert.equal(setup.calls[1]?.meta?.leaseId, 'remote-lease'); - assert.equal(setup.calls[2]?.session, 'remote-session'); - assert.equal(setup.calls[2]?.command, 'snapshot'); - assert.equal(setup.calls[2]?.flags?.platform, 'android'); - assert.equal(snapshot.identifiers.session, 'remote-session'); - } finally { - fs.rmSync(tempRoot, { recursive: true, force: true }); - } +test('lease helpers forward scope through daemon-backed client methods', async () => { + const setup = createTransport(async (req) => ({ + ok: true, + data: + req.command === 'lease_release' + ? { released: true } + : { + lease: { + leaseId: req.meta?.leaseId ?? 'lease-new', + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + backend: req.meta?.leaseBackend, + }, + }, + })); + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); + + const allocated = await client.leases.allocate({ + tenant: 'remote-tenant', + runId: 'remote-run', + leaseBackend: 'android-instance', + }); + const released = await client.leases.release({ + tenant: 'remote-tenant', + runId: 'remote-run', + leaseId: allocated.leaseId, + }); + + assert.equal(setup.calls[0]?.command, 'lease_allocate'); + assert.equal(setup.calls[0]?.meta?.tenantId, 'remote-tenant'); + assert.equal(setup.calls[0]?.meta?.runId, 'remote-run'); + assert.equal(setup.calls[0]?.meta?.leaseBackend, 'android-instance'); + assert.equal(allocated.leaseId, 'lease-new'); + assert.equal(setup.calls[1]?.command, 'lease_release'); + assert.equal(setup.calls[1]?.meta?.leaseId, 'lease-new'); + assert.equal(released.released, true); }); test('client capture.snapshot preserves visibility metadata from daemon responses', async () => { diff --git a/src/__tests__/close-remote-metro.test.ts b/src/__tests__/close-remote-metro.test.ts deleted file mode 100644 index 7cad547a..00000000 --- a/src/__tests__/close-remote-metro.test.ts +++ /dev/null @@ -1,251 +0,0 @@ -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(); -}); - -const unexpectedCommandCall = async (): Promise => { - throw new Error('unexpected call'); -}; - -function createThrowingMethodGroup(methods: Partial = {}): T { - return new Proxy(methods, { - get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, - }) as T; -} - -function createTestClient(groups: Partial = {}): AgentDeviceClient { - return { - command: createThrowingMethodGroup(), - devices: createThrowingMethodGroup(), - sessions: createThrowingMethodGroup(), - simulators: createThrowingMethodGroup(), - apps: createThrowingMethodGroup(), - materializations: createThrowingMethodGroup(), - metro: createThrowingMethodGroup(), - capture: createThrowingMethodGroup(), - interactions: createThrowingMethodGroup(), - replay: createThrowingMethodGroup(), - batch: createThrowingMethodGroup(), - observability: createThrowingMethodGroup(), - recording: createThrowingMethodGroup(), - settings: createThrowingMethodGroup(), - ...groups, - }; -} - -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 = createTestClient({ - sessions: createThrowingMethodGroup({ - close: async () => ({ - session: 'adc-android', - identifiers: { session: 'adc-android' }, - }), - }), - }); - - 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 = createTestClient({ - sessions: createThrowingMethodGroup({ - close: async () => { - throw new Error('session close failed'); - }, - }), - }); - - 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 = createTestClient({ - apps: createThrowingMethodGroup({ - close: async () => ({ - session: 'adc-android', - identifiers: { session: 'adc-android' }, - }), - }), - }); - - 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 = createTestClient({ - sessions: createThrowingMethodGroup({ - close: async () => ({ - session: 'adc-android', - identifiers: { session: 'adc-android' }, - }), - }), - }); - - 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/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts new file mode 100644 index 00000000..4d0371bf --- /dev/null +++ b/src/__tests__/remote-connection.test.ts @@ -0,0 +1,834 @@ +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 { + connectCommand, + connectionCommand, + disconnectCommand, +} from '../cli/commands/connection.ts'; +import { stopMetroCompanion } from '../client-metro-companion.ts'; +import { AppError } from '../utils/errors.ts'; +import { + hashRemoteConfigFile, + readRemoteConnectionState, + writeRemoteConnectionState, +} from '../remote-connection-state.ts'; +import type { AgentDeviceClient, MetroPrepareOptions } from '../client.ts'; +import type { LeaseBackend } from '../contracts.ts'; + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); +}); + +const unexpectedCommandCall = async (): Promise => { + throw new Error('unexpected call'); +}; + +function createThrowingMethodGroup(methods: Partial = {}): T { + return new Proxy(methods, { + get: (target, property) => target[property as keyof T] ?? unexpectedCommandCall, + }) as T; +} + +function createTestClient( + options: { + allocate?: AgentDeviceClient['leases']['allocate']; + heartbeat?: AgentDeviceClient['leases']['heartbeat']; + release?: AgentDeviceClient['leases']['release']; + prepare?: AgentDeviceClient['metro']['prepare']; + closeSession?: AgentDeviceClient['sessions']['close']; + } = {}, +): AgentDeviceClient { + return { + command: createThrowingMethodGroup(), + devices: createThrowingMethodGroup(), + sessions: createThrowingMethodGroup({ + close: + options.closeSession ?? + (async () => ({ + session: 'adc-android', + identifiers: { session: 'adc-android' }, + })), + }), + simulators: createThrowingMethodGroup(), + apps: createThrowingMethodGroup(), + materializations: createThrowingMethodGroup(), + leases: createThrowingMethodGroup({ + allocate: + options.allocate ?? + (async (request) => ({ + leaseId: 'lease-1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + })), + heartbeat: + options.heartbeat ?? + (async (request) => ({ + leaseId: request.leaseId, + tenantId: request.tenant ?? 'acme', + runId: request.runId ?? 'run-123', + backend: request.leaseBackend ?? 'android-instance', + })), + release: options.release ?? (async () => ({ released: true })), + }), + metro: createThrowingMethodGroup({ + prepare: + options.prepare ?? + (async () => ({ + projectRoot: '/tmp/project', + kind: 'react-native', + dependenciesInstalled: false, + packageManager: null, + started: false, + reused: true, + pid: 0, + logPath: '/tmp/project/.agent-device/metro.log', + statusUrl: 'http://127.0.0.1:8081/status', + runtimeFilePath: null, + iosRuntime: { platform: 'ios' }, + androidRuntime: { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', + }, + bridge: null, + })), + }), + capture: createThrowingMethodGroup(), + interactions: createThrowingMethodGroup(), + replay: createThrowingMethodGroup(), + batch: createThrowingMethodGroup(), + observability: createThrowingMethodGroup(), + recording: createThrowingMethodGroup(), + settings: createThrowingMethodGroup(), + }; +} + +test('connect allocates a lease, prepares Metro, and writes connection state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ daemonBaseUrl: 'https://daemon.example.test' }), + ); + let observedBackend: LeaseBackend | undefined; + let observedPrepare: MetroPrepareOptions | undefined; + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: + 'https://user:pass@daemon.example.test/agent-device?token=redacted&apiKey=redacted&tenant=acme', + tenant: 'acme', + sessionIsolation: 'tenant', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ + allocate: async (request) => { + observedBackend = request.leaseBackend; + return { + leaseId: 'lease-1', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }; + }, + prepare: async (options) => { + observedPrepare = options; + return { + projectRoot: '/tmp/project', + kind: 'react-native', + dependenciesInstalled: false, + packageManager: null, + started: false, + reused: true, + pid: 0, + logPath: '/tmp/project/.agent-device/metro.log', + statusUrl: 'http://127.0.0.1:8081/status', + runtimeFilePath: null, + iosRuntime: { platform: 'ios' }, + androidRuntime: { platform: 'android', bundleUrl: 'https://bundle.example.test' }, + bridge: null, + }; + }, + }), + }); + }); + + const state = readRemoteConnectionState({ stateDir, session: 'adc-android' }); + assert.equal(observedBackend, 'android-instance'); + assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath); + assert.equal(state?.leaseId, 'lease-1'); + assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath)); + assert.deepEqual(state?.daemon, { + baseUrl: 'https://daemon.example.test/agent-device?tenant=acme', + }); + assert.equal(state?.metro?.projectRoot, '/tmp/project'); + assert.deepEqual(state?.runtime, { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect missing scope errors mention remote config or flags', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-scope-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + platform: 'android', + }, + client: createTestClient(), + }), + /connect requires tenant in remote config or via --tenant /, + ); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + platform: 'android', + }, + client: createTestClient(), + }), + /connect requires runId in remote config or via --run-id /, + ); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect reuses an active compatible lease by heartbeat', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-heartbeat-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let heartbeatCount = 0; + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + }, + client: createTestClient({ + heartbeat: async (request) => { + heartbeatCount += 1; + return { + leaseId: request.leaseId, + tenantId: request.tenant ?? 'acme', + runId: request.runId ?? 'run-123', + backend: 'android-instance', + }; + }, + allocate: async () => { + throw new Error('allocate should not run'); + }, + }), + }); + }); + + assert.equal(heartbeatCount, 1); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId, + 'lease-old', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect allocates a new lease when cloud reports the stored lease is inactive', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-stale-lease-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let allocateCount = 0; + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + }, + client: createTestClient({ + heartbeat: async () => { + throw new AppError('UNAUTHORIZED', 'Lease is not active', { + reason: 'LEASE_NOT_FOUND', + }); + }, + allocate: async (request) => { + allocateCount += 1; + return { + leaseId: 'lease-new', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }; + }, + }), + }); + }); + + assert.equal(allocateCount, 1); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId, + 'lease-new', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect does not allocate when heartbeat fails for auth or scope reasons', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-auth-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://daemon.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc-android', + platform: 'android', + }, + client: createTestClient({ + heartbeat: async () => { + throw new AppError('UNAUTHORIZED', 'Request rejected by auth hook', { + reason: 'AUTH_FAILED', + }); + }, + allocate: async () => { + throw new Error('allocate should not run'); + }, + }), + }), + /Request rejected by auth hook/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect requires force when compatible scope changes platform', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-platform-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc', + platform: 'ios', + leaseBackend: 'android-instance', + }, + client: createTestClient(), + }), + /A different remote connection is already active/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect requires force when the daemon endpoint changes', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-daemon-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://old.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: { baseUrl: 'https://old.example' }, + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://new.example', + tenant: 'acme', + runId: 'run-123', + session: 'adc', + platform: 'android', + }, + client: createTestClient(), + }), + /A different remote connection is already active/, + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect --force stops replaced Metro companion after state is updated', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-force-')); + const stateDir = path.join(tempRoot, '.state'); + const oldRemoteConfigPath = path.join(tempRoot, 'old-remote.json'); + const newRemoteConfigPath = path.join(tempRoot, 'new-remote.json'); + fs.writeFileSync(oldRemoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://old.example' })); + fs.writeFileSync(newRemoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://new.example' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath: oldRemoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(oldRemoteConfigPath), + tenant: 'acme', + runId: 'run-old', + leaseId: 'lease-old', + leaseBackend: 'android-instance', + daemon: { + baseUrl: 'https://old.example', + transport: 'http', + }, + metro: { + projectRoot: '/tmp/old-project', + profileKey: oldRemoteConfigPath, + consumerKey: 'adc-android', + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + let releaseRequest: Parameters[0] | undefined; + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + force: true, + stateDir, + remoteConfig: newRemoteConfigPath, + daemonBaseUrl: 'https://new.example', + tenant: 'acme', + runId: 'run-new', + session: 'adc-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ + release: async (request) => { + releaseRequest = request; + return { released: true }; + }, + }), + }); + }); + + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/old-project', + profileKey: oldRemoteConfigPath, + consumerKey: 'adc-android', + }); + assert.equal(releaseRequest?.leaseId, 'lease-old'); + assert.equal(releaseRequest?.daemonBaseUrl, 'https://old.example'); + assert.equal(releaseRequest?.daemonTransport, 'http'); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' })?.runId, 'run-new'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect cleans up prepared Metro companion if state write fails', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-fail-')); + const stateDir = path.join(tempRoot, 'state-file'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(stateDir, 'not a directory'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' })); + let releaseCount = 0; + + await assert.rejects( + async () => + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }, + client: createTestClient({ + release: async () => { + releaseCount += 1; + return { released: true }; + }, + }), + }), + ); + + assert.equal(releaseCount, 1); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'default', + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('disconnect tolerates prior close and removes local connection state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); + fs.writeFileSync(remoteConfigPath, '{}'); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'adc-android.json'), + JSON.stringify({ + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + metro: { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }), + ); + + let handled = false; + await captureStdout(async () => { + handled = await disconnectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + session: 'adc-android', + shutdown: true, + }, + client: createTestClient({ + closeSession: async () => { + throw new Error('already closed'); + }, + release: async () => ({ released: false }), + }), + }); + }); + + assert.equal(handled, true); + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' }), null); + assert.deepEqual(vi.mocked(stopMetroCompanion).mock.calls[0]?.[0], { + projectRoot: '/tmp/project', + profileKey: remoteConfigPath, + consumerKey: 'adc-android', + }); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('disconnect without a session uses active connection state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-disconnect-active-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + await captureStdout(async () => { + await disconnectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + shutdown: true, + }, + client: createTestClient(), + }); + }); + + assert.equal(readRemoteConnectionState({ stateDir, session: 'adc-android' }), null); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connection status reports missing state without daemon calls', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connection-status-')); + let handled = false; + await captureStdout(async () => { + handled = await connectionCommand({ + positionals: ['status'], + flags: { + json: true, + help: false, + version: false, + stateDir: path.join(tempRoot, '.state'), + session: 'adc-android', + }, + client: createTestClient(), + }); + }); + assert.equal(handled, true); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connection status reports active connection state', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connection-active-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'adc-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'acme', + runId: 'run-123', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + + const output = await captureStdout(async () => { + await connectionCommand({ + positionals: ['status'], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: createTestClient(), + }); + }); + + assert.equal(JSON.parse(output).data.session, 'adc-android'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connection state filenames distinguish unsafe session names', () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connection-state-names-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync(remoteConfigPath, '{}'); + const baseState = { + version: 1 as const, + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + tenant: 'acme', + runId: 'run-123', + leaseBackend: 'android-instance' as const, + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + writeRemoteConnectionState({ + stateDir, + state: { ...baseState, session: 'a/b', leaseId: 'lease-slash' }, + }); + writeRemoteConnectionState({ + stateDir, + state: { ...baseState, session: 'a_b', leaseId: 'lease-underscore' }, + }); + + assert.equal(readRemoteConnectionState({ stateDir, session: 'a/b' })?.leaseId, 'lease-slash'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'a_b' })?.leaseId, + 'lease-underscore', + ); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +async function captureStdout(run: () => Promise): Promise { + let stdout = ''; + const originalWrite = process.stdout.write.bind(process.stdout); + (process.stdout as any).write = ((chunk: unknown) => { + stdout += String(chunk); + return true; + }) as typeof process.stdout.write; + + try { + await run(); + } finally { + process.stdout.write = originalWrite; + } + + return stdout; +} diff --git a/src/cli.ts b/src/cli.ts index ad0a3383..c8cdfa3c 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -24,6 +24,9 @@ import { resolveDaemonPaths } from './daemon/config.ts'; import { applyDefaultPlatformBinding, resolveBindingSettings } from './utils/session-binding.ts'; import { resolveCliOptions } from './utils/cli-options.ts'; import { maybeRunUpgradeNotifier } from './utils/update-check.ts'; +import { resolveRemoteConnectionDefaults } from './remote-connection-state.ts'; +import type { CliFlags, FlagKey } from './utils/command-schema.ts'; +import type { SessionRuntimeHints } from './contracts.ts'; type CliDeps = { sendToDaemon: typeof sendToDaemon; @@ -112,48 +115,86 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } const { command, positionals } = parsed; - const binding = resolveBindingSettings({ - policyOverrides: parsed.flags, - configuredPlatform: parsed.flags.platform, - configuredSession: parsed.flags.session, - }); - const flags = binding.lockPolicy - ? { ...parsed.flags } - : applyDefaultPlatformBinding(parsed.flags, { - policyOverrides: parsed.flags, - configuredPlatform: parsed.flags.platform, - configuredSession: parsed.flags.session, - }); - const daemonPaths = resolveDaemonPaths(flags.stateDir); - const sessionName = flags.session ?? 'default'; + let binding: ReturnType; + let flags: typeof parsed.flags; + let daemonPaths: ReturnType; + let sessionName: string; + let connectionDefaults: ReturnType; + let effectiveFlags: typeof parsed.flags; + const explicitFlagKeys = new Set(parsed.providedFlags.map((entry) => entry.key)); + try { + if (parsed.flags.remoteConfig && command !== 'connect' && command !== 'metro') { + throw new AppError( + 'INVALID_ARGS', + '--remote-config is only supported by connect and metro prepare. Run agent-device connect first, then use normal commands.', + ); + } + binding = resolveBindingSettings({ + policyOverrides: parsed.flags, + configuredPlatform: parsed.flags.platform, + configuredSession: parsed.flags.session, + }); + flags = binding.lockPolicy + ? { ...parsed.flags } + : applyDefaultPlatformBinding(parsed.flags, { + policyOverrides: parsed.flags, + configuredPlatform: parsed.flags.platform, + configuredSession: parsed.flags.session, + }); + daemonPaths = resolveDaemonPaths(flags.stateDir); + sessionName = flags.session ?? 'default'; + connectionDefaults = resolveActiveConnectionDefaults({ + command, + explicitFlagKeys, + stateDir: daemonPaths.baseDir, + session: sessionName, + }); + effectiveFlags = connectionDefaults + ? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys) + : flags; + } catch (err) { + const appErr = asAppError(err); + const normalized = normalizeError(appErr, { + diagnosticId: getDiagnosticsMeta().diagnosticId, + logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined, + }); + if (parsed.flags.json) { + printJson({ success: false, error: normalized }); + } else { + printHumanError(normalized, { showDetails: parsed.flags.verbose }); + } + process.exit(1); + return; + } maybeRunUpgradeNotifier({ command, currentVersion: version, stateDir: daemonPaths.baseDir, - flags, + flags: effectiveFlags, }); - const remoteDaemonBaseUrl = flags.daemonBaseUrl; + const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl; const logTailStopper = - flags.verbose && !flags.json && !remoteDaemonBaseUrl + effectiveFlags.verbose && !effectiveFlags.json && !remoteDaemonBaseUrl ? startDaemonLogTail(daemonPaths.logPath) : null; const clientConfig: AgentDeviceClientConfig = { - session: sessionName, + session: effectiveFlags.session ?? sessionName, requestId, - stateDir: flags.stateDir, - daemonBaseUrl: flags.daemonBaseUrl, - daemonAuthToken: flags.daemonAuthToken, - daemonTransport: flags.daemonTransport, - daemonServerMode: flags.daemonServerMode, - remoteConfig: flags.remoteConfig, - tenant: flags.tenant, - sessionIsolation: flags.sessionIsolation, - runId: flags.runId, - leaseId: flags.leaseId, + stateDir: effectiveFlags.stateDir, + daemonBaseUrl: effectiveFlags.daemonBaseUrl, + daemonAuthToken: effectiveFlags.daemonAuthToken, + daemonTransport: effectiveFlags.daemonTransport, + daemonServerMode: effectiveFlags.daemonServerMode, + tenant: effectiveFlags.tenant, + sessionIsolation: effectiveFlags.sessionIsolation, + runId: effectiveFlags.runId, + leaseId: effectiveFlags.leaseId, + leaseBackend: effectiveFlags.leaseBackend, + runtime: connectionDefaults?.runtime, lockPolicy: binding.lockPolicy, lockPlatform: binding.defaultPlatform, cwd: process.cwd(), - debug: Boolean(flags.verbose), + debug: Boolean(effectiveFlags.verbose), }; const client = createAgentDeviceClient(clientConfig, { transport: deps.sendToDaemon as AgentDeviceDaemonTransport, @@ -169,17 +210,17 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): binding.lockPolicy && flags.platform === undefined ? { ...((step.flags ?? {}) as Partial) } : applyDefaultPlatformBinding((step.flags ?? {}) as Partial, { - policyOverrides: flags, - configuredPlatform: flags.platform, - configuredSession: flags.session, - inheritedPlatform: flags.platform, + policyOverrides: effectiveFlags, + configuredPlatform: effectiveFlags.platform, + configuredSession: effectiveFlags.session, + inheritedPlatform: effectiveFlags.platform, }), })); if ( await tryRunClientBackedCommand({ command, positionals, - flags: { ...flags, batchSteps }, + flags: { ...effectiveFlags, batchSteps }, client, }) ) { @@ -188,9 +229,11 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): } else if (command === 'runtime') { throw new AppError( 'INVALID_ARGS', - 'runtime command was removed. Use open --remote-config --relaunch for remote Metro launches, or metro prepare --remote-config for inspection.', + 'runtime command was removed. Use connect --remote-config for remote runs, or metro prepare --remote-config for inspection.', ); - } else if (await tryRunClientBackedCommand({ command, positionals, flags, client })) { + } else if ( + await tryRunClientBackedCommand({ command, positionals, flags: effectiveFlags, client }) + ) { return; } @@ -202,19 +245,19 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): logPath: flushDiagnosticsToSessionFile({ force: true }) ?? undefined, }); if (command === 'close' && isDaemonStartupFailure(appErr)) { - if (flags.json) { + if (effectiveFlags.json) { printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } }); } return; } - if (flags.json) { + if (effectiveFlags.json) { printJson({ success: false, error: normalized, }); } else { - printHumanError(normalized, { showDetails: flags.verbose }); - if (flags.verbose) { + printHumanError(normalized, { showDetails: effectiveFlags.verbose }); + if (effectiveFlags.verbose) { try { const logPath = daemonPaths.logPath; if (fs.existsSync(logPath)) { @@ -264,6 +307,42 @@ function isDaemonStartupFailure(error: AppError): boolean { return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string'; } +function resolveActiveConnectionDefaults(options: { + command: string; + explicitFlagKeys: Set; + stateDir: string; + session: string; +}): { + flags: Partial; + runtime?: SessionRuntimeHints; +} | null { + if (options.command === 'connect' || options.command === 'connection') return null; + if (options.explicitFlagKeys.has('remoteConfig')) return null; + const defaults = resolveRemoteConnectionDefaults({ + stateDir: options.stateDir, + session: options.session, + cwd: process.cwd(), + env: process.env, + allowActiveFallback: !options.explicitFlagKeys.has('session'), + validateRemoteConfigHash: options.command !== 'disconnect', + }); + return defaults; +} + +function mergeConnectionFlags( + flags: CliFlags, + defaults: Partial, + explicitFlagKeys: Set, +): CliFlags { + const merged = { ...flags }; + for (const [key, value] of Object.entries(defaults) as Array<[FlagKey, unknown]>) { + if (value === undefined) continue; + if (explicitFlagKeys.has(key)) continue; + (merged as Record)[key] = value; + } + return merged; +} + function guessSessionFromArgv(argv: string[]): string | null { for (let i = 0; i < argv.length; i += 1) { const token = argv[i]; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts new file mode 100644 index 00000000..ef84efa5 --- /dev/null +++ b/src/cli/commands/connection.ts @@ -0,0 +1,413 @@ +import { resolveDaemonPaths } from '../../daemon/config.ts'; +import { stopMetroTunnel } from '../../metro.ts'; +import { resolveRemoteConfigProfile } from '../../remote-config.ts'; +import { + fingerprint, + hashRemoteConfigFile, + readActiveConnectionState, + readRemoteConnectionState, + removeRemoteConnectionState, + writeRemoteConnectionState, + type RemoteConnectionState, +} from '../../remote-connection-state.ts'; +import { AppError } from '../../utils/errors.ts'; +import { writeCommandOutput } from './shared.ts'; +import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; +import type { CliFlags } from '../../utils/command-schema.ts'; +import type { AgentDeviceClient, Lease } from '../../client.ts'; +import type { ClientCommandHandler } from './router.ts'; + +export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { + if (!flags.remoteConfig) { + throw new AppError('INVALID_ARGS', 'connect requires --remote-config .'); + } + const session = flags.session ?? 'default'; + const tenant = flags.tenant; + const runId = flags.runId; + if (!tenant) { + throw new AppError( + 'INVALID_ARGS', + 'connect requires tenant in remote config or via --tenant .', + ); + } + if (!runId) { + throw new AppError( + 'INVALID_ARGS', + 'connect requires runId in remote config or via --run-id .', + ); + } + if (!flags.daemonBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'connect requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', + ); + } + + const remoteConfig = resolveRemoteConfigProfile({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }); + const remoteConfigHash = hashRemoteConfigFile(remoteConfig.resolvedPath); + const leaseBackend = flags.leaseBackend ?? inferLeaseBackend(flags.platform); + const daemon = buildDaemonState(flags); + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const previous = readRemoteConnectionState({ stateDir, session }); + if ( + previous && + !isCompatibleConnection(previous, { + flags, + remoteConfigPath: remoteConfig.resolvedPath, + remoteConfigHash, + leaseBackend, + daemon, + }) + ) { + if (!flags.force) { + throw new AppError( + 'INVALID_ARGS', + 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', + { session, remoteConfig: previous.remoteConfigPath }, + ); + } + } + + let lease: Lease | undefined; + let allocatedForThisCommand = false; + let metroCleanup: NonNullable | undefined; + let statePersisted = false; + try { + lease = + previous && previous.leaseId && !flags.force + ? await heartbeatOrAllocateLease(client, previous.leaseId, { tenant, runId, leaseBackend }) + : undefined; + if (!lease) { + lease = await client.leases.allocate({ tenant, runId, leaseBackend }); + allocatedForThisCommand = true; + } + + const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session); + metroCleanup = metro.cleanup; + const now = new Date().toISOString(); + const state: RemoteConnectionState = { + version: 1, + session, + remoteConfigPath: remoteConfig.resolvedPath, + remoteConfigHash, + daemon, + tenant, + runId, + leaseId: lease.leaseId, + leaseBackend, + platform: flags.platform, + target: flags.target, + runtime: metro.runtime, + metro: metroCleanup, + connectedAt: previous && !flags.force ? previous.connectedAt : now, + updatedAt: now, + }; + writeRemoteConnectionState({ stateDir, state }); + statePersisted = true; + if ( + previous && + flags.force && + (previous.metro?.projectRoot !== metroCleanup?.projectRoot || + previous.metro?.profileKey !== metroCleanup?.profileKey || + previous.metro?.consumerKey !== metroCleanup?.consumerKey) + ) { + await stopMetroCleanup(previous.metro); + } + if ( + previous && + flags.force && + (previous.tenant !== state.tenant || + previous.runId !== state.runId || + previous.leaseId !== state.leaseId || + !isSameDaemonState(previous.daemon, state.daemon)) + ) { + await releasePreviousLease(client, previous); + } + + writeCommandOutput( + flags, + serializeConnectionState(state), + () => + `Connected remote session "${session}" tenant "${tenant}" run "${runId}" lease ${state.leaseId}`, + ); + } catch (error) { + if (!statePersisted) { + await stopMetroCleanup(metroCleanup); + } + if (allocatedForThisCommand && lease) { + try { + await client.leases.release({ tenant, runId, leaseId: lease.leaseId }); + } catch { + // Best-effort cleanup; preserve the original connection failure. + } + } + throw error; + } + return true; +}; + +export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { + const session = flags.session ?? 'default'; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = + readRemoteConnectionState({ stateDir, session }) ?? + (flags.session ? null : readActiveConnectionState({ stateDir })); + if (!state) { + writeCommandOutput( + flags, + { connected: false, session }, + () => `No remote connection for "${session}".`, + ); + return true; + } + const connectedSession = state.session; + + try { + await client.sessions.close({ shutdown: flags.shutdown }); + } catch { + // Disconnect is idempotent; the session may already be closed. + } + await stopMetroCleanup(state.metro); + let released = false; + try { + const result = await client.leases.release({ + tenant: state.tenant, + runId: state.runId, + leaseId: state.leaseId, + }); + released = result.released; + } catch { + // Bridges may release on close or be unreachable; local state still needs cleanup. + } + removeRemoteConnectionState({ stateDir, session: connectedSession }); + writeCommandOutput( + flags, + { connected: false, session: connectedSession, released }, + () => `Disconnected remote session "${connectedSession}".`, + ); + return true; +}; + +export const connectionCommand: ClientCommandHandler = async ({ positionals, flags }) => { + if (positionals[0] !== 'status') { + throw new AppError('INVALID_ARGS', 'connection accepts only: status'); + } + const session = flags.session ?? 'default'; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const state = + readRemoteConnectionState({ stateDir, session }) ?? + (flags.session ? null : readActiveConnectionState({ stateDir })); + if (!state) { + writeCommandOutput( + flags, + { connected: false, session }, + () => `No remote connection for "${session}".`, + ); + return true; + } + writeCommandOutput(flags, serializeConnectionState(state), () => + [ + `Connected remote session "${state.session}".`, + `tenant=${state.tenant} runId=${state.runId} leaseId=${state.leaseId} backend=${state.leaseBackend}`, + `remoteConfig=${state.remoteConfigPath}`, + state.runtime ? 'metro=prepared' : 'metro=not-prepared', + ].join('\n'), + ); + return true; +}; + +async function heartbeatOrAllocateLease( + client: AgentDeviceClient, + leaseId: string, + scope: { tenant: string; runId: string; leaseBackend: LeaseBackend }, +): Promise { + try { + return await client.leases.heartbeat({ + tenant: scope.tenant, + runId: scope.runId, + leaseId, + leaseBackend: scope.leaseBackend, + }); + } catch (error) { + if (isInactiveLeaseError(error)) return undefined; + throw error; + } +} + +async function prepareConnectedMetro( + flags: CliFlags, + client: AgentDeviceClient, + remoteConfigPath: string, + session: string, +): Promise<{ + runtime?: SessionRuntimeHints; + cleanup?: NonNullable; +}> { + if (!flags.metroProjectRoot && !flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) { + return {}; + } + if (flags.platform !== 'ios' && flags.platform !== 'android') { + throw new AppError( + 'INVALID_ARGS', + 'connect Metro preparation requires platform "ios" or "android".', + ); + } + if (!flags.metroPublicBaseUrl) { + throw new AppError('INVALID_ARGS', 'connect Metro preparation requires metroPublicBaseUrl.'); + } + const prepared = await client.metro.prepare({ + projectRoot: flags.metroProjectRoot, + kind: flags.metroKind, + publicBaseUrl: flags.metroPublicBaseUrl, + proxyBaseUrl: flags.metroProxyBaseUrl, + bearerToken: flags.metroBearerToken, + launchUrl: flags.launchUrl, + companionProfileKey: remoteConfigPath, + companionConsumerKey: session, + port: flags.metroPreparePort, + listenHost: flags.metroListenHost, + statusHost: flags.metroStatusHost, + startupTimeoutMs: flags.metroStartupTimeoutMs, + probeTimeoutMs: flags.metroProbeTimeoutMs, + reuseExisting: flags.metroNoReuseExisting ? false : undefined, + installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, + runtimeFilePath: flags.metroRuntimeFile, + }); + return { + runtime: flags.platform === 'ios' ? prepared.iosRuntime : prepared.androidRuntime, + cleanup: flags.metroProxyBaseUrl + ? { + projectRoot: prepared.projectRoot, + profileKey: remoteConfigPath, + consumerKey: session, + } + : undefined, + }; +} + +async function stopMetroCleanup( + cleanup: RemoteConnectionState['metro'] | undefined, +): Promise { + if (!cleanup) return; + try { + await stopMetroTunnel(cleanup); + } catch { + // Connection lifecycle cleanup must stay best-effort. + } +} + +async function releasePreviousLease( + client: AgentDeviceClient, + previous: RemoteConnectionState, +): Promise { + try { + await client.leases.release({ + tenant: previous.tenant, + runId: previous.runId, + leaseId: previous.leaseId, + daemonBaseUrl: previous.daemon?.baseUrl, + daemonTransport: previous.daemon?.transport, + daemonServerMode: previous.daemon?.serverMode, + }); + } catch { + // Reconnect must succeed even if the old lease was already released. + } +} + +function inferLeaseBackend(platform: CliFlags['platform']): LeaseBackend { + if (platform === 'android') return 'android-instance'; + if (platform === 'ios') return 'ios-instance'; + throw new AppError( + 'INVALID_ARGS', + 'connect requires --lease-backend when platform is not ios or android.', + ); +} + +function isCompatibleConnection( + state: RemoteConnectionState, + options: { + flags: CliFlags; + remoteConfigPath: string; + remoteConfigHash: string; + leaseBackend: LeaseBackend; + daemon: RemoteConnectionState['daemon']; + }, +): boolean { + return ( + state.remoteConfigPath === options.remoteConfigPath && + state.remoteConfigHash === options.remoteConfigHash && + state.session === (options.flags.session ?? 'default') && + state.tenant === options.flags.tenant && + state.runId === options.flags.runId && + state.leaseBackend === options.leaseBackend && + state.platform === options.flags.platform && + state.target === options.flags.target && + isSameDaemonState(state.daemon, options.daemon) + ); +} + +function isSameDaemonState( + a: RemoteConnectionState['daemon'], + b: RemoteConnectionState['daemon'], +): boolean { + return ( + (a?.baseUrl ?? undefined) === (b?.baseUrl ?? undefined) && + (a?.transport ?? undefined) === (b?.transport ?? undefined) && + (a?.serverMode ?? undefined) === (b?.serverMode ?? undefined) + ); +} + +function buildDaemonState(flags: CliFlags): RemoteConnectionState['daemon'] { + return { + baseUrl: sanitizeDaemonBaseUrl(flags.daemonBaseUrl), + transport: flags.daemonTransport, + serverMode: flags.daemonServerMode, + }; +} + +function sanitizeDaemonBaseUrl(value: string | undefined): string | undefined { + if (!value) return undefined; + const url = new URL(value); + url.username = ''; + url.password = ''; + for (const key of [...url.searchParams.keys()]) { + if (/(auth|key|password|secret|token)/i.test(key)) { + url.searchParams.delete(key); + } + } + return url.toString().replace(/\/+$/, ''); +} + +function isInactiveLeaseError(error: unknown): boolean { + if (!(error instanceof AppError) || error.code !== 'UNAUTHORIZED') return false; + return ( + error.details?.reason === 'LEASE_NOT_FOUND' || + error.details?.reason === 'LEASE_EXPIRED' || + error.details?.reason === 'LEASE_REVOKED' + ); +} + +function serializeConnectionState(state: RemoteConnectionState): Record { + return { + connected: true, + session: state.session, + tenant: state.tenant, + runId: state.runId, + leaseId: state.leaseId, + leaseBackend: state.leaseBackend, + platform: state.platform, + target: state.target, + remoteConfig: state.remoteConfigPath, + remoteConfigHash: state.remoteConfigHash, + daemonBaseUrlFingerprint: fingerprint(state.daemon?.baseUrl), + metro: state.metro + ? { prepared: true, projectRoot: state.metro.projectRoot } + : { prepared: false }, + connectedAt: state.connectedAt, + updatedAt: state.updatedAt, + }; +} diff --git a/src/cli/commands/open.ts b/src/cli/commands/open.ts index 51673032..56cace60 100644 --- a/src/cli/commands/open.ts +++ b/src/cli/commands/open.ts @@ -1,13 +1,8 @@ import { serializeCloseResult, serializeOpenResult } from '../../client-shared.ts'; -import { stopMetroTunnel } from '../../metro.ts'; -import { resolveRemoteOpenRuntime } from '../../core/remote-open.ts'; -import { resolveRemoteConfigProfile } from '../../remote-config.ts'; import { buildSelectionOptions, writeCommandMessage } from './shared.ts'; -import type { StopMetroTunnelOptions } from '../../metro.ts'; import type { ClientCommandHandler } from './router.ts'; export const openCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const runtime = await resolveRemoteOpenRuntime(flags, client); const result = await client.apps.open({ app: positionals[0], url: positionals[1], @@ -16,7 +11,6 @@ export const openCommand: ClientCommandHandler = async ({ positionals, flags, cl relaunch: flags.relaunch, saveScript: flags.saveScript, noRecord: flags.noRecord, - runtime, ...buildSelectionOptions(flags), }); const data = serializeOpenResult(result); @@ -25,50 +19,9 @@ export const openCommand: ClientCommandHandler = async ({ positionals, flags, cl }; export const closeCommand: ClientCommandHandler = async ({ positionals, flags, client }) => { - const resolveManagedMetroCompanionStopOptions = (): StopMetroTunnelOptions | null => { - if (!flags.remoteConfig) return null; - let remoteConfig; - try { - remoteConfig = resolveRemoteConfigProfile({ - configPath: flags.remoteConfig, - cwd: process.cwd(), - env: process.env, - }); - } catch { - return null; - } - if (!remoteConfig.profile.metroProjectRoot || !remoteConfig.profile.metroProxyBaseUrl) { - return null; - } - return { - projectRoot: remoteConfig.profile.metroProjectRoot, - profileKey: remoteConfig.resolvedPath, - consumerKey: flags.session, - }; - }; - - const managedMetroCompanionStopOptions = resolveManagedMetroCompanionStopOptions(); - - const runWithCompanionCleanup = async (runClose: () => Promise): Promise => { - try { - return await runClose(); - } finally { - try { - if (managedMetroCompanionStopOptions) { - await stopMetroTunnel(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 result = positionals[0] + ? await client.apps.close({ app: positionals[0], shutdown: flags.shutdown }) + : await client.sessions.close({ shutdown: flags.shutdown }); const data = serializeCloseResult(result); writeCommandMessage(flags, data); diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 683e6f3a..40e15e1a 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -8,6 +8,7 @@ import { metroCommand } from './metro.ts'; import { appsCommand } from './apps.ts'; import { installCommand, reinstallCommand, installFromSourceCommand } from './install.ts'; import { openCommand, closeCommand } from './open.ts'; +import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { snapshotCommand } from './snapshot.ts'; import { screenshotCommand, diffCommand } from './screenshot.ts'; import { clientCommandMethodHandlers } from './client-command.ts'; @@ -31,6 +32,9 @@ const dedicatedClientApiHandlers = { install: installCommand, reinstall: reinstallCommand, 'install-from-source': installFromSourceCommand, + connect: connectCommand, + disconnect: disconnectCommand, + connection: connectionCommand, open: openCommand, close: closeCommand, [CLIENT_COMMANDS.snapshot]: snapshotCommand, diff --git a/src/client-normalizers.ts b/src/client-normalizers.ts index af476826..a1f83cbe 100644 --- a/src/client-normalizers.ts +++ b/src/client-normalizers.ts @@ -241,6 +241,7 @@ export function buildFlags(options: InternalRequestOptions): CommandFlags { sessionIsolation: options.sessionIsolation, runId: options.runId, leaseId: options.leaseId, + leaseBackend: options.leaseBackend, platform: options.platform, target: options.target, device: options.device, @@ -311,6 +312,8 @@ export function buildMeta(options: InternalRequestOptions): DaemonRequest['meta' tenantId: options.tenant, runId: options.runId, leaseId: options.leaseId, + leaseBackend: options.leaseBackend, + leaseTtlMs: options.leaseTtlMs, sessionIsolation: options.sessionIsolation, installSource: options.installSource, retainMaterializedPaths: options.retainMaterializedPaths, diff --git a/src/client-types.ts b/src/client-types.ts index ba6eacb5..155e7491 100644 --- a/src/client-types.ts +++ b/src/client-types.ts @@ -4,6 +4,7 @@ import type { DaemonLockPolicy, DaemonRequest, DaemonResponse, + LeaseBackend, SessionRuntimeHints, } from './contracts.ts'; import type { DeviceKind, DeviceTarget, Platform, PlatformSelector } from './utils/device.ts'; @@ -23,7 +24,6 @@ export type AgentDeviceClientConfig = { lockPolicy?: DaemonLockPolicy; lockPlatform?: PlatformSelector; requestId?: string; - remoteConfig?: string; stateDir?: string; daemonBaseUrl?: string; daemonAuthToken?: string; @@ -33,6 +33,8 @@ export type AgentDeviceClientConfig = { sessionIsolation?: SessionIsolationMode; runId?: string; leaseId?: string; + leaseBackend?: LeaseBackend; + runtime?: SessionRuntimeHints; cwd?: string; debug?: boolean; }; @@ -43,10 +45,15 @@ export type AgentDeviceRequestOverrides = Pick< | 'lockPolicy' | 'lockPlatform' | 'requestId' + | 'daemonBaseUrl' + | 'daemonAuthToken' + | 'daemonTransport' + | 'daemonServerMode' | 'tenant' | 'sessionIsolation' | 'runId' | 'leaseId' + | 'leaseBackend' | 'cwd' | 'debug' >; @@ -228,6 +235,32 @@ export type MaterializationReleaseResult = { identifiers: AgentDeviceIdentifiers; }; +export type Lease = { + leaseId: string; + tenantId: string; + runId: string; + backend: LeaseBackend; + createdAt?: number; + heartbeatAt?: number; + expiresAt?: number; +}; + +export type LeaseOptions = AgentDeviceRequestOverrides & { + ttlMs?: number; +}; + +export type LeaseAllocateOptions = LeaseOptions & { + tenant: string; + runId: string; + leaseBackend?: LeaseBackend; +}; + +export type LeaseScopedOptions = LeaseOptions & { + tenant?: string; + runId?: string; + leaseId: string; +}; + export type MetroPrepareOptions = { projectRoot?: string; kind?: MetroPrepareKind; @@ -732,6 +765,7 @@ export type InternalRequestOptions = AgentDeviceClientConfig & retainMaterializedPaths?: boolean; materializedPathRetentionMs?: number; materializationId?: string; + leaseTtlMs?: number; }; export type CommandRequestResult = DaemonResponseData; @@ -768,6 +802,11 @@ export type AgentDeviceClient = { materializations: { release: (options: MaterializationReleaseOptions) => Promise; }; + leases: { + allocate: (options: LeaseAllocateOptions) => Promise; + heartbeat: (options: LeaseScopedOptions) => Promise; + release: (options: LeaseScopedOptions) => Promise<{ released: boolean }>; + }; metro: { prepare: (options: MetroPrepareOptions) => Promise; }; diff --git a/src/client.ts b/src/client.ts index d9135c97..a71c8a51 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3,7 +3,6 @@ import { prepareMetroRuntime } from './client-metro.ts'; import { CLIENT_COMMANDS } from './client-command-registry.ts'; import { createAgentDeviceCommandClient, type PreparedClientCommand } from './client-commands.ts'; import { throwDaemonError } from './daemon-error.ts'; -import { resolveRemoteConfigDefaults } from './utils/remote-config.ts'; import { buildFlags, buildMeta, @@ -42,6 +41,7 @@ import type { FindOptions, InteractionTarget, InternalRequestOptions, + Lease, MaterializationReleaseOptions, MetroPrepareOptions, NetworkOptions, @@ -52,14 +52,13 @@ export function createAgentDeviceClient( deps: { transport?: AgentDeviceDaemonTransport } = {}, ): AgentDeviceClient { const transport = deps.transport ?? sendToDaemon; - const remoteConfigDefaults = resolveClientRemoteConfigDefaults(config); const execute = async ( command: string, positionals: string[] = [], options: InternalRequestOptions = {}, ): Promise> => { - const merged = mergeClientOptions(config, remoteConfigDefaults, options); + const merged = mergeClientOptions(config, options); const response = await transport({ session: resolveSessionName(merged.session), command, @@ -91,7 +90,7 @@ export function createAgentDeviceClient( (await execute(command, positionals, options)) as CommandRequestResult; const resolveRequestSession = (options: InternalRequestOptions = {}) => - resolveSessionName(mergeClientOptions(config, remoteConfigDefaults, options).session); + resolveSessionName(mergeClientOptions(config, options).session); return { command: createAgentDeviceCommandClient(executePreparedCommand), @@ -236,6 +235,27 @@ export function createAgentDeviceClient( }), ), }, + leases: { + allocate: async (options) => + normalizeLease( + await execute('lease_allocate', [], { + ...options, + leaseId: undefined, + leaseTtlMs: options.ttlMs, + }), + ), + heartbeat: async (options) => + normalizeLease( + await execute('lease_heartbeat', [], { + ...options, + leaseTtlMs: options.ttlMs, + }), + ), + release: async (options) => { + const data = await execute('lease_release', [], options); + return { released: data.released === true }; + }, + }, metro: { prepare: async (options: MetroPrepareOptions) => await prepareMetroRuntime({ @@ -501,31 +521,26 @@ function optionalNumber(value: number | undefined): string[] { function mergeClientOptions( config: AgentDeviceClientConfig, - remoteConfigDefaults: InternalRequestOptions, options: InternalRequestOptions, ): InternalRequestOptions { - if (options.remoteConfig && options.remoteConfig !== config.remoteConfig) { - return { - ...resolveClientRemoteConfigDefaults({ ...config, ...options }), - ...config, - ...options, - }; - } - return { ...remoteConfigDefaults, ...config, ...options }; + return { ...config, ...options }; } -function resolveClientRemoteConfigDefaults( - config: AgentDeviceClientConfig, -): InternalRequestOptions { - if (!config.remoteConfig) return {}; - - const remoteDefaults = resolveRemoteConfigDefaults({ - remoteConfig: config.remoteConfig, - cwd: config.cwd ?? process.cwd(), - env: process.env, - }); - const { runtime: _cliRuntime, ...clientDefaults } = remoteDefaults; - return clientDefaults; +function normalizeLease(data: Record): Lease { + const rawLease = data.lease; + if (!rawLease || typeof rawLease !== 'object' || Array.isArray(rawLease)) { + throw new Error('Invalid lease response from daemon'); + } + const lease = rawLease as Record; + return { + leaseId: readRequiredString(lease, 'leaseId'), + tenantId: readRequiredString(lease, 'tenantId'), + runId: readRequiredString(lease, 'runId'), + backend: readRequiredString(lease, 'backend') as Lease['backend'], + createdAt: typeof lease.createdAt === 'number' ? lease.createdAt : undefined, + heartbeatAt: typeof lease.heartbeatAt === 'number' ? lease.heartbeatAt : undefined, + expiresAt: typeof lease.expiresAt === 'number' ? lease.expiresAt : undefined, + }; } export type { @@ -584,6 +599,10 @@ export type { InteractionTarget, KeyboardCommandOptions, KeyboardCommandResult, + Lease, + LeaseAllocateOptions, + LeaseOptions, + LeaseScopedOptions, LogsOptions, LongPressOptions, MaterializationReleaseOptions, diff --git a/src/core/remote-open.ts b/src/core/remote-open.ts deleted file mode 100644 index 8405939f..00000000 --- a/src/core/remote-open.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { AgentDeviceClient } from '../client.ts'; -import { resolveRemoteConfigPath } from '../remote-config.ts'; -import type { CliFlags } from '../utils/command-schema.ts'; -import { AppError } from '../utils/errors.ts'; - -export async function resolveRemoteOpenRuntime( - flags: CliFlags, - client: AgentDeviceClient, -): Promise< - | { - platform?: 'ios' | 'android'; - metroHost?: string; - metroPort?: number; - bundleUrl?: string; - launchUrl?: string; - } - | undefined -> { - if (!flags.remoteConfig) return undefined; - const platform = flags.platform; - if (platform !== 'ios' && platform !== 'android') { - throw new AppError( - 'INVALID_ARGS', - 'open --remote-config requires platform "ios" or "android" in the remote config file or CLI flags.', - ); - } - if (!flags.metroPublicBaseUrl) { - throw new AppError( - 'INVALID_ARGS', - 'open --remote-config requires "metroPublicBaseUrl" in the remote config file.', - ); - } - const prepared = await client.metro.prepare({ - projectRoot: flags.metroProjectRoot, - kind: flags.metroKind, - 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, - startupTimeoutMs: flags.metroStartupTimeoutMs, - probeTimeoutMs: flags.metroProbeTimeoutMs, - reuseExisting: flags.metroNoReuseExisting ? false : undefined, - installDependenciesIfNeeded: flags.metroNoInstallDeps ? false : undefined, - runtimeFilePath: flags.metroRuntimeFile, - }); - return platform === 'ios' ? prepared.iosRuntime : prepared.androidRuntime; -} diff --git a/src/daemon-client.ts b/src/daemon-client.ts index bf9142e8..3b2afbfd 100644 --- a/src/daemon-client.ts +++ b/src/daemon-client.ts @@ -933,12 +933,7 @@ async function sendHttpRequest( ? new URL(`http://127.0.0.1:${info.httpPort}/rpc`) : null; if (!rpcUrl) throw new AppError('COMMAND_FAILED', 'Daemon HTTP endpoint is unavailable'); - const rpcPayload = JSON.stringify({ - jsonrpc: '2.0', - id: req.meta?.requestId ?? createRequestId(), - method: 'agent_device.command', - params: req, - }); + const rpcPayload = JSON.stringify(buildHttpRpcPayload(req, { includeTokenParam: !info.baseUrl })); const headers: Record = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(rpcPayload), @@ -1055,6 +1050,83 @@ async function sendHttpRequest( }); } +function buildHttpRpcPayload( + req: DaemonRequest, + options: { includeTokenParam: boolean }, +): { + jsonrpc: '2.0'; + id: string; + method: string; + params: DaemonRequest | Record; +} { + const id = req.meta?.requestId ?? createRequestId(); + if (!isLeaseRpcCommand(req.command)) { + return { + jsonrpc: '2.0', + id, + method: 'agent_device.command', + params: req, + }; + } + return { + jsonrpc: '2.0', + id, + method: leaseRpcMethodForCommand(req.command), + params: buildLeaseRpcParams(req, req.command, options), + }; +} + +type LeaseRpcCommand = 'lease_allocate' | 'lease_heartbeat' | 'lease_release'; + +function isLeaseRpcCommand(command: string): command is LeaseRpcCommand { + return ( + command === 'lease_allocate' || command === 'lease_heartbeat' || command === 'lease_release' + ); +} + +function leaseRpcMethodForCommand(command: LeaseRpcCommand): string { + switch (command) { + case 'lease_allocate': + return 'agent_device.lease.allocate'; + case 'lease_heartbeat': + return 'agent_device.lease.heartbeat'; + case 'lease_release': + return 'agent_device.lease.release'; + } +} + +function buildLeaseRpcParams( + req: DaemonRequest, + command: LeaseRpcCommand, + options: { includeTokenParam: boolean }, +): Record { + const common = { + ...(options.includeTokenParam ? { token: req.token } : {}), + session: req.session, + tenantId: req.meta?.tenantId, + runId: req.meta?.runId, + }; + switch (command) { + case 'lease_allocate': + return { + ...common, + ttlMs: req.meta?.leaseTtlMs, + backend: req.meta?.leaseBackend, + }; + case 'lease_heartbeat': + return { + ...common, + leaseId: req.meta?.leaseId, + ttlMs: req.meta?.leaseTtlMs, + }; + case 'lease_release': + return { + ...common, + leaseId: req.meta?.leaseId, + }; + } +} + function cleanupTimedOutIosRunnerBuilds(): { terminated: number; error?: string } { let terminated = 0; try { diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index 6b1168f7..0d21f802 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -185,7 +185,11 @@ function toLeaseDaemonRequest( runId: readStringParam(params, 'runId'), leaseId: readStringParam(params, 'leaseId'), leaseTtlMs: readIntParam(params, 'ttlMs'), - leaseBackend: readStringParam(params, 'backend') as 'ios-simulator' | undefined, + leaseBackend: readStringParam(params, 'backend') as + | 'ios-simulator' + | 'ios-instance' + | 'android-instance' + | undefined, }, }; } diff --git a/src/daemon/lease-context.ts b/src/daemon/lease-context.ts index 2ab92901..763e309c 100644 --- a/src/daemon/lease-context.ts +++ b/src/daemon/lease-context.ts @@ -1,11 +1,12 @@ import type { DaemonRequest } from './types.ts'; +import type { LeaseBackend } from '../contracts.ts'; export type LeaseScope = { tenantId?: string; runId?: string; leaseId?: string; leaseTtlMs?: number; - leaseBackend?: 'ios-simulator'; + leaseBackend?: LeaseBackend; }; export function resolveLeaseScope(req: Pick): LeaseScope { diff --git a/src/daemon/lease-registry.ts b/src/daemon/lease-registry.ts index 206dfa1c..3f5a4ba3 100644 --- a/src/daemon/lease-registry.ts +++ b/src/daemon/lease-registry.ts @@ -1,8 +1,7 @@ import crypto from 'node:crypto'; import { AppError } from '../utils/errors.ts'; import { normalizeTenantId } from './config.ts'; - -export type LeaseBackend = 'ios-simulator'; +import type { LeaseBackend } from '../contracts.ts'; export type SimulatorLease = { leaseId: string; @@ -72,6 +71,7 @@ function normalizeLeaseId(raw: string | undefined): string | undefined { function normalizeLeaseBackend(raw: string | undefined): LeaseBackend { const value = (raw ?? '').trim().toLowerCase(); if (!value || value === 'ios-simulator') return 'ios-simulator'; + if (value === 'ios-instance' || value === 'android-instance') return value; throw new AppError('INVALID_ARGS', `Unsupported lease backend: ${raw ?? ''}`); } diff --git a/src/daemon/types.ts b/src/daemon/types.ts index 8391a40a..6b507e29 100644 --- a/src/daemon/types.ts +++ b/src/daemon/types.ts @@ -4,6 +4,7 @@ import type { DaemonRequestMeta as PublicDaemonRequestMeta, DaemonResponse as PublicDaemonResponse, DaemonResponseData as PublicDaemonResponseData, + LeaseBackend, SessionRuntimeHints as PublicSessionRuntimeHints, } from '../contracts.ts'; export type { DaemonLockPolicy } from '../contracts.ts'; @@ -24,7 +25,7 @@ export type DaemonResponseData = PublicDaemonResponseData; type DaemonRequestMeta = Omit & { installSource?: DaemonInstallSource; lockPlatform?: PlatformSelector; - leaseBackend?: 'ios-simulator'; + leaseBackend?: LeaseBackend; }; export type DaemonRequest = Omit & { diff --git a/src/remote-config-schema.ts b/src/remote-config-schema.ts index 0e3f62ff..d2822261 100644 --- a/src/remote-config-schema.ts +++ b/src/remote-config-schema.ts @@ -10,6 +10,7 @@ export type RemoteConfigProfile = { sessionIsolation?: 'none' | 'tenant'; runId?: string; leaseId?: string; + leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; platform?: 'ios' | 'macos' | 'android' | 'linux' | 'apple'; target?: 'mobile' | 'tv' | 'desktop'; device?: string; @@ -64,6 +65,11 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'sessionIsolation', type: 'enum', enumValues: ['none', 'tenant'] }, { key: 'runId', type: 'string' }, { key: 'leaseId', type: 'string' }, + { + key: 'leaseBackend', + type: 'enum', + enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], + }, { key: 'platform', type: 'enum', enumValues: ['ios', 'macos', 'android', 'linux', 'apple'] }, { key: 'target', type: 'enum', enumValues: ['mobile', 'tv', 'desktop'] }, { key: 'device', type: 'string' }, @@ -100,27 +106,6 @@ export const REMOTE_CONFIG_FIELD_SPECS = [ { key: 'metroNoInstallDeps', type: 'boolean' }, ] as const satisfies readonly RemoteConfigFieldSpec[]; -export const REMOTE_OPEN_PROFILE_KEYS = [ - 'session', - 'platform', - 'daemonBaseUrl', - 'daemonAuthToken', - 'daemonTransport', - 'metroProjectRoot', - 'metroKind', - 'metroPublicBaseUrl', - 'metroProxyBaseUrl', - 'metroBearerToken', - 'metroPreparePort', - 'metroListenHost', - 'metroStatusHost', - 'metroStartupTimeoutMs', - 'metroProbeTimeoutMs', - 'metroRuntimeFile', - 'metroNoReuseExisting', - 'metroNoInstallDeps', -] as const satisfies readonly (keyof RemoteConfigProfile)[]; - const remoteConfigFieldSpecByKey = new Map( REMOTE_CONFIG_FIELD_SPECS.map((spec) => [spec.key, spec]), ); diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts new file mode 100644 index 00000000..1b0cb204 --- /dev/null +++ b/src/remote-connection-state.ts @@ -0,0 +1,252 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveRemoteConfigProfile } from './remote-config-core.ts'; +import { AppError } from './utils/errors.ts'; +import { emitDiagnostic } from './utils/diagnostics.ts'; +import type { CliFlags } from './utils/command-schema.ts'; +import type { LeaseBackend, SessionRuntimeHints } from './contracts.ts'; + +export type RemoteConnectionState = { + version: 1; + session: string; + remoteConfigPath: string; + remoteConfigHash: string; + daemon?: { + baseUrl?: string; + transport?: CliFlags['daemonTransport']; + serverMode?: CliFlags['daemonServerMode']; + }; + tenant: string; + runId: string; + leaseId: string; + leaseBackend: LeaseBackend; + platform?: CliFlags['platform']; + target?: CliFlags['target']; + runtime?: SessionRuntimeHints; + metro?: { + projectRoot: string; + profileKey: string; + consumerKey: string; + }; + connectedAt: string; + updatedAt: string; +}; + +type RemoteConnectionDefaults = { + flags: Partial; + runtime?: SessionRuntimeHints; +}; + +export function readRemoteConnectionState(options: { + stateDir: string; + session: string; +}): RemoteConnectionState | null { + const statePath = remoteConnectionStatePath(options); + if (!fs.existsSync(statePath)) return null; + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(statePath, 'utf8')); + } catch (error) { + removeInvalidRemoteConnectionState(options, error); + return null; + } + if (!isRemoteConnectionState(parsed)) { + removeInvalidRemoteConnectionState(options); + return null; + } + return parsed; +} + +export function writeRemoteConnectionState(options: { + stateDir: string; + state: RemoteConnectionState; +}): void { + const statePath = remoteConnectionStatePath({ + stateDir: options.stateDir, + session: options.state.session, + }); + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + writeJsonFile(statePath, options.state); + writeJsonFile(activeConnectionStatePath(options.stateDir), { session: options.state.session }); +} + +export function removeRemoteConnectionState(options: { stateDir: string; session: string }): void { + fs.rmSync(remoteConnectionStatePath(options), { force: true }); + const activePath = activeConnectionStatePath(options.stateDir); + const activeSession = readActiveConnectionSession(options.stateDir); + if (activeSession === options.session) { + fs.rmSync(activePath, { force: true }); + } +} + +export function resolveRemoteConnectionDefaults(options: { + stateDir: string; + session: string; + cwd: string; + env: Record; + allowActiveFallback?: boolean; + validateRemoteConfigHash?: boolean; +}): RemoteConnectionDefaults | null { + const validateRemoteConfigHash = options.validateRemoteConfigHash ?? true; + const state = + readRemoteConnectionState(options) ?? + (options.allowActiveFallback + ? readActiveConnectionState({ stateDir: options.stateDir }) + : null); + if (!state) return null; + if ( + validateRemoteConfigHash && + hashRemoteConfigFile(state.remoteConfigPath) !== state.remoteConfigHash + ) { + throw new AppError( + 'INVALID_ARGS', + 'Active remote connection config changed. Run agent-device connect --force to refresh it.', + { remoteConfig: state.remoteConfigPath }, + ); + } + const profile = resolveConnectionProfile(state, options); + return { + runtime: state.runtime, + flags: { + ...profile, + remoteConfig: state.remoteConfigPath, + daemonBaseUrl: state.daemon?.baseUrl ?? profile.daemonBaseUrl, + daemonTransport: state.daemon?.transport ?? profile.daemonTransport, + daemonServerMode: state.daemon?.serverMode ?? profile.daemonServerMode, + tenant: state.tenant, + sessionIsolation: 'tenant', + runId: state.runId, + leaseId: state.leaseId, + leaseBackend: state.leaseBackend, + session: state.session, + platform: state.platform ?? profile.platform, + target: state.target ?? profile.target, + }, + }; +} + +export function hashRemoteConfigFile(configPath: string): string { + try { + return crypto.createHash('sha256').update(fs.readFileSync(configPath)).digest('hex'); + } catch (error) { + throw new AppError('INVALID_ARGS', `Remote config file not found: ${configPath}`, { + cause: error instanceof Error ? error.message : String(error), + }); + } +} + +function remoteConnectionStatePath(options: { stateDir: string; session: string }): string { + return path.join( + options.stateDir, + 'remote-connections', + `${safeStateName(options.session)}.json`, + ); +} + +function activeConnectionStatePath(stateDir: string): string { + return path.join(stateDir, 'remote-connections', '.active-session.json'); +} + +export function readActiveConnectionState(options: { + stateDir: string; +}): RemoteConnectionState | null { + const session = readActiveConnectionSession(options.stateDir); + return session + ? readRemoteConnectionState({ + stateDir: options.stateDir, + session, + }) + : null; +} + +function readActiveConnectionSession(stateDir: string): string | undefined { + const activePath = activeConnectionStatePath(stateDir); + if (!fs.existsSync(activePath)) return undefined; + try { + const parsed = JSON.parse(fs.readFileSync(activePath, 'utf8')) as Record; + return typeof parsed.session === 'string' ? parsed.session : undefined; + } catch { + return undefined; + } +} + +export function fingerprint(value: string | undefined): string | undefined { + if (!value) return undefined; + return crypto.createHash('sha256').update(value).digest('hex').slice(0, 12); +} + +function resolveConnectionProfile( + state: RemoteConnectionState, + options: { + cwd: string; + env: Record; + validateRemoteConfigHash?: boolean; + }, +): Partial { + try { + return resolveRemoteConfigProfile({ + configPath: state.remoteConfigPath, + cwd: options.cwd, + env: options.env, + }).profile; + } catch (error) { + // Disconnect tolerates a missing/unparseable profile; other paths already failed hash checks. + if (options.validateRemoteConfigHash === false) { + return {}; + } + throw error; + } +} + +function writeJsonFile(filePath: string, value: unknown): void { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, { + encoding: 'utf8', + mode: 0o600, + }); + fs.chmodSync(filePath, 0o600); +} + +function removeInvalidRemoteConnectionState( + options: { stateDir: string; session: string }, + error?: unknown, +): void { + emitDiagnostic({ + level: 'warn', + phase: 'remote_connection_state_invalid', + data: { + session: options.session, + cause: error instanceof Error ? error.message : error ? String(error) : undefined, + }, + }); + removeRemoteConnectionState(options); +} + +function safeStateName(value: string): string { + const safe = value.replaceAll(/[^a-zA-Z0-9._-]/g, '_'); + if (!safe) return 'default'; + if (safe === value) return safe; + const suffix = crypto.createHash('sha256').update(value).digest('hex').slice(0, 8); + return `${safe}-${suffix}`; +} + +function isRemoteConnectionState(value: unknown): value is RemoteConnectionState { + if (!value || typeof value !== 'object' || Array.isArray(value)) return false; + const record = value as Record; + return ( + record.version === 1 && + typeof record.session === 'string' && + typeof record.remoteConfigPath === 'string' && + typeof record.remoteConfigHash === 'string' && + (record.daemon === undefined || + (typeof record.daemon === 'object' && + record.daemon !== null && + !Array.isArray(record.daemon))) && + typeof record.tenant === 'string' && + typeof record.runId === 'string' && + typeof record.leaseId === 'string' && + typeof record.leaseBackend === 'string' && + typeof record.connectedAt === 'string' && + typeof record.updatedAt === 'string' + ); +} diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index faf2471e..9cb54bdb 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -242,13 +242,21 @@ test('parseArgs accepts metro prepare arguments', () => { test('parseArgs accepts remote workflow profile flag', () => { const parsed = parseArgs( - ['open', 'com.example.app', '--remote-config', './agent-device.remote.json'], + [ + 'connect', + '--remote-config', + './agent-device.remote.json', + '--tenant', + 'acme', + '--run-id', + 'run-1', + ], { strictFlags: true, }, ); - assert.equal(parsed.command, 'open'); - assert.deepEqual(parsed.positionals, ['com.example.app']); + assert.equal(parsed.command, 'connect'); + assert.deepEqual(parsed.positionals, []); assert.equal(parsed.flags.remoteConfig, './agent-device.remote.json'); }); @@ -323,6 +331,28 @@ test('parseArgs recognizes daemon transport/state/tenant isolation flags', () => assert.equal(parsed.flags.leaseId, 'abcd1234ef567890'); }); +test('parseArgs recognizes connect lease backend and force flags', () => { + const parsed = parseArgs( + [ + 'connect', + '--remote-config', + './remote.json', + '--tenant', + 'acme', + '--run-id', + 'run-123', + '--lease-backend', + 'android-instance', + '--force', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'connect'); + assert.equal(parsed.flags.remoteConfig, './remote.json'); + assert.equal(parsed.flags.leaseBackend, 'android-instance'); + assert.equal(parsed.flags.force, true); +}); + test('parseArgs recognizes explicit config file flag', () => { const parsed = parseArgs(['open', 'settings', '--config', './agent-device.json'], { strictFlags: true, @@ -622,6 +652,7 @@ test('usage includes only global flags in the top-level flags section', () => { assert.match(usageText, /--session-isolation none\|tenant/); assert.match(usageText, /--run-id /); assert.match(usageText, /--lease-id /); + assert.match(usageText, /--lease-backend ios-simulator\|ios-instance\|android-instance/); assert.doesNotMatch(usageText, /--relaunch/); assert.doesNotMatch(usageText, /--header /); assert.doesNotMatch(usageText, /--restart/); diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index 456f7775..c17fed6c 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -219,6 +219,151 @@ test('sendToDaemon uses explicit remote daemon base URL and auth token', async ( } }); +test('sendToDaemon sends lease helpers as top-level JSON-RPC methods over HTTP', async () => { + const rpcRequests: Record[] = []; + const originalHttpRequest = http.request; + (http as unknown as { request: typeof http.request }).request = (( + options: any, + callback: (res: any) => void, + ) => { + const req = new EventEmitter() as EventEmitter & { + write: (chunk: string) => void; + end: () => void; + destroy: () => void; + }; + let body = ''; + req.write = (chunk: string) => { + body += chunk; + }; + req.destroy = () => { + req.emit('close'); + }; + req.end = () => { + const res = new EventEmitter() as EventEmitter & { + statusCode?: number; + resume: () => void; + setEncoding: (_encoding: string) => void; + }; + res.statusCode = 200; + res.resume = () => {}; + res.setEncoding = () => {}; + process.nextTick(() => { + callback(res); + if (options.method === 'GET') { + res.emit('end'); + return; + } + const rpcRequest = JSON.parse(body) as Record; + rpcRequests.push(rpcRequest); + res.emit( + 'data', + JSON.stringify({ + jsonrpc: '2.0', + id: rpcRequest.id, + result: { + ok: true, + data: { + lease: + rpcRequest.method === 'agent_device.lease.release' + ? undefined + : { + leaseId: 'lease-new', + tenantId: rpcRequest.params?.tenantId, + runId: rpcRequest.params?.runId, + backend: rpcRequest.params?.backend, + }, + released: rpcRequest.method === 'agent_device.lease.release' ? true : undefined, + }, + }, + }), + ); + res.emit('end'); + }); + }; + return req as any; + }) as typeof http.request; + + const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL; + const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device'; + process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret'; + + try { + const allocateResponse = await sendToDaemon({ + session: 'qa-android', + command: 'lease_allocate', + positionals: [], + flags: {}, + meta: { + requestId: 'lease-req', + tenantId: 'acme', + runId: 'run-123', + leaseBackend: 'android-instance', + leaseTtlMs: 30_000, + }, + }); + const heartbeatResponse = await sendToDaemon({ + session: 'qa-android', + command: 'lease_heartbeat', + positionals: [], + flags: {}, + meta: { + requestId: 'heartbeat-req', + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-new', + leaseTtlMs: 15_000, + }, + }); + const releaseResponse = await sendToDaemon({ + session: 'qa-android', + command: 'lease_release', + positionals: [], + flags: {}, + meta: { + requestId: 'release-req', + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-new', + }, + }); + + assert.equal(allocateResponse.ok, true); + assert.equal(heartbeatResponse.ok, true); + assert.equal(releaseResponse.ok, true); + assert.equal(rpcRequests.length, 3); + assert.equal(rpcRequests[0]?.method, 'agent_device.lease.allocate'); + assert.deepEqual(rpcRequests[0]?.params, { + session: 'qa-android', + tenantId: 'acme', + runId: 'run-123', + ttlMs: 30_000, + backend: 'android-instance', + }); + assert.equal(rpcRequests[1]?.method, 'agent_device.lease.heartbeat'); + assert.deepEqual(rpcRequests[1]?.params, { + session: 'qa-android', + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-new', + ttlMs: 15_000, + }); + assert.equal(rpcRequests[2]?.method, 'agent_device.lease.release'); + assert.deepEqual(rpcRequests[2]?.params, { + session: 'qa-android', + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-new', + }); + } finally { + (http as unknown as { request: typeof http.request }).request = originalHttpRequest; + if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL; + else process.env.AGENT_DEVICE_DAEMON_BASE_URL = previousBaseUrl; + if (previousAuthToken === undefined) delete process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + else process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = previousAuthToken; + } +}); + test('openApp forwards typed runtime hints on open requests', async () => { let rpcRequest: Record | null = null; const originalHttpRequest = http.request; diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 17efd872..5682af6c 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -2,7 +2,7 @@ import type { CliFlags } from './command-schema.ts'; import { mergeDefinedFlags } from './merge-flags.ts'; import { finalizeParsedArgs, parseRawArgs } from './args.ts'; import { resolveConfigBackedFlagDefaults } from './cli-config.ts'; -import { pickRemoteOpenDefaults, resolveRemoteConfigDefaults } from './remote-config.ts'; +import { resolveRemoteConfigDefaults } from './remote-config.ts'; type EnvMap = Record; @@ -17,11 +17,13 @@ export function resolveCliOptions( const rawParsed = parseRawArgs(argv); const env = options?.env ?? process.env; const cwd = options?.cwd ?? process.cwd(); - const remoteConfigDefaults = resolveRemoteConfigDefaults({ - remoteConfig: rawParsed.flags.remoteConfig, - cwd, - env, - }); + const remoteConfigDefaults = shouldApplyRemoteConfigDefaults(rawParsed.command) + ? resolveRemoteConfigDefaults({ + remoteConfig: rawParsed.flags.remoteConfig, + cwd, + env, + }) + : {}; const defaultFlags = mergeDefinedFlags( resolveConfigBackedFlagDefaults({ command: rawParsed.command, @@ -35,17 +37,9 @@ export function resolveCliOptions( strictFlags: options?.strictFlags, defaultFlags, }); - if (rawParsed.command === 'open' && rawParsed.flags.remoteConfig) { - mergeMissingFlags(finalized.flags, pickRemoteOpenDefaults(defaultFlags)); - } - return finalized; + return { ...finalized, providedFlags: rawParsed.providedFlags }; } -function mergeMissingFlags>(target: T, source: Partial): T { - for (const [key, value] of Object.entries(source)) { - if (value !== undefined && target[key as keyof T] === undefined) { - target[key as keyof T] = value as T[keyof T]; - } - } - return target; +function shouldApplyRemoteConfigDefaults(command: string | null): boolean { + return command === 'connect' || command === 'metro'; } diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index f74d25b5..b3e7ba5a 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -14,6 +14,8 @@ export type CliFlags = { sessionIsolation?: 'none' | 'tenant'; runId?: string; leaseId?: string; + leaseBackend?: 'ios-simulator' | 'ios-instance' | 'android-instance'; + force?: boolean; sessionLock?: 'reject' | 'strip'; sessionLocked?: boolean; sessionLockConflicts?: 'reject' | 'strip'; @@ -255,6 +257,21 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--lease-id ', usageDescription: 'Lease identifier bound to tenant/run admission scope', }, + { + key: 'leaseBackend', + names: ['--lease-backend'], + type: 'enum', + enumValues: ['ios-simulator', 'ios-instance', 'android-instance'], + usageLabel: '--lease-backend ios-simulator|ios-instance|android-instance', + usageDescription: 'Lease backend for remote tenant connection admission', + }, + { + key: 'force', + names: ['--force'], + type: 'boolean', + usageLabel: '--force', + usageDescription: 'Force connection state replacement when reconnecting', + }, { key: 'sessionLock', names: ['--session-lock'], @@ -924,6 +941,7 @@ export const GLOBAL_FLAG_KEYS = new Set([ 'sessionIsolation', 'runId', 'leaseId', + 'leaseBackend', 'sessionLock', 'sessionLocked', 'sessionLockConflicts', @@ -955,6 +973,48 @@ const COMMAND_SCHEMAS: Record = { positionalArgs: ['appOrUrl?', 'url?'], allowedFlags: ['activity', 'saveScript', 'relaunch', 'surface'], }, + connect: { + usageOverride: + 'connect --remote-config [--tenant ] [--run-id ] [--lease-backend ] [--force]', + helpDescription: 'Connect to a remote daemon, allocate a tenant lease, and prepare Metro', + summary: 'Connect to remote daemon', + positionalArgs: [], + allowedFlags: [ + 'force', + 'metroProjectRoot', + 'metroKind', + 'metroPublicBaseUrl', + 'metroProxyBaseUrl', + 'metroBearerToken', + 'metroPreparePort', + 'metroListenHost', + 'metroStatusHost', + 'metroStartupTimeoutMs', + 'metroProbeTimeoutMs', + 'metroRuntimeFile', + 'metroNoReuseExisting', + 'metroNoInstallDeps', + 'launchUrl', + ], + skipCapabilityCheck: true, + }, + disconnect: { + helpDescription: + 'Disconnect remote daemon state, stop owned Metro companion, and release lease', + summary: 'Disconnect remote daemon', + positionalArgs: [], + allowedFlags: ['shutdown'], + skipCapabilityCheck: true, + }, + connection: { + usageOverride: 'connection status', + listUsageOverride: 'connection status', + helpDescription: 'Inspect active remote connection state', + summary: 'Inspect remote connection', + positionalArgs: ['status'], + allowedFlags: [], + skipCapabilityCheck: true, + }, close: { helpDescription: 'Close app or just end session', summary: 'Close app or end session', diff --git a/src/utils/remote-config.ts b/src/utils/remote-config.ts index 95d4e861..ad10a962 100644 --- a/src/utils/remote-config.ts +++ b/src/utils/remote-config.ts @@ -1,9 +1,5 @@ import type { CliFlags } from './command-schema.ts'; -import { - REMOTE_CONFIG_FIELD_SPECS, - REMOTE_OPEN_PROFILE_KEYS, - type RemoteConfigProfile, -} from '../remote-config-schema.ts'; +import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../remote-config-schema.ts'; import { resolveRemoteConfigProfile } from '../remote-config-core.ts'; // Remote config can supply defaults for any supported CLI flag that exists in the profile schema. @@ -12,13 +8,6 @@ const REMOTE_CONFIG_DEFAULT_FLAG_KEYS = REMOTE_CONFIG_FIELD_SPECS.map( (spec) => spec.key, ) as readonly (keyof RemoteConfigProfile)[]; -// This subset is still intentional: open preserves extra remote-config defaults after command -// parsing so it can prepare remote Metro without exposing every Metro flag as an open CLI option. -export const REMOTE_OPEN_FLAG_KEYS = [ - 'remoteConfig', - ...REMOTE_OPEN_PROFILE_KEYS, -] as const satisfies readonly (keyof CliFlags)[]; - function profileToCliFlags(profile: RemoteConfigProfile): Partial { const flags: Partial = {}; for (const key of REMOTE_CONFIG_DEFAULT_FLAG_KEYS) { @@ -49,14 +38,3 @@ export function resolveRemoteConfigDefaults(options: { remoteConfig: options.remoteConfig, }; } - -export function pickRemoteOpenDefaults(defaultFlags: Partial): Partial { - const retained: Partial = {}; - for (const key of REMOTE_OPEN_FLAG_KEYS) { - const value = defaultFlags[key]; - if (value !== undefined) { - (retained as Record)[key] = value; - } - } - return retained; -} diff --git a/test/integration/smoke-open-remote-config.test.ts b/test/integration/smoke-open-remote-config.test.ts index 10181e1b..35464388 100644 --- a/test/integration/smoke-open-remote-config.test.ts +++ b/test/integration/smoke-open-remote-config.test.ts @@ -7,8 +7,8 @@ import http from 'node:http'; import net from 'node:net'; import { runCli } from '../../src/cli.ts'; -// Smoke coverage for the repo-local remote host flow: resolve a remote profile, -// prepare Metro through the host bridge, and forward inline runtime hints on open. +// Smoke coverage for the repo-local remote host flow: connect to a remote profile, +// prepare Metro through the host bridge, and reuse connection runtime hints on open. class ExitSignal extends Error { public readonly code: number; @@ -131,7 +131,7 @@ async function readJsonBody(req: http.IncomingMessage): Promise { return body ? JSON.parse(body) : {}; } -test('open --remote-config prepares Metro and sends bridged runtime to remote daemon', async (t) => { +test('connect prepares Metro and open reuses bridged runtime for remote daemon', async (t) => { if (!(await supportsLoopbackBind())) { t.skip('loopback listeners are not permitted in this environment'); return; @@ -173,7 +173,7 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da }); let capturedBridgeRequest: any; - let capturedRpcRequest: any; + let capturedOpenRpcRequest: any; const sharedToken = 'test-token'; let hostPort = 0; const hostServer = http.createServer(async (req, res) => { @@ -235,12 +235,40 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da } if (req.method === 'POST' && req.url === '/agent-device/rpc') { - capturedRpcRequest = { + const rpcRequest = { authorization: req.headers.authorization, token: req.headers['x-agent-device-token'], body: await readJsonBody(req), }; - const runtime = capturedRpcRequest.body?.params?.runtime; + if (rpcRequest.body?.method === 'agent_device.lease.allocate') { + res.writeHead(200, { + 'content-type': 'application/json', + connection: 'close', + }); + res.end( + JSON.stringify({ + jsonrpc: '2.0', + id: rpcRequest.body?.id ?? 'remote-connect-smoke', + result: { + ok: true, + data: { + lease: { + leaseId: 'abc123abc123abc1', + tenantId: rpcRequest.body?.params?.tenantId, + runId: rpcRequest.body?.params?.runId, + backend: rpcRequest.body?.params?.backend, + createdAt: Date.now(), + heartbeatAt: Date.now(), + expiresAt: Date.now() + 60_000, + }, + }, + }, + }), + ); + return; + } + capturedOpenRpcRequest = rpcRequest; + const runtime = capturedOpenRpcRequest.body?.params?.runtime; res.writeHead(200, { 'content-type': 'application/json', connection: 'close', @@ -248,7 +276,7 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da res.end( JSON.stringify({ jsonrpc: '2.0', - id: capturedRpcRequest.body?.id ?? 'remote-open-smoke', + id: capturedOpenRpcRequest.body?.id ?? 'remote-open-smoke', result: { ok: true, data: { @@ -278,6 +306,7 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da }); const remoteConfigPath = path.join(configDir, 'agent-device.remote.json'); + const stateDir = path.join(root, 'state'); fs.writeFileSync( remoteConfigPath, JSON.stringify({ @@ -292,7 +321,29 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da 'utf8', ); - const result = await runCliJson(['open', 'Demo', '--remote-config', remoteConfigPath, '--json'], { + const connectResult = await runCliJson( + [ + 'connect', + '--remote-config', + remoteConfigPath, + '--tenant', + 'acme', + '--run-id', + 'run-123', + '--state-dir', + stateDir, + '--json', + ], + { + AGENT_DEVICE_DAEMON_AUTH_TOKEN: sharedToken, + AGENT_DEVICE_PROXY_TOKEN: sharedToken, + }, + ); + + assert.equal(connectResult.code, null, `${connectResult.stderr}\n${connectResult.stdout}`); + assert.equal(connectResult.json?.success, true, JSON.stringify(connectResult.json)); + + const result = await runCliJson(['open', 'Demo', '--state-dir', stateDir, '--json'], { AGENT_DEVICE_DAEMON_AUTH_TOKEN: sharedToken, AGENT_DEVICE_PROXY_TOKEN: sharedToken, }); @@ -301,12 +352,13 @@ test('open --remote-config prepares Metro and sends bridged runtime to remote da assert.equal(result.json?.success, true, JSON.stringify(result.json)); assert.equal(capturedBridgeRequest?.authorization, `Bearer ${sharedToken}`); - assert.equal(capturedRpcRequest?.authorization, `Bearer ${sharedToken}`); - assert.equal(capturedRpcRequest?.body?.method, 'agent_device.command'); - assert.equal(capturedRpcRequest?.body?.params?.session, 'qa-android'); - assert.equal(capturedRpcRequest?.body?.params?.command, 'open'); - assert.deepEqual(capturedRpcRequest?.body?.params?.positionals, ['Demo']); - assert.deepEqual(capturedRpcRequest?.body?.params?.runtime, { + assert.equal(capturedOpenRpcRequest?.authorization, `Bearer ${sharedToken}`); + assert.equal(capturedOpenRpcRequest?.body?.method, 'agent_device.command'); + assert.equal(capturedOpenRpcRequest?.body?.params?.session, 'qa-android'); + assert.equal(capturedOpenRpcRequest?.body?.params?.command, 'open'); + assert.deepEqual(capturedOpenRpcRequest?.body?.params?.positionals, ['Demo']); + assert.equal(capturedOpenRpcRequest?.body?.params?.meta?.leaseId, 'abc123abc123abc1'); + assert.deepEqual(capturedOpenRpcRequest?.body?.params?.runtime, { platform: 'android', metroHost: '10.0.2.2', metroPort: metroPort, diff --git a/website/docs/docs/client-api.md b/website/docs/docs/client-api.md index f8072113..f517aa71 100644 --- a/website/docs/docs/client-api.md +++ b/website/docs/docs/client-api.md @@ -59,8 +59,6 @@ const client = createAgentDeviceClient({ session: 'qa-ios', lockPolicy: 'reject', lockPlatform: 'ios', - // Optional: loads profile defaults for daemon-backed requests. Per-call options override it. - remoteConfig: './agent-device.remote.json', }); const devices = await client.devices.list({ platform: 'ios' }); @@ -87,7 +85,7 @@ await client.sessions.close(); ## Command methods -Use `client.command.()` for command-level device actions. It uses the same daemon transport path as the higher-level client methods, including session metadata, tenant/run/lease fields, client-level remote config defaults, normalized daemon errors, and remote artifact handling. +Use `client.command.()` for command-level device actions. It uses the same daemon transport path as the higher-level client methods, including session metadata, tenant/run/lease fields, normalized daemon errors, and remote artifact handling. Results are daemon-shaped objects with typed known fields, so command semantics stay aligned with the CLI. diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 7ca28bb3..e17fb761 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -54,8 +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, 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. +- For remote `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - 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. @@ -64,7 +63,6 @@ agent-device open "https://example.com" --platform ios # open link in agent-device open MyApp "myapp://screen/to" --platform ios # open deep link to MyApp agent-device back --platform ios # tap visible app back UI only agent-device back --system --platform ios # use edge-swipe or remote back action -agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch agent-device reinstall MyApp /path/to/app-debug.apk --platform android --serial emulator-5554 agent-device open com.example.myapp --platform android --serial emulator-5554 --session my-session --relaunch ``` @@ -632,18 +630,36 @@ agent-device trace stop session.trace ## Remote Metro workflow +Example `agent-device.remote.json`: + +```json +{ + "daemonBaseUrl": "https://bridge.example.com/agent-device", + "daemonTransport": "http", + "tenant": "acme", + "runId": "run-123", + "session": "adc-android", + "sessionIsolation": "tenant", + "platform": "android", + "leaseBackend": "android-instance", + "metroProjectRoot": ".", + "metroPublicBaseUrl": "http://127.0.0.1:8081", + "metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123" +} +``` + ```bash -agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch -agent-device snapshot -i --remote-config ./agent-device.remote.json -agent-device metro prepare --remote-config ./agent-device.remote.json --json +agent-device connect --remote-config ./agent-device.remote.json +agent-device open com.example.myapp --relaunch +agent-device snapshot -i +agent-device disconnect ``` -- `--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, 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. +- `--remote-config ` points to a remote workflow profile that captures stable host, tenant/run, session, platform, lease backend, and Metro settings for `connect`. +- `connect --remote-config ...` is the main agent flow. It allocates or refreshes a tenant lease, prepares Metro locally, auto-manages the local Metro companion when the bridge needs it, and stores runtime hints for later `open`. +- After `connect`, `snapshot`, `press`, `fill`, `screenshot`, and other normal commands reuse active connection state so agents do not repeat remote host/session/lease selectors inline. Explicit command-line flags override those connected defaults. +- `metro prepare --remote-config ...` remains an advanced 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. `disconnect` stops the companion owned by the connection, but it does not stop the user’s Metro server. ## Session inspection diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index d0369a56..e809f270 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -64,6 +64,7 @@ Common keys include: - `sessionIsolation` - `runId` - `leaseId` +- `leaseBackend` - `sessionLock` - `sessionLocked` - `sessionLockConflicts` diff --git a/website/docs/docs/sessions.md b/website/docs/docs/sessions.md index c90fb41e..d388ec70 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, 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. +- For remote `connect --remote-config` sessions, see [Commands](/docs/commands#remote-metro-workflow). - Use `--session ` to run multiple sessions in parallel. For replay scripts and deterministic E2E guidance, see [Replay & E2E (Experimental)](/docs/replay-e2e).