From 778d1868a6516c77df691a4b1573205e1b6ecc79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 11:37:09 +0200 Subject: [PATCH 1/8] fix: propagate remote config through commands --- .../agent-device/references/remote-tenancy.md | 5 +- src/__tests__/cli-config.test.ts | 97 +++++++++++++++++-- src/__tests__/remote-connection.test.ts | 78 +++++++++++++++ src/cli.ts | 67 +++++++++++-- src/cli/commands/connection-runtime.ts | 93 ++++++++++++++++-- src/cli/commands/connection.ts | 20 +--- src/remote-connection-state.ts | 36 ++++++- src/utils/cli-options.ts | 2 +- website/docs/docs/commands.md | 3 +- 9 files changed, 354 insertions(+), 47 deletions(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 1a2caef9d..793da0b97 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -13,7 +13,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux ## 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 flags when a remote connection is already active. That can bypass saved Metro runtime hints. Prefer `connect --remote-config ` once, then normal commands. If a command must be self-contained, pass the same `--remote-config ` on that command so the CLI can merge the remote profile and persist any lease/runtime state it prepares. ## Preferred remote flow @@ -36,7 +36,7 @@ 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 `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command. +After `connect`, normal `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. Remote install examples: @@ -84,6 +84,7 @@ Optional overrides stay available for advanced cases: - Omit Metro fields for non-React Native flows. - Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow. - Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. +- If Android opens to a notification permission dialog before the React Native screen, treat the dialog as the current UI: run `snapshot -i`, then press the visible allow/dismiss button by `@ref` before checking Metro content again. A failed app-content check while `com.google.android.permissioncontroller` is foreground usually means the smoke is blocked by the permission prompt, not by Metro. - 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. diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 526d4d059..e9a4f5979 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -483,23 +483,102 @@ 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('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..914bbae78 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -332,6 +332,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..1e129d262 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,15 +380,16 @@ 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'), @@ -378,6 +398,37 @@ function resolveActiveConnectionDefaults(options: { 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..c1fc918c8 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 = { @@ -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..a2f59cf47 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, @@ -235,24 +236,7 @@ function isSameDaemonState( } 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(/\/+$/, ''); + return buildRemoteConnectionDaemonState(flags); } function serializeConnectionState(state: RemoteConnectionState): Record { 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/website/docs/docs/commands.md b/website/docs/docs/commands.md index ab44b5d8a..b26e33ddb 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -666,7 +666,8 @@ agent-device disconnect - `--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. +- 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. From 020e22b593abc016bd5e57120bdc5c9924c8a7f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 11:53:22 +0200 Subject: [PATCH 2/8] fix: complete remote React Native flow --- .../agent-device/references/remote-tenancy.md | 13 ++- src/__tests__/cli-client-commands.test.ts | 52 +++++++++++ src/__tests__/cli-config.test.ts | 68 ++++++++++++++ src/__tests__/remote-connection.test.ts | 91 +++++++++++++++++++ src/cli.ts | 18 ++++ src/cli/commands/connection-runtime.ts | 4 +- src/cli/commands/connection.ts | 80 ++++++++++++++-- src/cli/commands/install.ts | 2 +- src/cli/commands/router.ts | 2 + src/cli/commands/run-react-native.ts | 84 +++++++++++++++++ src/commands/catalog.ts | 1 + src/core/__tests__/capabilities.test.ts | 3 + src/core/capabilities.ts | 6 ++ .../handlers/__tests__/interaction.test.ts | 42 +++++++++ .../handlers/interaction-android-escape.ts | 56 +++++++++--- src/daemon/selector-runtime.ts | 37 +++++++- src/utils/__tests__/args.test.ts | 24 +++++ src/utils/command-schema.ts | 40 +++++++- website/docs/docs/commands.md | 13 +++ website/docs/docs/introduction.md | 2 +- 20 files changed, 609 insertions(+), 29 deletions(-) create mode 100644 src/cli/commands/run-react-native.ts diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 793da0b97..68ff317c3 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -7,6 +7,7 @@ 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 --remote-config run-react-native android --app ` - `agent-device connection status` - `agent-device disconnect` - `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...` @@ -38,6 +39,16 @@ agent-device disconnect After `connect`, normal `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. +For the React Native happy path, prefer a single ordered command when the app artifact is already available by URL: + +```bash +agent-device --remote-config ./remote-config.json run-react-native android \ + --install-from-source https://example.com/builds/app.apk \ + --app com.example.app +``` + +This command uses the positional platform for lease selection, prepares missing Metro runtime hints from the remote profile, installs the URL artifact when provided, then opens the requested app with a relaunch after install. + Remote install examples: ```bash @@ -84,7 +95,7 @@ Optional overrides stay available for advanced cases: - Omit Metro fields for non-React Native flows. - Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow. - Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. -- If Android opens to a notification permission dialog before the React Native screen, treat the dialog as the current UI: run `snapshot -i`, then press the visible allow/dismiss button by `@ref` before checking Metro content again. A failed app-content check while `com.google.android.permissioncontroller` is foreground usually means the smoke is blocked by the permission prompt, not by Metro. +- If Android opens to a notification permission dialog before the React Native screen, treat the dialog as the current UI: run `snapshot -i`, then press the visible allow/dismiss button by `@ref` before checking Metro content again. `is` and `wait` assertions now report this as a permission-dialog blocker when `com.google.android.permissioncontroller` is foreground, so do not interpret that failure as a Metro failure. - 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. diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 2e9e8004b..44e0cb4f3 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -83,6 +83,58 @@ test('install-from-source rejects malformed header syntax', async () => { ); }); +test('run-react-native installs from URL then opens the requested app', async () => { + let observedInstall: AppInstallFromSourceOptions | undefined; + let observedOpen: AppOpenOptions | undefined; + const client = createStubClient({ + installFromSource: async (options) => { + observedInstall = options; + return { + launchTarget: 'com.example.demo', + packageName: 'com.example.demo', + identifiers: { appId: 'com.example.demo', package: 'com.example.demo' }, + }; + }, + open: async (options) => { + observedOpen = options; + return { + session: 'rn-android', + appBundleId: 'com.example.demo', + identifiers: { session: 'rn-android', appId: 'com.example.demo' }, + }; + }, + }); + + const stdout = await captureStdout(async () => { + const handled = await tryRunClientBackedCommand({ + command: 'run-react-native', + positionals: ['android'], + flags: { + json: false, + help: false, + version: false, + app: 'com.example.demo', + installFromSource: 'https://example.com/app.apk', + header: ['authorization: Bearer token'], + }, + client, + }); + assert.equal(handled, true); + }); + + assert.equal(observedInstall?.platform, 'android'); + assert.deepEqual(observedInstall?.source, { + kind: 'url', + url: 'https://example.com/app.apk', + headers: { authorization: 'Bearer token' }, + }); + assert.equal(observedOpen?.platform, 'android'); + assert.equal(observedOpen?.app, 'com.example.demo'); + assert.equal(observedOpen?.relaunch, true); + assert.match(stdout, /Installed: com.example.demo/); + assert.match(stdout, /Opened: com.example.demo/); +}); + test('metro prepare forwards normalized options to client.metro.prepare', async () => { let observed: MetroPrepareOptions | undefined; const client = createStubClient({ diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index e9a4f5979..956730064 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -540,6 +540,74 @@ test('normal commands accept direct remote-config usage', async () => { fs.rmSync(root, { recursive: true, force: true }); }); +test('run-react-native uses positional platform for remote lease and install/open flow', 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', + }), + 'utf8', + ); + + const result = await runCliCapture( + [ + 'run-react-native', + 'android', + '--remote-config', + remoteConfig, + '--state-dir', + stateDir, + '--app', + 'com.example.demo', + '--install-from-source', + 'https://example.com/app.apk', + '--json', + ], + { + cwd: project, + env: { HOME: home }, + sendToDaemon: async (req) => { + if (req.command === 'lease_allocate') { + return { + ok: true, + data: { + lease: { + leaseId: 'lease-rn-001', + tenantId: 'acme', + runId: 'run-123', + backend: 'android-instance', + }, + }, + }; + } + if (req.command === 'install_source') { + return { ok: true, data: { launchTarget: 'com.example.demo' } }; + } + if (req.command === 'open') { + return { ok: true, data: { appBundleId: 'com.example.demo' } }; + } + return { ok: true, data: {} }; + }, + }, + ); + + assert.equal(result.code, null); + assert.equal(result.calls[0]?.command, 'lease_allocate'); + assert.equal(result.calls[1]?.command, 'install_source'); + assert.equal(result.calls[1]?.flags?.platform, 'android'); + assert.equal(result.calls[2]?.command, 'open'); + assert.equal(result.calls[2]?.positionals?.[0], 'com.example.demo'); + assert.equal(result.calls[2]?.flags?.relaunch, true); + assert.equal(result.calls[2]?.meta?.leaseId, 'lease-rn-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(); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 914bbae78..7ed3b2453 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'); @@ -410,6 +447,60 @@ test('direct remote-config materialization creates state and prepares Metro for fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('direct remote-config materialization prepares Metro for run-react-native', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-direct-remote-rn-')); + 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-rn', + platform: 'android', + metroPublicBaseUrl: 'https://sandbox.example.test', + metroProxyBaseUrl: 'https://proxy.example.test', + }), + ); + + const materialized = await materializeRemoteConnectionForCommand({ + command: 'run-react-native', + flags: { + json: true, + help: false, + version: false, + stateDir, + remoteConfig: remoteConfigPath, + daemonBaseUrl: 'https://daemon.example', + tenant: 'acme', + runId: 'run-123', + session: 'direct-rn', + platform: 'android', + }, + client: createTestClient({ + allocate: async (request) => ({ + leaseId: 'lease-rn', + tenantId: request.tenant, + runId: request.runId, + backend: request.leaseBackend ?? 'android-instance', + }), + }), + }); + + assert.equal(materialized.flags.leaseId, 'lease-rn'); + assert.deepEqual(materialized.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', + }); + assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'direct-rn' })?.runtime, { + platform: 'android', + bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', + }); + + 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 1e129d262..55625eb70 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -176,6 +176,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): effectiveFlags = connectionDefaults ? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys) : flags; + effectiveFlags = applyCommandPositionalDefaults(command, positionals, effectiveFlags); } catch (err) { const appErr = asAppError(err); const normalized = normalizeError(appErr, { @@ -402,6 +403,23 @@ function shouldMaterializeRemoteConnection(command: string): boolean { return !REMOTE_MATERIALIZATION_DEFERRED_COMMANDS.has(command); } +function applyCommandPositionalDefaults( + command: string, + positionals: string[], + flags: CliFlags, +): CliFlags { + if (command !== 'run-react-native') return flags; + const platform = positionals[0]; + if (platform !== 'ios' && platform !== 'android') return flags; + if (flags.platform && flags.platform !== platform) { + throw new AppError( + 'INVALID_ARGS', + `run-react-native ${platform} conflicts with --platform ${flags.platform}.`, + ); + } + return { ...flags, platform }; +} + function shouldWarnOpenMayMissRemoteRuntime(options: { command: string; flags: CliFlags; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index c1fc918c8..f01ffc7f9 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -25,7 +25,7 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -const runtimeDeferredCommands = new Set(['open']); +const runtimeDeferredCommands = new Set(['open', 'run-react-native']); export async function materializeRemoteConnectionForCommand(options: { command: string; @@ -291,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 || diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index a2f59cf47..c2475811a 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -13,6 +13,7 @@ import { } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; import { + hasDeferredMetroConfig, releasePreviousLease, resolveRequestedLeaseBackend, stopMetroCleanup, @@ -105,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; }; @@ -178,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; }; @@ -239,7 +247,64 @@ function buildDaemonState(flags: CliFlags): RemoteConnectionState['daemon'] { return buildRemoteConnectionDaemonState(flags); } -function serializeConnectionState(state: RemoteConnectionState): Record { +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 { + status: 'deferred', + nextStep, + message: + `Metro runtime is not prepared yet; it will be prepared automatically on first open/run-react-native, ` + + `or run "${nextStep}" to inspect it before launch.`, + }; +} + +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; + } +} + +function serializeConnectionState( + state: RemoteConnectionState, + runtimePreparation?: RuntimePreparationNotice, +): Record { return { connected: true, session: state.session, @@ -256,6 +321,7 @@ function serializeConnectionState(state: RemoteConnectionState): Record | undefined { if (!headerFlags || headerFlags.length === 0) return undefined; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 40e15e1aa..74fddf5ea 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -7,6 +7,7 @@ import { ensureSimulatorCommand } from './ensure-simulator.ts'; import { metroCommand } from './metro.ts'; import { appsCommand } from './apps.ts'; import { installCommand, reinstallCommand, installFromSourceCommand } from './install.ts'; +import { runReactNativeCommand } from './run-react-native.ts'; import { openCommand, closeCommand } from './open.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { snapshotCommand } from './snapshot.ts'; @@ -32,6 +33,7 @@ const dedicatedClientApiHandlers = { install: installCommand, reinstall: reinstallCommand, 'install-from-source': installFromSourceCommand, + 'run-react-native': runReactNativeCommand, connect: connectCommand, disconnect: disconnectCommand, connection: connectionCommand, diff --git a/src/cli/commands/run-react-native.ts b/src/cli/commands/run-react-native.ts new file mode 100644 index 000000000..9f6a240f6 --- /dev/null +++ b/src/cli/commands/run-react-native.ts @@ -0,0 +1,84 @@ +import { serializeInstallFromSourceResult, serializeOpenResult } from '../../client-shared.ts'; +import type { AppInstallFromSourceResult } from '../../client-types.ts'; +import { AppError } from '../../utils/errors.ts'; +import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; +import { parseInstallSourceHeaders } from './install.ts'; +import type { ClientCommandHandler } from './router.ts'; + +export const runReactNativeCommand: ClientCommandHandler = async ({ + positionals, + flags, + client, +}) => { + const platform = readReactNativePlatform(positionals); + if (flags.platform && flags.platform !== platform) { + throw new AppError( + 'INVALID_ARGS', + `run-react-native ${platform} conflicts with --platform ${flags.platform}.`, + ); + } + const app = flags.app?.trim(); + if (!app) { + throw new AppError('INVALID_ARGS', 'run-react-native requires --app .'); + } + const effectiveFlags = { ...flags, platform }; + let installed: AppInstallFromSourceResult | undefined; + if (flags.installFromSource) { + installed = await client.apps.installFromSource({ + ...buildSelectionOptions(effectiveFlags), + retainPaths: flags.retainPaths, + retentionMs: flags.retentionMs, + source: { + kind: 'url', + url: flags.installFromSource, + headers: parseInstallSourceHeaders(flags.header), + }, + }); + } + const opened = await client.apps.open({ + ...buildSelectionOptions(effectiveFlags), + app, + activity: flags.activity, + relaunch: flags.relaunch ?? Boolean(installed), + saveScript: flags.saveScript, + noRecord: flags.noRecord, + }); + const installData = installed ? serializeInstallFromSourceResult(installed) : undefined; + const openData = serializeOpenResult(opened); + writeCommandOutput( + flags, + { + platform, + app, + ...(installData ? { installed: stripSuccessText(installData) } : {}), + opened: stripSuccessText(openData), + }, + () => + [installData?.message, openData.message] + .filter((line): line is string => typeof line === 'string' && line.length > 0) + .join('\n'), + ); + return true; +}; + +function readReactNativePlatform(positionals: string[]): 'ios' | 'android' { + const platform = positionals[0]; + if (platform !== 'ios' && platform !== 'android') { + throw new AppError( + 'INVALID_ARGS', + 'run-react-native requires platform: run-react-native ios|android --app .', + ); + } + if (positionals.length > 1) { + throw new AppError( + 'INVALID_ARGS', + 'run-react-native accepts exactly one positional argument: ios|android.', + ); + } + return platform; +} + +function stripSuccessText(data: Record): Record { + const { message: _message, ...rest } = data; + return rest; +} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index e9bc7625e..20f386efe 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -82,6 +82,7 @@ export const commandCatalog: readonly CommandCatalogEntry[] = [ { command: 'install', category: 'backend-admin', status: 'implemented' }, { command: 'reinstall', category: 'backend-admin', status: 'implemented' }, { command: 'install-from-source', category: 'backend-admin', status: 'implemented' }, + { command: 'run-react-native', category: 'backend-admin', status: 'implemented' }, { command: 'admin.devices', category: 'backend-admin', status: 'implemented' }, { command: 'admin.boot', category: 'backend-admin', status: 'implemented' }, { command: 'admin.ensureSimulator', category: 'backend-admin', status: 'implemented' }, diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 14d3d32d6..4b88fb00d 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -158,6 +158,7 @@ test('core commands support iOS simulator, iOS device, and Android', () => { 'perf', 'press', 'record', + 'run-react-native', 'rotate', 'screenshot', 'scroll', @@ -216,6 +217,7 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'install-from-source', 'push', 'reinstall', + 'run-react-native', 'rotate', ], [{ device: macOsDevice, expected: false, label: 'on macOS' }], @@ -307,6 +309,7 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'push', 'record', 'reinstall', + 'run-react-native', 'rotate', 'settings', 'trigger-app-event', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 18e74d485..9f150e53a 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -164,6 +164,12 @@ const COMMAND_CAPABILITY_MATRIX: Record = { linux: LINUX_NONE, supports: isNotMacOs, }, + 'run-react-native': { + apple: { simulator: true, device: true }, + android: { emulator: true, device: true, unknown: true }, + linux: LINUX_NONE, + supports: isNotMacOs, + }, reinstall: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, diff --git a/src/daemon/handlers/__tests__/interaction.test.ts b/src/daemon/handlers/__tests__/interaction.test.ts index 5d840bb07..2e98589c8 100644 --- a/src/daemon/handlers/__tests__/interaction.test.ts +++ b/src/daemon/handlers/__tests__/interaction.test.ts @@ -1869,3 +1869,45 @@ test('is visible fails for nodes outside the current viewport', async () => { 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/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index 835746ff1..cc5df4e96 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -202,6 +202,29 @@ test('parseArgs accepts install-from-source url and repeated headers', () => { assert.equal(parsed.flags.retentionMs, 60000); }); +test('parseArgs accepts run-react-native happy path flags', () => { + const parsed = parseArgs( + [ + 'run-react-native', + 'android', + '--app', + 'com.example.demo', + '--install-from-source', + 'https://example.com/builds/app.apk', + '--header', + 'authorization: Bearer token', + '--relaunch', + ], + { strictFlags: true }, + ); + assert.equal(parsed.command, 'run-react-native'); + assert.deepEqual(parsed.positionals, ['android']); + assert.equal(parsed.flags.app, 'com.example.demo'); + assert.equal(parsed.flags.installFromSource, 'https://example.com/builds/app.apk'); + assert.deepEqual(parsed.flags.header, ['authorization: Bearer token']); + assert.equal(parsed.flags.relaunch, true); +}); + test('parseArgs accepts metro prepare arguments', () => { const parsed = parseArgs( [ @@ -658,6 +681,7 @@ test('parseArgs rejects conflicting back mode flags', () => { test('usage includes concise top-level commands', () => { const usageText = usage(); assert.match(usageText, /install-from-source /); + assert.match(usageText, /run-react-native ios\|android --app /); assert.match(usageText, /metro prepare --public-base-url /); assert.match(usageText, /batch --steps \| --steps-file /); assert.match(usageText, /network dump/); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index d43d93e0c..3945c9e54 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -76,6 +76,8 @@ export type CliFlags = { pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; activity?: string; + app?: string; + installFromSource?: string; header?: string[]; saveScript?: boolean | string; shutdown?: boolean; @@ -519,13 +521,28 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--activity ', usageDescription: 'Android app launch activity (package/Activity); not for URL opens', }, + { + key: 'app', + names: ['--app'], + type: 'string', + usageLabel: '--app ', + usageDescription: 'run-react-native: app bundle identifier or Android package to open', + }, + { + key: 'installFromSource', + names: ['--install-from-source'], + type: 'string', + usageLabel: '--install-from-source ', + usageDescription: 'run-react-native: install an app artifact URL before opening', + }, { key: 'header', names: ['--header'], type: 'string', multiple: true, usageLabel: '--header ', - usageDescription: 'install-from-source: repeatable HTTP header for URL downloads', + usageDescription: + 'install-from-source/run-react-native: repeatable HTTP header for URL downloads', }, { key: 'session', @@ -987,7 +1004,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: [ @@ -1050,6 +1067,25 @@ const COMMAND_SCHEMAS: Record = { positionalArgs: ['url'], allowedFlags: ['header', 'retainPaths', 'retentionMs'], }, + 'run-react-native': { + usageOverride: + 'run-react-native ios|android --app [--install-from-source ] [--relaunch]', + listUsageOverride: 'run-react-native ios|android --app [--install-from-source ]', + helpDescription: + 'Prepare remote Metro runtime, optionally install, then open a React Native app', + summary: 'Install and open a React Native app', + positionalArgs: ['ios|android'], + allowedFlags: [ + 'app', + 'installFromSource', + 'activity', + 'header', + 'retainPaths', + 'retentionMs', + 'relaunch', + 'saveScript', + ], + }, push: { helpDescription: 'Simulate push notification payload delivery', summary: 'Deliver push payload', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b26e33ddb..b418599a6 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -386,6 +386,19 @@ agent-device install-from-source https://api.github.com/repos/acme/app/actions/a - `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. - URL downloads follow the same `installFromSource()` safety checks and host restrictions as the JS client API. +## React Native remote happy path + +```bash +agent-device --remote-config ./remote-config.json run-react-native android \ + --install-from-source https://example.com/builds/app.apk \ + --app com.example.app +``` + +- `run-react-native ios|android --app ` prepares the remote Metro runtime when the profile has Metro settings, optionally installs a URL artifact, then opens the app. +- `--install-from-source ` accepts the same URL sources and repeatable `--header ` flags as `install-from-source`. +- The positional platform (`ios` or `android`) is used for remote lease selection, so profiles can omit `platform` when a script provides it in the command. +- The command is equivalent to the ordered remote flow: materialize remote lease/runtime, install from URL when requested, then `open --relaunch` after install. + ## Push notification simulation ```bash diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 76bc2dae5..0e2986ff9 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -21,7 +21,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t ## Platform support highlights -- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `back`, `home`, `rotate`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `reinstall`, `trigger-app-event`. +- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `back`, `home`, `rotate`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `run-react-native`, `reinstall`, `trigger-app-event`. - iOS `appstate` is session-scoped on the selected target device. - iOS/tvOS simulator-only: `settings`, `push`, `clipboard`. - Apple simulators and macOS desktop app sessions: `alert`, `pinch`. From f3e04aeff6d7fddeadf1efc9e2657f447e147268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 11:56:25 +0200 Subject: [PATCH 3/8] test: cover remote install source lease reuse --- .../agent-device/references/remote-tenancy.md | 2 +- src/__tests__/cli-config.test.ts | 110 ++++++++++++++++++ website/docs/docs/commands.md | 1 + 3 files changed, 112 insertions(+), 1 deletion(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 68ff317c3..bbff548d0 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -37,7 +37,7 @@ 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 `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. +After `connect`, normal `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. End self-contained remote scripts with `agent-device disconnect --remote-config ` or `agent-device disconnect` to release the lease and stop the owned Metro companion. For the React Native happy path, prefer a single ordered command when the app artifact is already available by URL: diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index 956730064..b3027bf72 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -540,6 +540,116 @@ test('normal commands accept direct remote-config usage', async () => { 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('run-react-native uses positional platform for remote lease and install/open flow', async () => { const { root, home, project } = makeTempWorkspace(); const stateDir = path.join(root, 'state'); diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index b418599a6..ebcfbe004 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -680,6 +680,7 @@ agent-device disconnect - `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. 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. From 1c5bfe7760080f1337750dd8a9bfd9f917a7ff9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 11:59:26 +0200 Subject: [PATCH 4/8] fix: honor remote config session scope --- src/__tests__/cli-config.test.ts | 85 ++++++++++++++++++++++++++++++++ src/cli.ts | 4 +- 2 files changed, 88 insertions(+), 1 deletion(-) diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index b3027bf72..d972eada9 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -540,6 +540,91 @@ test('normal commands accept direct remote-config usage', async () => { 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'); diff --git a/src/cli.ts b/src/cli.ts index 55625eb70..e726926dc 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -393,7 +393,9 @@ function resolveActiveConnectionDefaults(options: { 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; From 6c4cb2779bf8a45ed5ccb01d6425c5325a3673f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 12:12:25 +0200 Subject: [PATCH 5/8] refactor: drop remote react native shortcut --- .../agent-device/references/remote-tenancy.md | 24 ++++-- src/__tests__/cli-client-commands.test.ts | 52 ------------ src/__tests__/cli-config.test.ts | 68 --------------- src/__tests__/remote-connection.test.ts | 54 ------------ src/cli.ts | 18 ---- src/cli/commands/connection-runtime.ts | 2 +- src/cli/commands/connection.ts | 2 +- src/cli/commands/install.ts | 2 +- src/cli/commands/router.ts | 2 - src/cli/commands/run-react-native.ts | 84 ------------------- src/commands/catalog.ts | 1 - src/core/__tests__/capabilities.test.ts | 3 - src/core/capabilities.ts | 6 -- src/utils/__tests__/args.test.ts | 24 ------ src/utils/command-schema.ts | 38 +-------- website/docs/docs/commands.md | 22 ++--- website/docs/docs/introduction.md | 2 +- 17 files changed, 32 insertions(+), 372 deletions(-) delete mode 100644 src/cli/commands/run-react-native.ts diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index bbff548d0..39c3132a5 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -7,7 +7,8 @@ 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 --remote-config run-react-native android --app ` +- `agent-device install-from-source --remote-config --platform android` +- `agent-device open --remote-config --relaunch` - `agent-device connection status` - `agent-device disconnect` - `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...` @@ -39,15 +40,26 @@ agent-device disconnect After `connect`, normal `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. End self-contained remote scripts with `agent-device disconnect --remote-config ` or `agent-device disconnect` to release the lease and stop the owned Metro companion. -For the React Native happy path, prefer a single ordered command when the app artifact is already available by URL: +For self-contained React Native scripts, pass the same `--remote-config` to each step so the CLI can propagate daemon, lease, and Metro runtime state: ```bash -agent-device --remote-config ./remote-config.json run-react-native android \ - --install-from-source https://example.com/builds/app.apk \ - --app com.example.app +agent-device install-from-source https://example.com/builds/app.apk \ + --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 ``` -This command uses the positional platform for lease selection, prepares missing Metro runtime hints from the remote profile, installs the URL artifact when provided, then opens the requested app with a relaunch after install. +Use this explicit sequence instead of mixing ad-hoc `--session`, daemon, tenant, or lease flags. The first command that needs a lease or Metro runtime prepares and persists it for the later steps. Remote install examples: diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index 44e0cb4f3..2e9e8004b 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -83,58 +83,6 @@ test('install-from-source rejects malformed header syntax', async () => { ); }); -test('run-react-native installs from URL then opens the requested app', async () => { - let observedInstall: AppInstallFromSourceOptions | undefined; - let observedOpen: AppOpenOptions | undefined; - const client = createStubClient({ - installFromSource: async (options) => { - observedInstall = options; - return { - launchTarget: 'com.example.demo', - packageName: 'com.example.demo', - identifiers: { appId: 'com.example.demo', package: 'com.example.demo' }, - }; - }, - open: async (options) => { - observedOpen = options; - return { - session: 'rn-android', - appBundleId: 'com.example.demo', - identifiers: { session: 'rn-android', appId: 'com.example.demo' }, - }; - }, - }); - - const stdout = await captureStdout(async () => { - const handled = await tryRunClientBackedCommand({ - command: 'run-react-native', - positionals: ['android'], - flags: { - json: false, - help: false, - version: false, - app: 'com.example.demo', - installFromSource: 'https://example.com/app.apk', - header: ['authorization: Bearer token'], - }, - client, - }); - assert.equal(handled, true); - }); - - assert.equal(observedInstall?.platform, 'android'); - assert.deepEqual(observedInstall?.source, { - kind: 'url', - url: 'https://example.com/app.apk', - headers: { authorization: 'Bearer token' }, - }); - assert.equal(observedOpen?.platform, 'android'); - assert.equal(observedOpen?.app, 'com.example.demo'); - assert.equal(observedOpen?.relaunch, true); - assert.match(stdout, /Installed: com.example.demo/); - assert.match(stdout, /Opened: com.example.demo/); -}); - test('metro prepare forwards normalized options to client.metro.prepare', async () => { let observed: MetroPrepareOptions | undefined; const client = createStubClient({ diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index d972eada9..9e268dbed 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -735,74 +735,6 @@ test('install-from-source --remote-config writes and reuses lease state', async fs.rmSync(root, { recursive: true, force: true }); }); -test('run-react-native uses positional platform for remote lease and install/open flow', 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', - }), - 'utf8', - ); - - const result = await runCliCapture( - [ - 'run-react-native', - 'android', - '--remote-config', - remoteConfig, - '--state-dir', - stateDir, - '--app', - 'com.example.demo', - '--install-from-source', - 'https://example.com/app.apk', - '--json', - ], - { - cwd: project, - env: { HOME: home }, - sendToDaemon: async (req) => { - if (req.command === 'lease_allocate') { - return { - ok: true, - data: { - lease: { - leaseId: 'lease-rn-001', - tenantId: 'acme', - runId: 'run-123', - backend: 'android-instance', - }, - }, - }; - } - if (req.command === 'install_source') { - return { ok: true, data: { launchTarget: 'com.example.demo' } }; - } - if (req.command === 'open') { - return { ok: true, data: { appBundleId: 'com.example.demo' } }; - } - return { ok: true, data: {} }; - }, - }, - ); - - assert.equal(result.code, null); - assert.equal(result.calls[0]?.command, 'lease_allocate'); - assert.equal(result.calls[1]?.command, 'install_source'); - assert.equal(result.calls[1]?.flags?.platform, 'android'); - assert.equal(result.calls[2]?.command, 'open'); - assert.equal(result.calls[2]?.positionals?.[0], 'com.example.demo'); - assert.equal(result.calls[2]?.flags?.relaunch, true); - assert.equal(result.calls[2]?.meta?.leaseId, 'lease-rn-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(); diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 7ed3b2453..be81737bf 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -447,60 +447,6 @@ test('direct remote-config materialization creates state and prepares Metro for fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('direct remote-config materialization prepares Metro for run-react-native', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-direct-remote-rn-')); - 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-rn', - platform: 'android', - metroPublicBaseUrl: 'https://sandbox.example.test', - metroProxyBaseUrl: 'https://proxy.example.test', - }), - ); - - const materialized = await materializeRemoteConnectionForCommand({ - command: 'run-react-native', - flags: { - json: true, - help: false, - version: false, - stateDir, - remoteConfig: remoteConfigPath, - daemonBaseUrl: 'https://daemon.example', - tenant: 'acme', - runId: 'run-123', - session: 'direct-rn', - platform: 'android', - }, - client: createTestClient({ - allocate: async (request) => ({ - leaseId: 'lease-rn', - tenantId: request.tenant, - runId: request.runId, - backend: request.leaseBackend ?? 'android-instance', - }), - }), - }); - - assert.equal(materialized.flags.leaseId, 'lease-rn'); - assert.deepEqual(materialized.runtime, { - platform: 'android', - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', - }); - assert.deepEqual(readRemoteConnectionState({ stateDir, session: 'direct-rn' })?.runtime, { - platform: 'android', - bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android', - }); - - 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 e726926dc..59d901181 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -176,7 +176,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): effectiveFlags = connectionDefaults ? mergeConnectionFlags(flags, connectionDefaults.flags, explicitFlagKeys) : flags; - effectiveFlags = applyCommandPositionalDefaults(command, positionals, effectiveFlags); } catch (err) { const appErr = asAppError(err); const normalized = normalizeError(appErr, { @@ -405,23 +404,6 @@ function shouldMaterializeRemoteConnection(command: string): boolean { return !REMOTE_MATERIALIZATION_DEFERRED_COMMANDS.has(command); } -function applyCommandPositionalDefaults( - command: string, - positionals: string[], - flags: CliFlags, -): CliFlags { - if (command !== 'run-react-native') return flags; - const platform = positionals[0]; - if (platform !== 'ios' && platform !== 'android') return flags; - if (flags.platform && flags.platform !== platform) { - throw new AppError( - 'INVALID_ARGS', - `run-react-native ${platform} conflicts with --platform ${flags.platform}.`, - ); - } - return { ...flags, platform }; -} - function shouldWarnOpenMayMissRemoteRuntime(options: { command: string; flags: CliFlags; diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index f01ffc7f9..60e2195f8 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -25,7 +25,7 @@ const leaseDeferredCommands = new Set([ 'metro', 'session', ]); -const runtimeDeferredCommands = new Set(['open', 'run-react-native']); +const runtimeDeferredCommands = new Set(['open']); export async function materializeRemoteConnectionForCommand(options: { command: string; diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index c2475811a..238fbf6eb 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -277,7 +277,7 @@ function buildDeferredRuntimeNotice(remoteConfigPath: string): RuntimePreparatio status: 'deferred', nextStep, message: - `Metro runtime is not prepared yet; it will be prepared automatically on first open/run-react-native, ` + + `Metro runtime is not prepared yet; it will be prepared automatically on first open, ` + `or run "${nextStep}" to inspect it before launch.`, }; } diff --git a/src/cli/commands/install.ts b/src/cli/commands/install.ts index 14cdbc6b7..ac3d1f1ea 100644 --- a/src/cli/commands/install.ts +++ b/src/cli/commands/install.ts @@ -81,7 +81,7 @@ async function runInstallFromSourceCommand( }); } -export function parseInstallSourceHeaders( +function parseInstallSourceHeaders( headerFlags: CliFlags['header'], ): Record | undefined { if (!headerFlags || headerFlags.length === 0) return undefined; diff --git a/src/cli/commands/router.ts b/src/cli/commands/router.ts index 74fddf5ea..40e15e1aa 100644 --- a/src/cli/commands/router.ts +++ b/src/cli/commands/router.ts @@ -7,7 +7,6 @@ import { ensureSimulatorCommand } from './ensure-simulator.ts'; import { metroCommand } from './metro.ts'; import { appsCommand } from './apps.ts'; import { installCommand, reinstallCommand, installFromSourceCommand } from './install.ts'; -import { runReactNativeCommand } from './run-react-native.ts'; import { openCommand, closeCommand } from './open.ts'; import { connectCommand, connectionCommand, disconnectCommand } from './connection.ts'; import { snapshotCommand } from './snapshot.ts'; @@ -33,7 +32,6 @@ const dedicatedClientApiHandlers = { install: installCommand, reinstall: reinstallCommand, 'install-from-source': installFromSourceCommand, - 'run-react-native': runReactNativeCommand, connect: connectCommand, disconnect: disconnectCommand, connection: connectionCommand, diff --git a/src/cli/commands/run-react-native.ts b/src/cli/commands/run-react-native.ts deleted file mode 100644 index 9f6a240f6..000000000 --- a/src/cli/commands/run-react-native.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { serializeInstallFromSourceResult, serializeOpenResult } from '../../client-shared.ts'; -import type { AppInstallFromSourceResult } from '../../client-types.ts'; -import { AppError } from '../../utils/errors.ts'; -import { buildSelectionOptions, writeCommandOutput } from './shared.ts'; -import { parseInstallSourceHeaders } from './install.ts'; -import type { ClientCommandHandler } from './router.ts'; - -export const runReactNativeCommand: ClientCommandHandler = async ({ - positionals, - flags, - client, -}) => { - const platform = readReactNativePlatform(positionals); - if (flags.platform && flags.platform !== platform) { - throw new AppError( - 'INVALID_ARGS', - `run-react-native ${platform} conflicts with --platform ${flags.platform}.`, - ); - } - const app = flags.app?.trim(); - if (!app) { - throw new AppError('INVALID_ARGS', 'run-react-native requires --app .'); - } - const effectiveFlags = { ...flags, platform }; - let installed: AppInstallFromSourceResult | undefined; - if (flags.installFromSource) { - installed = await client.apps.installFromSource({ - ...buildSelectionOptions(effectiveFlags), - retainPaths: flags.retainPaths, - retentionMs: flags.retentionMs, - source: { - kind: 'url', - url: flags.installFromSource, - headers: parseInstallSourceHeaders(flags.header), - }, - }); - } - const opened = await client.apps.open({ - ...buildSelectionOptions(effectiveFlags), - app, - activity: flags.activity, - relaunch: flags.relaunch ?? Boolean(installed), - saveScript: flags.saveScript, - noRecord: flags.noRecord, - }); - const installData = installed ? serializeInstallFromSourceResult(installed) : undefined; - const openData = serializeOpenResult(opened); - writeCommandOutput( - flags, - { - platform, - app, - ...(installData ? { installed: stripSuccessText(installData) } : {}), - opened: stripSuccessText(openData), - }, - () => - [installData?.message, openData.message] - .filter((line): line is string => typeof line === 'string' && line.length > 0) - .join('\n'), - ); - return true; -}; - -function readReactNativePlatform(positionals: string[]): 'ios' | 'android' { - const platform = positionals[0]; - if (platform !== 'ios' && platform !== 'android') { - throw new AppError( - 'INVALID_ARGS', - 'run-react-native requires platform: run-react-native ios|android --app .', - ); - } - if (positionals.length > 1) { - throw new AppError( - 'INVALID_ARGS', - 'run-react-native accepts exactly one positional argument: ios|android.', - ); - } - return platform; -} - -function stripSuccessText(data: Record): Record { - const { message: _message, ...rest } = data; - return rest; -} diff --git a/src/commands/catalog.ts b/src/commands/catalog.ts index 20f386efe..e9bc7625e 100644 --- a/src/commands/catalog.ts +++ b/src/commands/catalog.ts @@ -82,7 +82,6 @@ export const commandCatalog: readonly CommandCatalogEntry[] = [ { command: 'install', category: 'backend-admin', status: 'implemented' }, { command: 'reinstall', category: 'backend-admin', status: 'implemented' }, { command: 'install-from-source', category: 'backend-admin', status: 'implemented' }, - { command: 'run-react-native', category: 'backend-admin', status: 'implemented' }, { command: 'admin.devices', category: 'backend-admin', status: 'implemented' }, { command: 'admin.boot', category: 'backend-admin', status: 'implemented' }, { command: 'admin.ensureSimulator', category: 'backend-admin', status: 'implemented' }, diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index 4b88fb00d..14d3d32d6 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -158,7 +158,6 @@ test('core commands support iOS simulator, iOS device, and Android', () => { 'perf', 'press', 'record', - 'run-react-native', 'rotate', 'screenshot', 'scroll', @@ -217,7 +216,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'install-from-source', 'push', 'reinstall', - 'run-react-native', 'rotate', ], [{ device: macOsDevice, expected: false, label: 'on macOS' }], @@ -309,7 +307,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'push', 'record', 'reinstall', - 'run-react-native', 'rotate', 'settings', 'trigger-app-event', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 9f150e53a..18e74d485 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -164,12 +164,6 @@ const COMMAND_CAPABILITY_MATRIX: Record = { linux: LINUX_NONE, supports: isNotMacOs, }, - 'run-react-native': { - apple: { simulator: true, device: true }, - android: { emulator: true, device: true, unknown: true }, - linux: LINUX_NONE, - supports: isNotMacOs, - }, reinstall: { apple: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index cc5df4e96..835746ff1 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -202,29 +202,6 @@ test('parseArgs accepts install-from-source url and repeated headers', () => { assert.equal(parsed.flags.retentionMs, 60000); }); -test('parseArgs accepts run-react-native happy path flags', () => { - const parsed = parseArgs( - [ - 'run-react-native', - 'android', - '--app', - 'com.example.demo', - '--install-from-source', - 'https://example.com/builds/app.apk', - '--header', - 'authorization: Bearer token', - '--relaunch', - ], - { strictFlags: true }, - ); - assert.equal(parsed.command, 'run-react-native'); - assert.deepEqual(parsed.positionals, ['android']); - assert.equal(parsed.flags.app, 'com.example.demo'); - assert.equal(parsed.flags.installFromSource, 'https://example.com/builds/app.apk'); - assert.deepEqual(parsed.flags.header, ['authorization: Bearer token']); - assert.equal(parsed.flags.relaunch, true); -}); - test('parseArgs accepts metro prepare arguments', () => { const parsed = parseArgs( [ @@ -681,7 +658,6 @@ test('parseArgs rejects conflicting back mode flags', () => { test('usage includes concise top-level commands', () => { const usageText = usage(); assert.match(usageText, /install-from-source /); - assert.match(usageText, /run-react-native ios\|android --app /); assert.match(usageText, /metro prepare --public-base-url /); assert.match(usageText, /batch --steps \| --steps-file /); assert.match(usageText, /network dump/); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 3945c9e54..e3c4f9e13 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -76,8 +76,6 @@ export type CliFlags = { pauseMs?: number; pattern?: 'one-way' | 'ping-pong'; activity?: string; - app?: string; - installFromSource?: string; header?: string[]; saveScript?: boolean | string; shutdown?: boolean; @@ -521,28 +519,13 @@ const FLAG_DEFINITIONS: readonly FlagDefinition[] = [ usageLabel: '--activity ', usageDescription: 'Android app launch activity (package/Activity); not for URL opens', }, - { - key: 'app', - names: ['--app'], - type: 'string', - usageLabel: '--app ', - usageDescription: 'run-react-native: app bundle identifier or Android package to open', - }, - { - key: 'installFromSource', - names: ['--install-from-source'], - type: 'string', - usageLabel: '--install-from-source ', - usageDescription: 'run-react-native: install an app artifact URL before opening', - }, { key: 'header', names: ['--header'], type: 'string', multiple: true, usageLabel: '--header ', - usageDescription: - 'install-from-source/run-react-native: repeatable HTTP header for URL downloads', + usageDescription: 'install-from-source: repeatable HTTP header for URL downloads', }, { key: 'session', @@ -1067,25 +1050,6 @@ const COMMAND_SCHEMAS: Record = { positionalArgs: ['url'], allowedFlags: ['header', 'retainPaths', 'retentionMs'], }, - 'run-react-native': { - usageOverride: - 'run-react-native ios|android --app [--install-from-source ] [--relaunch]', - listUsageOverride: 'run-react-native ios|android --app [--install-from-source ]', - helpDescription: - 'Prepare remote Metro runtime, optionally install, then open a React Native app', - summary: 'Install and open a React Native app', - positionalArgs: ['ios|android'], - allowedFlags: [ - 'app', - 'installFromSource', - 'activity', - 'header', - 'retainPaths', - 'retentionMs', - 'relaunch', - 'saveScript', - ], - }, push: { helpDescription: 'Simulate push notification payload delivery', summary: 'Deliver push payload', diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index ebcfbe004..1f3d9419d 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -386,19 +386,6 @@ agent-device install-from-source https://api.github.com/repos/acme/app/actions/a - `--retain-paths` keeps retained materialized artifact paths after install, and `--retention-ms ` sets their TTL. - URL downloads follow the same `installFromSource()` safety checks and host restrictions as the JS client API. -## React Native remote happy path - -```bash -agent-device --remote-config ./remote-config.json run-react-native android \ - --install-from-source https://example.com/builds/app.apk \ - --app com.example.app -``` - -- `run-react-native ios|android --app ` prepares the remote Metro runtime when the profile has Metro settings, optionally installs a URL artifact, then opens the app. -- `--install-from-source ` accepts the same URL sources and repeatable `--header ` flags as `install-from-source`. -- The positional platform (`ios` or `android`) is used for remote lease selection, so profiles can omit `platform` when a script provides it in the command. -- The command is equivalent to the ordered remote flow: materialize remote lease/runtime, install from URL when requested, then `open --relaunch` after install. - ## Push notification simulation ```bash @@ -676,6 +663,15 @@ 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. diff --git a/website/docs/docs/introduction.md b/website/docs/docs/introduction.md index 0e2986ff9..76bc2dae5 100644 --- a/website/docs/docs/introduction.md +++ b/website/docs/docs/introduction.md @@ -21,7 +21,7 @@ For exploratory QA and bug-hunting workflows, see `skills/dogfood/SKILL.md` in t ## Platform support highlights -- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `back`, `home`, `rotate`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `run-react-native`, `reinstall`, `trigger-app-event`. +- iOS core runner commands: `snapshot`, `snapshot --diff`, `diff snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `back`, `home`, `rotate`, `app-switcher`, `open` (app), `close`, `screenshot`, `apps`, `appstate`, `install`, `install-from-source`, `reinstall`, `trigger-app-event`. - iOS `appstate` is session-scoped on the selected target device. - iOS/tvOS simulator-only: `settings`, `push`, `clipboard`. - Apple simulators and macOS desktop app sessions: `alert`, `pinch`. From bd945d91e1fc2666808a93c8f3c72fa7d6562a66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 12:20:15 +0200 Subject: [PATCH 6/8] docs: simplify remote tenancy skill flow --- .../agent-device/references/remote-tenancy.md | 34 +++++++++++++------ 1 file changed, 23 insertions(+), 11 deletions(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 39c3132a5..f59dc9d3a 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -9,26 +9,30 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux - `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 mix an arbitrary `--session` plus ad-hoc daemon/tenant flags when a remote connection is already active. That can bypass saved Metro runtime hints. Prefer `connect --remote-config ` once, then normal commands. If a command must be self-contained, pass the same `--remote-config ` on that command so the CLI can merge the remote profile and persist any lease/runtime state it prepares. +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 agent-device open com.example.app --relaunch agent-device snapshot -i @@ -36,11 +40,11 @@ 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. -After `connect`, normal `agent-device` commands use the active remote connection. Repeating the same `--remote-config` is also supported for self-contained scripts; it reuses matching saved state when present and prepares missing lease or Metro runtime state before dispatch. End self-contained remote scripts with `agent-device disconnect --remote-config ` or `agent-device disconnect` to release the lease and stop the owned Metro companion. +### Self-contained script flow -For self-contained React Native scripts, pass the same `--remote-config` to each step so the CLI can propagate daemon, lease, and Metro runtime state: +Use this when each command must be explicit and repeatable. Pass the same `--remote-config` to each step. ```bash agent-device install-from-source https://example.com/builds/app.apk \ @@ -59,7 +63,15 @@ agent-device disconnect \ --remote-config ./remote-config.json ``` -Use this explicit sequence instead of mixing ad-hoc `--session`, daemon, tenant, or lease flags. The first command that needs a lease or Metro runtime prepares and persists it for the later steps. +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 + +- `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: From c055c47ea52b6de4914711f96c74b34729be1460 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 12:25:28 +0200 Subject: [PATCH 7/8] docs: use trusted artifact placeholders --- .../agent-device/references/bootstrap-install.md | 8 +++++--- skills/agent-device/references/remote-tenancy.md | 15 ++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) 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 f59dc9d3a..3fc6542c9 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -33,7 +33,8 @@ export AGENT_DEVICE_PROXY_TOKEN="$AGENT_DEVICE_DAEMON_AUTH_TOKEN" agent-device connect --remote-config ./remote-config.json -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" @@ -47,7 +48,9 @@ After `connect`, normal commands use the active remote connection. End with `dis Use this when each command must be explicit and repeatable. Pass the same `--remote-config` to each step. ```bash -agent-device install-from-source https://example.com/builds/app.apk \ +ARTIFACT_URL="" + +agent-device install-from-source "$ARTIFACT_URL" \ --remote-config ./remote-config.json \ --platform android @@ -77,12 +80,14 @@ 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. From ef1ff28c70500979b3ac16023ae648d60e8747aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 17 Apr 2026 12:26:10 +0200 Subject: [PATCH 8/8] docs: prune remote tenancy prompt note --- skills/agent-device/references/remote-tenancy.md | 1 - 1 file changed, 1 deletion(-) diff --git a/skills/agent-device/references/remote-tenancy.md b/skills/agent-device/references/remote-tenancy.md index 3fc6542c9..899677193 100644 --- a/skills/agent-device/references/remote-tenancy.md +++ b/skills/agent-device/references/remote-tenancy.md @@ -124,7 +124,6 @@ Optional overrides stay available for advanced cases: - Omit Metro fields for non-React Native flows. - Put `tenant`, `runId`, and `sessionIsolation` in the remote profile so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags. Add `platform`, `leaseBackend`, `session`, or Metro overrides only when the default inference is not enough for that flow. - Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope. -- If Android opens to a notification permission dialog before the React Native screen, treat the dialog as the current UI: run `snapshot -i`, then press the visible allow/dismiss button by `@ref` before checking Metro content again. `is` and `wait` assertions now report this as a permission-dialog blocker when `com.google.android.permissioncontroller` is foreground, so do not interpret that failure as a Metro failure. - 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.