diff --git a/skills/agent-device/references/bootstrap-install.md b/skills/agent-device/references/bootstrap-install.md index eebd7a05c..dca99a37d 100644 --- a/skills/agent-device/references/bootstrap-install.md +++ b/skills/agent-device/references/bootstrap-install.md @@ -62,15 +62,17 @@ agent-device install com.example.app ./build/MyApp.app --platform ios --device " ``` ```bash -agent-device install-from-source https://example.com/builds/app.aab --platform android -agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" +ARTIFACT_URL="" +agent-device install-from-source "$ARTIFACT_URL" --platform android +GITHUB_ARTIFACT_URL="" +agent-device install-from-source "$GITHUB_ARTIFACT_URL" --platform ios --header "authorization: Bearer TOKEN" ``` ## Install guidance - Use `install ` when the app may already be installed and you do not need a fresh-state reset. - Use `reinstall ` when you explicitly need uninstall plus install as one deterministic step. -- Use `install-from-source ` when an existing artifact URL is already reachable by the daemon. +- Use `install-from-source ` only when an existing artifact URL is trusted, operator-approved, and reachable by the daemon. - Local `.apk`, `.aab`, `.app`, and `.ipa` paths go through `install` or `reinstall`; existing reachable URLs go through `install-from-source`. - Do not download, re-zip, publish temporary GitHub releases, or move CI artifacts elsewhere just to make an install command work. - Keep install and open as separate phases. Do not turn them into one default command flow. diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 1a2caef9d..899677193 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -7,47 +7,87 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux ## Main commands to reach for first - `agent-device connect --remote-config ` +- `agent-device install-from-source --remote-config --platform android` +- `agent-device open --remote-config --relaunch` +- `agent-device snapshot --remote-config -i` +- `agent-device disconnect --remote-config ` - `agent-device connection status` -- `agent-device disconnect` - `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...` ## Most common mistake to avoid -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, and session context, then resolves lease and Metro details only when a later command actually needs them. +Do not mix an arbitrary `--session` plus ad-hoc daemon, tenant, run, or lease flags. That can bypass saved Metro runtime hints. Use one of these patterns instead: -## Preferred remote flow +- Interactive flow: run `connect --remote-config ` once, then normal commands, then `disconnect`. +- Script flow: pass the same `--remote-config ` to every command, including `disconnect`. -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. +## Choose one flow + +### Interactive flow + +Use this when the agent will run several commands in one session. ```bash 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 connect --remote-config ./remote-config.json -agent-device install com.example.app ./app.apk -agent-device install-from-source https://example.com/builds/app.apk --platform android +ARTIFACT_URL="" +agent-device install-from-source "$ARTIFACT_URL" --platform android agent-device open com.example.app --relaunch agent-device snapshot -i agent-device fill @e3 "test@example.com" agent-device disconnect ``` -`connect` resolves the remote profile, generates a local session name when the profile omits one, stores local non-secret connection state, and defers tenant lease allocation plus Metro preparation until a later command needs them. When a command such as `open`, `install`, `apps`, or `snapshot` needs a lease, the client allocates or refreshes it from the connected scope. When a command needs Metro runtime hints, the client prepares Metro locally at that point and starts the local Metro companion when the bridge needs it, including `batch` runs whose steps open an app. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease when one was allocated, and removes local connection state. +After `connect`, normal commands use the active remote connection. End with `disconnect` to release the lease and stop the owned Metro companion. + +### Self-contained script flow + +Use this when each command must be explicit and repeatable. Pass the same `--remote-config` to each step. + +```bash +ARTIFACT_URL="" + +agent-device install-from-source "$ARTIFACT_URL" \ + --remote-config ./remote-config.json \ + --platform android + +agent-device open com.example.app \ + --remote-config ./remote-config.json \ + --relaunch + +agent-device snapshot \ + --remote-config ./remote-config.json \ + -i + +agent-device disconnect \ + --remote-config ./remote-config.json +``` + +The first command that needs a lease or Metro runtime prepares and persists it. Later commands with the same `--remote-config` reuse that state. End with `disconnect --remote-config ` to release the lease and stop the owned Metro companion. + +## Behavior summary -After `connect`, normal `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command. +- `connect` stores local non-secret connection state and defers tenant lease allocation plus Metro preparation until a later command needs them. +- Commands such as `install-from-source`, `open`, `snapshot`, and `apps` allocate or refresh the lease when needed. +- `open` prepares Metro runtime hints when the remote profile has Metro fields and no compatible runtime is already saved. +- `batch` also prepares Metro when any step opens an app and that step does not provide its own runtime. +- `disconnect` closes the session when possible, stops the Metro companion owned by the connection, releases the lease when one was allocated, and removes local connection state. Remote install examples: ```bash agent-device install com.example.app ./app.apk -agent-device install-from-source https://example.com/builds/app.aab --platform android -agent-device install-from-source https://api.github.com/repos/acme/app/actions/artifacts/123/zip --platform ios --header "authorization: Bearer TOKEN" +ARTIFACT_URL="" +agent-device install-from-source "$ARTIFACT_URL" --platform android +GITHUB_ARTIFACT_URL="" +agent-device install-from-source "$GITHUB_ARTIFACT_URL" --platform ios --header "authorization: Bearer TOKEN" ``` - Use `install` or `reinstall` for local paths; remote daemons upload local artifacts automatically. -- Use `install-from-source` for artifact URLs the remote daemon can reach. +- Use `install-from-source` only for trusted, operator-approved artifact URLs the remote daemon can reach. Do not fetch arbitrary user-supplied URLs. - For local-path versus URL artifact rules, follow [bootstrap-install.md](bootstrap-install.md). 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. diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 526d4d059..9e268dbed 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -483,23 +483,297 @@ 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 () => { +test('normal commands accept direct remote-config usage', async () => { const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); const remoteConfig = path.join(project, 'agent-device.remote.json'); fs.writeFileSync( remoteConfig, - JSON.stringify({ daemonBaseUrl: 'http://127.0.0.1:9124' }), + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + tenant: 'acme', + runId: 'run-123', + platform: 'android', + }), 'utf8', ); - const result = await runCliCapture(['snapshot', '--remote-config', remoteConfig], { - cwd: project, - env: { HOME: home }, - }); + const result = await runCliCapture( + ['snapshot', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-direct-001', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + return { ok: true, data: { nodes: [], truncated: false } }; + }, + }, + ); - 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); + assert.equal(result.code, null); + assert.equal(result.calls.length, 2); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[1]?.command, 'snapshot'); + assert.equal( + result.calls[1]?.flags?.daemonBaseUrl, + 'http://remote-mac.example.test:9124/agent-device', + ); + assert.equal(result.calls[1]?.meta?.tenantId, 'acme'); + assert.equal(result.calls[1]?.meta?.leaseId, 'lease-direct-001'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'default' })?.leaseId, + 'lease-direct-001', + ); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('direct remote-config command does not fall back to unrelated active session', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + tenant: 'acme', + runId: 'run-profile', + session: 'profile-session', + platform: 'android', + }), + 'utf8', + ); + fs.mkdirSync(path.join(stateDir, 'remote-connections'), { recursive: true }); + const now = new Date().toISOString(); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', 'active-session.json'), + JSON.stringify({ + version: 1, + session: 'active-session', + remoteConfigPath: remoteConfig, + remoteConfigHash: hashRemoteConfigFile(remoteConfig), + tenant: 'acme', + runId: 'run-active', + leaseId: 'lease-active', + leaseBackend: 'android-instance', + platform: 'android', + connectedAt: now, + updatedAt: now, + }), + 'utf8', + ); + fs.writeFileSync( + path.join(stateDir, 'remote-connections', '.active-session.json'), + JSON.stringify({ session: 'active-session' }), + 'utf8', + ); + + const result = await runCliCapture( + ['snapshot', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_heartbeat') { + throw new Error('should not reuse active-session lease'); + } + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-profile', + tenantId: 'acme', + runId: 'run-profile', + backend: 'android-instance', + }, + }, + }; + } + return { ok: true, data: { nodes: [], truncated: false } }; + }, + }, + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[1]?.command, 'snapshot'); + assert.equal(result.calls[1]?.session, 'profile-session'); + assert.equal(result.calls[1]?.meta?.runId, 'run-profile'); + assert.equal(result.calls[1]?.meta?.leaseId, 'lease-profile'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'profile-session' })?.leaseId, + 'lease-profile', + ); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'active-session' })?.leaseId, + 'lease-active', + ); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('install-from-source --remote-config writes and reuses lease state', async () => { + const { root, home, project } = makeTempWorkspace(); + const stateDir = path.join(root, 'state'); + const remoteConfig = path.join(project, 'agent-device.remote.json'); + fs.writeFileSync( + remoteConfig, + JSON.stringify({ + daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device', + tenant: 'acme', + runId: 'run-123', + platform: 'android', + }), + 'utf8', + ); + + const first = await runCliCapture( + [ + 'install-from-source', + 'https://example.com/app.apk', + '--remote-config', + remoteConfig, + '--state-dir', + stateDir, + '--json', + ], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-install-source-001', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + return { + ok: true, + data: { + launchTarget: 'com.example.demo', + packageName: 'com.example.demo', + }, + }; + }, + }, + ); + + assert.equal(first.code, null); + assert.equal(first.calls.length, 2); + assert.equal(first.calls[0]?.command, 'lease_allocate'); + assert.equal(first.calls[1]?.command, 'install_source'); + assert.equal(first.calls[1]?.meta?.leaseId, 'lease-install-source-001'); + assert.equal( + readRemoteConnectionState({ stateDir, session: 'default' })?.leaseId, + 'lease-install-source-001', + ); + + const second = await runCliCapture( + [ + 'install-from-source', + 'https://example.com/app.apk', + '--remote-config', + remoteConfig, + '--state-dir', + stateDir, + '--json', + ], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_heartbeat') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-install-source-001', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + return { + ok: true, + data: { + launchTarget: 'com.example.demo', + packageName: 'com.example.demo', + }, + }; + }, + }, + ); + + assert.equal(second.code, null); + assert.equal(second.calls.length, 2); + assert.equal(second.calls[0]?.command, 'lease_heartbeat'); + assert.equal(second.calls[1]?.command, 'install_source'); + assert.equal(second.calls[1]?.meta?.leaseId, 'lease-install-source-001'); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('open warns when explicit remote flags bypass saved runtime hints', async () => { + const { root, home, project } = makeTempWorkspace(); + + const result = await runCliCapture( + [ + 'open', + 'com.example.demo', + '--platform', + 'android', + '--daemon-base-url', + 'http://remote-mac.example.test:9124/agent-device', + '--tenant', + 'acme', + '--run-id', + 'run-123', + '--lease-id', + 'lease-123', + '--json', + ], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async () => ({ + ok: true, + data: { + platform: 'android', + target: 'mobile', + device: 'Pixel', + id: 'emulator-5554', + appBundleId: 'com.example.demo', + }, + }), + }, + ); + + assert.equal(result.code, null); + assert.match(result.stderr, /without saved Metro runtime hints/); + assert.equal(result.calls.length, 1); + assert.equal(result.calls[0]?.runtime, undefined); fs.rmSync(root, { recursive: true, force: true }); }); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 99d416110..be81737bf 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -154,6 +154,43 @@ test('connect auto-generates a local session and writes minimal remote state', a fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect reports deferred Metro runtime preparation when remote config has Metro settings', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-metro-notice-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + + const stdout = await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: false, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example.test', + tenant: 'acme', + runId: 'run-123', + platform: 'android', + }, + client: createTestClient(), + }); + }); + + assert.match(stdout, /Metro runtime is not prepared yet/); + assert.match(stdout, /metro prepare --remote-config/); + assert.equal(readActiveConnectionState({ stateDir })?.runtime, undefined); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('connect without a session reuses the active generated connection', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-idempotent-')); const stateDir = path.join(tempRoot, '.state'); @@ -332,6 +369,84 @@ test('deferred materialization allocates lease and prepares Metro for open', asy fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('direct remote-config materialization creates state and prepares Metro for open', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-direct-remote-open-')); + const stateDir = path.join(tempRoot, '.state'); + const remoteConfigPath = path.join(tempRoot, 'remote.json'); + fs.writeFileSync( + remoteConfigPath, + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'direct-android', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + let observedBridgeScope: { tenantId: string; runId: string; leaseId: string } | undefined; + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'open', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'direct-android', + platform: 'android', + }, + client: createTestClient({ + allocate: async (request) => ({ + leaseId: 'lease-direct', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }), + prepare: async (options) => { + observedBridgeScope = options.bridgeScope; + 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, + }; + }, + }), + }); + + assert.equal(materialized.flags.leaseId, 'lease-direct'); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }); + assert.deepEqual(observedBridgeScope, { + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-direct', + }); + assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'direct-android' })?.runtime, { + platform: 'android', + bundleUrl: 'https://bundle.example.test', + }); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + test('deferred materialization prepares Metro for batch when a step opens an app', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-batch-open-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli.ts b/src/cli.ts index 598c76bc0..59d901181 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -54,6 +54,17 @@ const METRO_RUNTIME_OVERRIDE_FLAG_KEYS = new Set([ 'metroStatusHost', ]); +const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([ + 'connect', + 'connection', + 'close', + 'devices', + 'disconnect', + 'ensure-simulator', + 'metro', + 'session', +]); + export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): Promise { const requestId = createRequestId(); const version = readVersion(); @@ -141,12 +152,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): 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, @@ -166,6 +171,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): explicitFlagKeys, stateDir: daemonPaths.baseDir, session: sessionName, + remoteConfig: flags.remoteConfig, }); effectiveFlags = connectionDefaults ? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys) @@ -223,7 +229,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): parsedBatchSteps = readBatchSteps(flags); } - if (connectionDefaults && command !== 'connect' && command !== 'connection') { + if (effectiveFlags.remoteConfig && shouldMaterializeRemoteConnection(command)) { const materializationClient = createAgentDeviceClient( buildClientConfig(effectiveFlags, resolvedRuntime), { @@ -241,6 +247,19 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): effectiveFlags = materialized.flags; resolvedRuntime = materialized.runtime; } + if ( + shouldWarnOpenMayMissRemoteRuntime({ + command, + flags: effectiveFlags, + runtime: resolvedRuntime, + explicitFlagKeys, + hadConnectionDefaults: Boolean(connectionDefaults), + }) + ) { + process.stderr.write( + 'Warning: open is using explicit remote daemon or tenant flags without saved Metro runtime hints. React Native apps may launch without bundle/runtime hints; prefer connect --remote-config first or pass --remote-config on this command.\n', + ); + } const remoteDaemonBaseUrl = effectiveFlags.daemonBaseUrl; logTailStopper = effectiveFlags.verbose && !effectiveFlags.json && !remoteDaemonBaseUrl @@ -361,23 +380,57 @@ function resolveActiveConnectionDefaults(options: { explicitFlagKeys: Set; stateDir: string; session: string; + remoteConfig?: 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, + remoteConfig: options.remoteConfig, cwd: process.cwd(), env: process.env, - allowActiveFallback: !options.explicitFlagKeys.has('session'), + allowActiveFallback: + !options.explicitFlagKeys.has('session') && + (!options.remoteConfig || options.command === 'disconnect'), validateRemoteConfigHash: options.command !== 'disconnect', }); return defaults; } +function shouldMaterializeRemoteConnection(command: string): boolean { + return !REMOTE_MATERIALIZATION_DEFERRED_COMMANDS.has(command); +} + +function shouldWarnOpenMayMissRemoteRuntime(options: { + command: string; + flags: CliFlags; + runtime?: SessionRuntimeHints; + explicitFlagKeys: Set; + hadConnectionDefaults: boolean; +}): boolean { + if (options.command !== 'open') return false; + if (options.runtime) return false; + if (options.flags.bundleUrl || options.flags.metroHost || options.flags.metroPort) return false; + if (options.flags.remoteConfig) return false; + if (options.hadConnectionDefaults) return false; + return hasExplicitRemoteScopeFlags(options.explicitFlagKeys); +} + +function hasExplicitRemoteScopeFlags(explicitFlagKeys: Set): boolean { + return ( + explicitFlagKeys.has('daemonBaseUrl') || + explicitFlagKeys.has('daemonTransport') || + explicitFlagKeys.has('tenant') || + explicitFlagKeys.has('sessionIsolation') || + explicitFlagKeys.has('runId') || + explicitFlagKeys.has('leaseId') || + explicitFlagKeys.has('leaseBackend') + ); +} + function mergeConnectionFlags( flags: CliFlags, defaults: Partial, diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 0b91470cd..60e2195f8 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -1,10 +1,14 @@ import { resolveDaemonPaths } from '../../daemon/config.ts'; import { stopMetroTunnel } from '../../metro.ts'; +import { resolveRemoteConfigProfile } from '../../remote-config.ts'; import { + buildRemoteConnectionDaemonState, + hashRemoteConfigFile, readRemoteConnectionState, writeRemoteConnectionState, type RemoteConnectionState, } from '../../remote-connection-state.ts'; +import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../../remote-config-schema.ts'; import type { BatchStep } from '../../core/dispatch.ts'; import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; @@ -37,15 +41,39 @@ export async function materializeRemoteConnectionForCommand(options: { } const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const state = readRemoteConnectionState({ stateDir, session: flags.session ?? 'default' }); - if (!state) { - return { flags, runtime: options.runtime }; + const remoteConfig = resolveRemoteConfigProfile({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }); + const profileFlags = profileToCliFlags(remoteConfig.profile); + const mergedFlags = { + ...profileFlags, + ...flags, + remoteConfig: remoteConfig.resolvedPath, + }; + const existingState = readRemoteConnectionState({ + stateDir, + session: mergedFlags.session ?? 'default', + }); + if (existingState && existingState.remoteConfigPath !== remoteConfig.resolvedPath) { + throw new AppError( + 'INVALID_ARGS', + 'A different remote connection is already active for this session. Run connect --force or disconnect before using a different --remote-config.', + { + session: existingState.session, + activeRemoteConfig: existingState.remoteConfigPath, + requestedRemoteConfig: remoteConfig.resolvedPath, + }, + ); } - const nextFlags = { ...flags }; - let nextRuntime = selectCompatibleRuntime(state.runtime, flags.platform) ?? options.runtime; + const state = + existingState ?? createRemoteConnectionStateFromFlags(mergedFlags, remoteConfig.resolvedPath); + const nextFlags = { ...mergedFlags, session: state.session }; + let nextRuntime = selectCompatibleRuntime(state.runtime, nextFlags.platform) ?? options.runtime; let nextState = state; - let changed = false; + let changed = !existingState; let metroCleanupToStop: RemoteConnectionState['metro'] | undefined; let preparedMetroCleanupOnFailure: RemoteConnectionState['metro'] | undefined; @@ -72,7 +100,7 @@ export async function materializeRemoteConnectionForCommand(options: { if ( shouldPrepareRuntimeForCommand(command, options.batchSteps) && - hasDeferredMetroConfig(flags) + hasDeferredMetroConfig(nextFlags) ) { if (!nextState.leaseId && nextFlags.leaseId) { nextState = { @@ -263,7 +291,7 @@ function shouldPrepareRuntimeForCommand(command: string, batchSteps?: BatchStep[ }); } -function hasDeferredMetroConfig(flags: CliFlags): boolean { +export function hasDeferredMetroConfig(flags: CliFlags): boolean { return Boolean( flags.metroPublicBaseUrl || flags.metroProxyBaseUrl || @@ -301,6 +329,57 @@ function selectCompatibleRuntime( return isRuntimeCompatibleWithPlatform(runtime, platform) ? runtime : undefined; } +function profileToCliFlags(profile: RemoteConfigProfile): Partial { + const flags: Partial = {}; + for (const spec of REMOTE_CONFIG_FIELD_SPECS) { + const value = profile[spec.key]; + if (value !== undefined) { + (flags as Record)[spec.key] = value; + } + } + return flags; +} + +function createRemoteConnectionStateFromFlags( + flags: CliFlags, + remoteConfigPath: string, +): RemoteConnectionState { + if (!flags.tenant) { + throw new AppError( + 'INVALID_ARGS', + 'remote command requires tenant in remote config or via --tenant .', + ); + } + if (!flags.runId) { + throw new AppError( + 'INVALID_ARGS', + 'remote command requires runId in remote config or via --run-id .', + ); + } + if (!flags.daemonBaseUrl) { + throw new AppError( + 'INVALID_ARGS', + 'remote command requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', + ); + } + const now = new Date().toISOString(); + return { + version: 1, + session: flags.session ?? 'default', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: buildRemoteConnectionDaemonState(flags), + tenant: flags.tenant, + runId: flags.runId, + leaseId: flags.leaseId, + leaseBackend: flags.leaseBackend ?? resolveRequestedLeaseBackend(flags), + platform: flags.platform, + target: flags.target, + connectedAt: now, + updatedAt: now, + }; +} + async function allocateOrReuseLease( client: AgentDeviceClient, state: RemoteConnectionState, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index 8b32c7693..238fbf6eb 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -2,6 +2,7 @@ import crypto from 'node:crypto'; import { resolveDaemonPaths } from '../../daemon/config.ts'; import { resolveRemoteConfigProfile } from '../../remote-config.ts'; import { + buildRemoteConnectionDaemonState, fingerprint, hashRemoteConfigFile, readActiveConnectionState, @@ -12,6 +13,7 @@ import { } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; import { + hasDeferredMetroConfig, releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, @@ -104,14 +106,17 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => await stopMetroCleanup(previous.metro); await releasePreviousLease(client, previous); } + const runtimePreparation = buildRuntimePreparationNotice(flags, state); - writeCommandOutput( - flags, - serializeConnectionState(state), - () => + writeCommandOutput(flags, serializeConnectionState(state, runtimePreparation), () => + [ `Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${ state.leaseId ? `lease ${state.leaseId}` : 'lease pending' }`, + runtimePreparation?.message, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'), ); return true; }; @@ -177,13 +182,17 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla ); return true; } - writeCommandOutput(flags, serializeConnectionState(state), () => + const runtimePreparation = buildRuntimePreparationNoticeFromState(state); + writeCommandOutput(flags, serializeConnectionState(state, runtimePreparation), () => [ `Connected remote session "${state.session}".`, `tenant=${state.tenant} runId=${state.runId} leaseId=${state.leaseId ?? 'pending'} backend=${state.leaseBackend ?? 'pending'}`, `remoteConfig=${state.remoteConfigPath}`, state.runtime ? 'metro=prepared' : 'metro=not-prepared', - ].join('\n'), + runtimePreparation?.message, + ] + .filter((line): line is string => Boolean(line)) + .join('\n'), ); return true; }; @@ -235,27 +244,67 @@ function isSameDaemonState( } function buildDaemonState(flags: CliFlags): RemoteConnectionState['daemon'] { + return buildRemoteConnectionDaemonState(flags); +} + +type RuntimePreparationNotice = { + status: 'deferred'; + message: string; + nextStep: string; +}; + +function buildRuntimePreparationNotice( + flags: CliFlags, + state: RemoteConnectionState, +): RuntimePreparationNotice | undefined { + if (state.runtime) return undefined; + if (!hasDeferredMetroConfig(flags) && !remoteConfigHasMetroSettings(state.remoteConfigPath)) { + return undefined; + } + return buildDeferredRuntimeNotice(state.remoteConfigPath); +} + +function buildRuntimePreparationNoticeFromState( + state: RemoteConnectionState, +): RuntimePreparationNotice | undefined { + if (state.runtime || !remoteConfigHasMetroSettings(state.remoteConfigPath)) return undefined; + return buildDeferredRuntimeNotice(state.remoteConfigPath); +} + +function buildDeferredRuntimeNotice(remoteConfigPath: string): RuntimePreparationNotice { + const nextStep = `agent-device metro prepare --remote-config ${remoteConfigPath}`; return { - baseUrl: sanitizeDaemonBaseUrl(flags.daemonBaseUrl), - transport: flags.daemonTransport, - serverMode: flags.daemonServerMode, + status: 'deferred', + nextStep, + message: + `Metro runtime is not prepared yet; it will be prepared automatically on first open, ` + + `or run "${nextStep}" to inspect it before launch.`, }; } -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); - } +function remoteConfigHasMetroSettings(remoteConfigPath: string): boolean { + try { + const remoteConfig = resolveRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: process.cwd(), + env: process.env, + }); + const profile = remoteConfig.profile; + return Boolean( + profile.metroPublicBaseUrl || + profile.metroProxyBaseUrl || + profile.metroProjectRoot || + profile.metroKind, + ); + } catch { + return false; } - return url.toString().replace(/\/+$/, ''); } -function serializeConnectionState(state: RemoteConnectionState): Record { +function serializeConnectionState( + state: RemoteConnectionState, + runtimePreparation?: RuntimePreparationNotice, +): Record { return { connected: true, session: state.session, @@ -272,6 +321,7 @@ function serializeConnectionState(state: RemoteConnectionState): Record { expect(response.error.message).toMatch(/actual=\{"visible":false/); } }); + +test('is reports Android permission dialog blocker when app content assertion fails', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'android-permission-blocked'; + sessionStore.set( + sessionName, + makeBaseAndroidSession(sessionName, { appBundleId: 'com.example.demo' }), + ); + + mockDispatch.mockImplementation(async (_device, command) => { + if (command !== 'snapshot') throw new Error(`unexpected command: ${command}`); + return { nodes: [], backend: 'uiautomator' }; + }); + mockGetAndroidAppState.mockResolvedValue({ + package: 'com.google.android.permissioncontroller', + activity: 'com.android.permissioncontroller.permission.ui.GrantPermissionsActivity', + }); + + const response = await handleInteractionCommands({ + req: { + token: 't', + session: sessionName, + command: 'is', + positionals: ['visible', 'label="Metro Ready"'], + flags: {}, + }, + sessionName, + sessionStore, + contextFromFlags, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(false); + if (response && !response.ok) { + expect(response.error.message).toMatch(/permission dialog is blocking/); + expect(response.error.details).toMatchObject({ + blockedBy: 'android_foreground_surface', + expectedPackage: 'com.example.demo', + foregroundPackage: 'com.google.android.permissioncontroller', + }); + } +}); diff --git a/src/daemon/handlers/interaction-android-escape.ts b/src/daemon/handlers/interaction-android-escape.ts index 14d73ff6e..0fdd833f4 100644 --- a/src/daemon/handlers/interaction-android-escape.ts +++ b/src/daemon/handlers/interaction-android-escape.ts @@ -2,29 +2,52 @@ import { getAndroidAppState } from '../../platforms/android/index.ts'; import { AppError } from '../../utils/errors.ts'; import type { SessionState } from '../types.ts'; +export type AndroidEscapeSurface = { + expectedPackage: string; + foregroundPackage: string; + activity?: string; + hint: string; +}; + export async function assertAndroidPressStayedInApp( session: SessionState, targetLabel: string, ): Promise { - if (session.device.platform !== 'android' || !session.appBundleId) return; - - const foreground = await getAndroidAppState(session.device); - const foregroundPackage = foreground.package?.trim(); - if (!foregroundPackage || foregroundPackage === session.appBundleId) return; - if (!looksLikeAndroidEscapeSurface(foregroundPackage)) return; + const surface = await detectAndroidEscapeSurface(session); + if (!surface) return; throw new AppError( 'COMMAND_FAILED', - `press ${targetLabel} left ${session.appBundleId} and foregrounded ${foregroundPackage}. The tap likely escaped the app.`, - { - expectedPackage: session.appBundleId, - foregroundPackage, - activity: foreground.activity, - hint: 'Use screenshot as visual truth, then take a fresh snapshot -i before retrying.', - }, + `press ${targetLabel} left ${session.appBundleId} and foregrounded ${surface.foregroundPackage}. The tap likely escaped the app.`, + surface, ); } +export async function detectAndroidEscapeSurface( + session: SessionState, +): Promise { + if (session.device.platform !== 'android' || !session.appBundleId) return null; + + const foreground = await getAndroidAppState(session.device); + const foregroundPackage = foreground.package?.trim(); + if (!foregroundPackage || foregroundPackage === session.appBundleId) return null; + if (!looksLikeAndroidEscapeSurface(foregroundPackage)) return null; + + return { + expectedPackage: session.appBundleId, + foregroundPackage, + activity: foreground.activity, + hint: buildAndroidEscapeHint(foregroundPackage), + }; +} + +export function describeAndroidEscapeSurface(surface: AndroidEscapeSurface): string { + if (surface.foregroundPackage === 'com.google.android.permissioncontroller') { + return `Android permission dialog is blocking ${surface.expectedPackage}`; + } + return `${surface.foregroundPackage} is foreground instead of ${surface.expectedPackage}`; +} + export function isAndroidEscapeError(error: AppError): boolean { return ( error.code === 'COMMAND_FAILED' && @@ -33,6 +56,13 @@ export function isAndroidEscapeError(error: AppError): boolean { ); } +function buildAndroidEscapeHint(packageName: string): string { + if (packageName === 'com.google.android.permissioncontroller') { + return 'Dismiss or allow the permission prompt, then retry the smoke assertion.'; + } + return 'Use screenshot as visual truth, then take a fresh snapshot -i before retrying.'; +} + function looksLikeAndroidEscapeSurface(packageName: string): boolean { return ( packageName === 'com.android.settings' || diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index f1fb79786..9bdc60652 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -32,6 +32,10 @@ import { isSupportedPredicate } from './is-predicates.ts'; import type { ContextFromFlags } from './handlers/interaction-common.ts'; import { createUnsupportedArtifactAdapter } from './runtime-artifacts.ts'; import { getActiveAndroidSnapshotFreshness } from './android-snapshot-freshness.ts'; +import { + describeAndroidEscapeSurface, + detectAndroidEscapeSurface, +} from './handlers/interaction-android-escape.ts'; import { buildFindRecordResult, buildGetRecordResult, @@ -167,7 +171,7 @@ export async function dispatchIsViaRuntime( }); if (!resolvedRuntime.ok) return resolvedRuntime.response; - return await toDaemonResponse(async () => { + const response = await toDaemonResponse(async () => { const result = await resolvedRuntime.runtime.selectors.is({ session: params.sessionName, requestId: req.meta?.requestId, @@ -178,6 +182,7 @@ export async function dispatchIsViaRuntime( recordIfSession(params.sessionStore, params.sessionName, req, result); return stripSelectorChain(result); }); + return await maybeAndroidForegroundBlockerResponse(params, response, `is ${predicate}`); } export async function dispatchWaitViaRuntime( @@ -196,7 +201,7 @@ export async function dispatchWaitViaRuntime( session, device, }); - return await toDaemonResponse(async () => { + const response = await toDaemonResponse(async () => { const result = await runtime.selectors.wait({ session: sessionName, requestId: req.meta?.requestId, @@ -205,6 +210,7 @@ export async function dispatchWaitViaRuntime( recordIfSession(sessionStore, sessionName, req, result); return toDaemonWaitData(result); }); + return await maybeAndroidForegroundBlockerResponse(params, response, 'wait'); }; if (!waitNeedsRunnerCleanup(parsed)) return await execute(); return await withSessionlessRunnerCleanup(session, device, execute); @@ -464,6 +470,33 @@ async function toDaemonResponse( } } +async function maybeAndroidForegroundBlockerResponse( + params: SelectorRuntimeParams, + response: DaemonResponse, + commandLabel: string, +): Promise { + if (response.ok) return response; + const session = params.sessionStore.get(params.sessionName); + if (!session) return response; + let surface: Awaited>; + try { + surface = await detectAndroidEscapeSurface(session); + } catch { + return response; + } + if (!surface) return response; + return errorResponse( + response.error.code, + `${commandLabel} failed because ${describeAndroidEscapeSurface(surface)}.`, + { + ...(response.error.details ?? {}), + ...surface, + blockedBy: 'android_foreground_surface', + originalMessage: response.error.message, + }, + ); +} + function toCommandSession(session: SessionState | undefined) { if (!session) return undefined; return { diff --git a/src/remote-connection-state.ts b/src/remote-connection-state.ts index b10b02c9d..3f3d3cf6e 100644 --- a/src/remote-connection-state.ts +++ b/src/remote-connection-state.ts @@ -1,7 +1,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; -import { resolveRemoteConfigProfile } from './remote-config-core.ts'; +import { resolveRemoteConfigPath, 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'; @@ -71,6 +71,16 @@ export function writeRemoteConnectionState(options: { writeJsonFile(activeConnectionStatePath(options.stateDir), { session: options.state.session }); } +export function buildRemoteConnectionDaemonState( + flags: Pick, +): RemoteConnectionState['daemon'] { + return { + baseUrl: sanitizeDaemonBaseUrl(flags.daemonBaseUrl), + transport: flags.daemonTransport, + serverMode: flags.daemonServerMode, + }; +} + export function removeRemoteConnectionState(options: { stateDir: string; session: string }): void { fs.rmSync(remoteConnectionStatePath(options), { force: true }); const activePath = activeConnectionStatePath(options.stateDir); @@ -83,18 +93,29 @@ export function removeRemoteConnectionState(options: { stateDir: string; session export function resolveRemoteConnectionDefaults(options: { stateDir: string; session: string; + remoteConfig?: string; cwd: string; env: Record; allowActiveFallback?: boolean; validateRemoteConfigHash?: boolean; }): RemoteConnectionDefaults | null { const validateRemoteConfigHash = options.validateRemoteConfigHash ?? true; + const expectedRemoteConfigPath = options.remoteConfig + ? resolveRemoteConfigPath({ + configPath: options.remoteConfig, + cwd: options.cwd, + env: options.env, + }) + : undefined; const state = readRemoteConnectionState(options) ?? (options.allowActiveFallback ? readActiveConnectionState({ stateDir: options.stateDir }) : null); if (!state) return null; + if (expectedRemoteConfigPath && state.remoteConfigPath !== expectedRemoteConfigPath) { + return null; + } if ( validateRemoteConfigHash && hashRemoteConfigFile(state.remoteConfigPath) !== state.remoteConfigHash @@ -207,6 +228,19 @@ function writeJsonFile(filePath: string, value: unknown): void { fs.chmodSync(filePath, 0o600); } +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 removeInvalidRemoteConnectionState( options: { stateDir: string; session: string }, error?: unknown, diff --git a/src/utils/cli-options.ts b/src/utils/cli-options.ts index 5682af6cd..be71adb27 100644 --- a/src/utils/cli-options.ts +++ b/src/utils/cli-options.ts @@ -41,5 +41,5 @@ export function resolveCliOptions( } function shouldApplyRemoteConfigDefaults(command: string | null): boolean { - return command === 'connect' || command === 'metro'; + return command !== null; } diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index d43d93e0c..e3c4f9e13 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -987,7 +987,7 @@ const COMMAND_SCHEMAS: Record = { connect: { usageOverride: 'connect --remote-config [--tenant ] [--run-id ] [--lease-backend ] [--force]', - helpDescription: 'Connect to a remote daemon, allocate a tenant lease, and prepare Metro', + helpDescription: 'Connect to a remote daemon and save remote session state', summary: 'Connect to remote daemon', positionalArgs: [], allowedFlags: [ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ab44b5d8a..1f3d9419d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -663,10 +663,21 @@ agent-device snapshot -i agent-device disconnect ``` +For self-contained scripts, pass the same profile to each step: + +```bash +agent-device install-from-source https://example.com/builds/app.apk --remote-config ./agent-device.remote.json --platform android +agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch +agent-device snapshot --remote-config ./agent-device.remote.json -i +agent-device disconnect --remote-config ./agent-device.remote.json +``` + - `--remote-config ` points to a remote workflow profile that captures stable host, tenant/run, and any optional session, platform, lease backend, or Metro overrides for `connect`. - `connect --remote-config ...` is the main agent flow. It generates a local session name when needed, stores the remote scope locally, and defers tenant lease allocation plus Metro preparation until a later command needs them. - Deferred Metro preparation also applies to `batch` when any step opens an app and the batch does not provide its own per-step runtime. -- 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. +- 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. Passing the same `--remote-config` to a normal command is also supported for self-contained scripts; the CLI reuses matching saved state or creates it before dispatch. +- Self-contained remote scripts should end with `disconnect --remote-config ` or `disconnect` to release the lease and stop the owned Metro companion. +- Explicit command-line flags override connected defaults. When `open` uses explicit remote daemon or tenant flags without saved runtime hints, the CLI warns because React Native apps may launch without Metro bundle/runtime hints. - `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.