Skip to content

Commit aebc59b

Browse files
authored
feat: streamline remote metro open flow (#240)
* feat: streamline remote metro open flow * fix: honor remote config defaults and cli precedence
1 parent 3da69cb commit aebc59b

34 files changed

Lines changed: 1195 additions & 266 deletions

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,8 @@ Navigation helpers:
349349
- `install`/`reinstall` accept package/bundle id style app names and support `~` in paths.
350350
- `install-from-source` supports `--retain-paths` and `--retention-ms <ms>` when callers need retained materialized artifact paths after the install.
351351
- When `AGENT_DEVICE_DAEMON_BASE_URL` targets a remote daemon, local `.apk`/`.aab`/`.ipa` files and `.app` bundles are uploaded automatically before `install`/`reinstall`.
352-
- `metro prepare --public-base-url <url>` starts or reuses a local Metro server for sandbox/client flows and prints runtime JSON to stdout. `--json` wraps the same payload in the standard `{ success, data }` envelope. Pass `--proxy-base-url <url>` plus `AGENT_DEVICE_PROXY_TOKEN` (preferred) or `--bearer-token <token>` when the runtime must be bridged through `agent-device-proxy`, and `--runtime-file <path>` only when a persisted artifact is needed.
353-
- Remote daemon clients can persist session-scoped runtime hints with `runtime set` before `open`; Android launches write React Native dev prefs, and iOS simulator launches write React Native bundle defaults before app start. Example: `agent-device runtime set --session my-session --platform android --metro-host 10.0.0.10 --metro-port 8081 --launch-url "myapp://dev"`.
352+
- `open <app> --remote-config <path> --relaunch` is the canonical remote Metro-backed launch flow for sandbox agents. The remote profile supplies host + Metro settings, `open` prepares Metro locally when needed, derives platform runtime hints, and forwards them inline to the remote daemon before launch.
353+
- `metro prepare --remote-config <path>` remains available for inspection and debugging. It prints JSON runtime hints to stdout, `--json` wraps them in the standard `{ success, data }` envelope, and `--runtime-file <path>` persists the same payload when callers need an artifact.
354354
- Remote daemon screenshots and recordings are materialized back to the caller path instead of returning host-local daemon paths.
355355
- To force a daemon-side path instead of uploading a local file, prefix it with `remote:`, for example `remote:/srv/builds/MyApp.app`.
356356
- Supported binary formats for `install`/`reinstall`: Android `.apk` and `.aab`, iOS `.app` and `.ipa`.
@@ -568,7 +568,7 @@ Environment selectors:
568568
- `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]` connect directly to a remote HTTP daemon and skip local daemon metadata/startup.
569569
- Remote daemon installs upload local artifacts through `POST /upload`; use a `remote:` path prefix when you need the daemon to read an existing server-side artifact path as-is.
570570
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>` auth token for remote HTTP daemon mode; sent in both the JSON-RPC request token and HTTP auth headers (`Authorization: Bearer` and `x-agent-device-token`).
571-
- `AGENT_DEVICE_PROXY_TOKEN=<token>` preferred bearer token for `metro prepare --proxy-base-url <url>` so the proxy secret does not need to be passed on the command line. `AGENT_DEVICE_METRO_BEARER_TOKEN` is also supported.
571+
- `AGENT_DEVICE_PROXY_TOKEN=<token>` preferred bearer token for `metro prepare --proxy-base-url <url>` so the host-bridge secret does not need to be passed on the command line. `AGENT_DEVICE_METRO_BEARER_TOKEN` is also supported.
572572
- `AGENT_DEVICE_DAEMON_SERVER_MODE=socket|http|dual` daemon server mode. `http` and `dual` expose JSON-RPC 2.0 at `POST /rpc` (`GET /health` available for liveness).
573573
- `AGENT_DEVICE_DAEMON_TRANSPORT=auto|socket|http` client preference when connecting to daemon metadata.
574574
- `AGENT_DEVICE_HTTP_AUTH_HOOK=<module-path>` optional HTTP auth hook module path for JSON-RPC server mode.

skills/agent-device/SKILL.md

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ Use this skill as a router, not a full manual.
3333
- iOS local QA: use simulators unless the task explicitly requires a physical device.
3434
- iOS local QA in mixed simulator/device environments: run `ensure-simulator` first and pass `--device`, `--udid`, or `--ios-simulator-device-set` on later commands.
3535
- Android local QA: use `install` or `reinstall` for `.apk`/`.aab` files, then relaunch by installed package name.
36-
- Android React Native + Metro flows: set runtime hints with `runtime set` before `open <package> --relaunch`.
36+
- Android React Native + Metro flows: prefer `open <package> --remote-config <path> --relaunch`.
3737
- In mixed-device environments, always pin the exact target with `--serial`, `--device`, `--udid`, or an isolation scope.
3838
- For session-bound automation runs, prefer a pre-bound session/platform instead of repeating selectors on every command: set `AGENT_DEVICE_SESSION`, set `AGENT_DEVICE_PLATFORM`, and the daemon will enforce the shared lock policy across CLI, typed client, and RPC entry points.
3939
- Use `--session-lock reject|strip` (or `AGENT_DEVICE_SESSION_LOCK`) only when you need to override the default reject behavior. Lock mode applies to nested `batch` steps too.
@@ -67,8 +67,7 @@ Use this when a physical iPhone is also connected and you want deterministic sim
6767

6868
```bash
6969
agent-device reinstall MyApp /path/to/app-debug.apk --platform android --serial emulator-5554
70-
agent-device runtime set --session qa-android --platform android --metro-host 10.0.2.2 --metro-port 8081
71-
agent-device open com.example.myapp --platform android --serial emulator-5554 --session qa-android --relaunch
70+
agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch
7271
agent-device snapshot -i
7372
agent-device close
7473
```
@@ -186,7 +185,7 @@ That includes bound-session defaults such as `sessionLock` / `AGENT_DEVICE_SESSI
186185
For Android emulators by AVD name, use `boot --platform android --device <avd-name>`.
187186
For Android emulators without GUI, add `--headless`.
188187
Use `--target mobile|tv` with `--platform` (required) to pick phone/tablet vs TV targets (AndroidTV/tvOS).
189-
For Android React Native + Metro flows, install or reinstall the APK first, set runtime hints with `runtime set`, then use `open <package> --relaunch`; do not use `open <apk|aab> --relaunch`.
188+
For Android React Native + Metro flows, install or reinstall the APK first, then use `open <package> --remote-config <path> --relaunch`; do not use `open <apk|aab> --relaunch`.
190189
For local iOS QA in mixed simulator/device environments, use `ensure-simulator` and pass `--device` or `--udid` so automation does not attach to a physical device by accident.
191190
For session-bound automation, prefer `AGENT_DEVICE_SESSION` + `AGENT_DEVICE_PLATFORM`; that bound-session default now enables lock mode automatically.
192191

src/__tests__/cli-client-commands.test.ts

Lines changed: 229 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,17 @@
1+
import fs from 'node:fs';
2+
import os from 'node:os';
3+
import path from 'node:path';
14
import test from 'node:test';
25
import assert from 'node:assert/strict';
36
import { tryRunClientBackedCommand } from '../cli-client-commands.ts';
47
import type {
58
AgentDeviceClient,
69
AppInstallFromSourceOptions,
10+
AppOpenOptions,
711
MetroPrepareOptions,
812
} from '../client.ts';
913
import { AppError } from '../utils/errors.ts';
14+
import { resolveCliOptions } from '../utils/cli-options.ts';
1015

1116
test('install-from-source forwards URL and repeated headers to client.apps.installFromSource', async () => {
1217
let observed: AppInstallFromSourceOptions | undefined;
@@ -180,6 +185,223 @@ test('metro prepare wraps output in the standard success envelope for --json', a
180185
assert.equal(payload.data.iosRuntime.platform, 'ios');
181186
});
182187

188+
test('metro prepare with --remote-config loads profile defaults', async () => {
189+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-metro-'));
190+
const configDir = path.join(tmpRoot, 'config');
191+
fs.mkdirSync(configDir, { recursive: true });
192+
const remoteConfigPath = path.join(configDir, 'remote.json');
193+
fs.writeFileSync(
194+
remoteConfigPath,
195+
JSON.stringify({
196+
metroProjectRoot: './apps/demo',
197+
metroPublicBaseUrl: 'https://sandbox.example.test',
198+
metroProxyBaseUrl: 'https://proxy.example.test',
199+
metroPreparePort: 9090,
200+
}),
201+
);
202+
const parsed = resolveCliOptions(['metro', 'prepare', '--remote-config', remoteConfigPath], {
203+
cwd: tmpRoot,
204+
env: process.env,
205+
});
206+
207+
let observedPrepare: MetroPrepareOptions | undefined;
208+
const client = createStubClient({
209+
installFromSource: async () => {
210+
throw new Error('unexpected install call');
211+
},
212+
prepareMetro: async (options) => {
213+
observedPrepare = options;
214+
return {
215+
projectRoot: '/tmp/project',
216+
kind: 'react-native',
217+
dependenciesInstalled: false,
218+
packageManager: null,
219+
started: false,
220+
reused: true,
221+
pid: 0,
222+
logPath: '/tmp/project/.agent-device/metro.log',
223+
statusUrl: 'http://127.0.0.1:8081/status',
224+
runtimeFilePath: null,
225+
iosRuntime: {
226+
platform: 'ios',
227+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
228+
},
229+
androidRuntime: {
230+
platform: 'android',
231+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
232+
},
233+
bridge: null,
234+
};
235+
},
236+
});
237+
238+
const stdout = await captureStdout(async () => {
239+
const handled = await tryRunClientBackedCommand({
240+
command: 'metro',
241+
positionals: ['prepare'],
242+
flags: parsed.flags,
243+
client,
244+
});
245+
assert.equal(handled, true);
246+
});
247+
const payload = JSON.parse(stdout);
248+
assert.deepEqual(observedPrepare, {
249+
projectRoot: path.join(configDir, 'apps/demo'),
250+
kind: undefined,
251+
publicBaseUrl: 'https://sandbox.example.test',
252+
proxyBaseUrl: 'https://proxy.example.test',
253+
bearerToken: undefined,
254+
port: 9090,
255+
listenHost: undefined,
256+
statusHost: undefined,
257+
startupTimeoutMs: undefined,
258+
probeTimeoutMs: undefined,
259+
reuseExisting: undefined,
260+
installDependenciesIfNeeded: undefined,
261+
runtimeFilePath: undefined,
262+
});
263+
assert.equal(payload.kind, 'react-native');
264+
});
265+
266+
test('open with --remote-config prepares Metro and forwards inline runtime hints', async () => {
267+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-'));
268+
const configDir = path.join(tmpRoot, 'config');
269+
fs.mkdirSync(configDir, { recursive: true });
270+
const remoteConfigPath = path.join(configDir, 'remote.json');
271+
fs.writeFileSync(
272+
remoteConfigPath,
273+
JSON.stringify({
274+
platform: 'android',
275+
metroProjectRoot: './apps/demo',
276+
metroRuntimeFile: './.agent-device-cloud/metro-runtime.json',
277+
metroPublicBaseUrl: 'https://sandbox.example.test',
278+
metroProxyBaseUrl: 'https://proxy.example.test',
279+
metroPreparePort: 9090,
280+
}),
281+
);
282+
const parsed = resolveCliOptions(
283+
['open', 'com.example.app', '--remote-config', remoteConfigPath],
284+
{
285+
cwd: tmpRoot,
286+
env: process.env,
287+
},
288+
);
289+
290+
let observedPrepare: MetroPrepareOptions | undefined;
291+
let observedOpen: AppOpenOptions | undefined;
292+
const client = createStubClient({
293+
installFromSource: async () => {
294+
throw new Error('unexpected install call');
295+
},
296+
prepareMetro: async (options) => {
297+
observedPrepare = options;
298+
return {
299+
projectRoot: '/tmp/project',
300+
kind: 'react-native',
301+
dependenciesInstalled: false,
302+
packageManager: null,
303+
started: false,
304+
reused: true,
305+
pid: 0,
306+
logPath: '/tmp/project/.agent-device/metro.log',
307+
statusUrl: 'http://127.0.0.1:8081/status',
308+
runtimeFilePath: null,
309+
iosRuntime: {
310+
platform: 'ios',
311+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
312+
},
313+
androidRuntime: {
314+
platform: 'android',
315+
metroHost: '10.0.2.2',
316+
metroPort: 9090,
317+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
318+
launchUrl: 'myapp://dev',
319+
},
320+
bridge: null,
321+
};
322+
},
323+
open: async (options) => {
324+
observedOpen = options;
325+
return {
326+
session: options.session ?? 'default',
327+
runtime: options.runtime,
328+
identifiers: { session: options.session ?? 'default' },
329+
};
330+
},
331+
});
332+
333+
const handled = await tryRunClientBackedCommand({
334+
command: 'open',
335+
positionals: ['com.example.app'],
336+
flags: { ...parsed.flags, relaunch: true },
337+
client,
338+
});
339+
340+
assert.equal(handled, true);
341+
assert.deepEqual(observedPrepare, {
342+
projectRoot: path.join(configDir, 'apps/demo'),
343+
kind: undefined,
344+
publicBaseUrl: 'https://sandbox.example.test',
345+
proxyBaseUrl: 'https://proxy.example.test',
346+
bearerToken: undefined,
347+
port: 9090,
348+
listenHost: undefined,
349+
statusHost: undefined,
350+
startupTimeoutMs: undefined,
351+
probeTimeoutMs: undefined,
352+
reuseExisting: undefined,
353+
installDependenciesIfNeeded: undefined,
354+
runtimeFilePath: path.join(configDir, '.agent-device-cloud/metro-runtime.json'),
355+
});
356+
assert.deepEqual(observedOpen?.runtime, {
357+
platform: 'android',
358+
metroHost: '10.0.2.2',
359+
metroPort: 9090,
360+
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
361+
launchUrl: 'myapp://dev',
362+
});
363+
});
364+
365+
test('open with --remote-config preserves CLI overrides over profile defaults', () => {
366+
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-override-'));
367+
const configDir = path.join(tmpRoot, 'config');
368+
fs.mkdirSync(configDir, { recursive: true });
369+
const remoteConfigPath = path.join(configDir, 'remote.json');
370+
fs.writeFileSync(
371+
remoteConfigPath,
372+
JSON.stringify({
373+
session: 'remote-session',
374+
platform: 'android',
375+
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
376+
metroPublicBaseUrl: 'https://sandbox.example.test',
377+
}),
378+
);
379+
380+
const parsed = resolveCliOptions(
381+
[
382+
'open',
383+
'com.example.app',
384+
'--remote-config',
385+
remoteConfigPath,
386+
'--session',
387+
'cli-session',
388+
'--platform',
389+
'ios',
390+
'--daemon-base-url',
391+
'http://cli-mac.example.test:9124/agent-device',
392+
],
393+
{
394+
cwd: tmpRoot,
395+
env: process.env,
396+
},
397+
);
398+
399+
assert.equal(parsed.flags.session, 'cli-session');
400+
assert.equal(parsed.flags.platform, 'ios');
401+
assert.equal(parsed.flags.daemonBaseUrl, 'http://cli-mac.example.test:9124/agent-device');
402+
assert.equal(parsed.flags.metroPublicBaseUrl, 'https://sandbox.example.test');
403+
});
404+
183405
async function captureStdout(run: () => Promise<void>): Promise<string> {
184406
let stdout = '';
185407
const originalWrite = process.stdout.write.bind(process.stdout);
@@ -200,6 +422,7 @@ async function captureStdout(run: () => Promise<void>): Promise<string> {
200422
function createStubClient(params: {
201423
installFromSource: AgentDeviceClient['apps']['installFromSource'];
202424
prepareMetro?: AgentDeviceClient['metro']['prepare'];
425+
open?: AgentDeviceClient['apps']['open'];
203426
}): AgentDeviceClient {
204427
return {
205428
devices: {
@@ -237,10 +460,12 @@ function createStubClient(params: {
237460
identifiers: { appId: 'com.example.demo' },
238461
}),
239462
installFromSource: params.installFromSource,
240-
open: async () => ({
241-
session: 'default',
242-
identifiers: { session: 'default' },
243-
}),
463+
open:
464+
params.open ??
465+
(async () => ({
466+
session: 'default',
467+
identifiers: { session: 'default' },
468+
})),
244469
close: async () => ({
245470
session: 'default',
246471
identifiers: { session: 'default' },
@@ -253,18 +478,6 @@ function createStubClient(params: {
253478
identifiers: { session: options.session ?? 'default' },
254479
}),
255480
},
256-
runtime: {
257-
set: async () => ({
258-
session: 'default',
259-
configured: true,
260-
identifiers: { session: 'default' },
261-
}),
262-
show: async () => ({
263-
session: 'default',
264-
configured: false,
265-
identifiers: { session: 'default' },
266-
}),
267-
},
268481
metro: {
269482
prepare:
270483
params.prepareMetro ??

src/__tests__/cli-config.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,61 @@ test('AGENT_DEVICE_CONFIG loads an explicit config path', async () => {
225225
fs.rmSync(root, { recursive: true, force: true });
226226
});
227227

228+
test('remote config defaults override generic config and env for remote workflow bindings', async () => {
229+
const { root, home, project } = makeTempWorkspace();
230+
fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true });
231+
fs.writeFileSync(
232+
path.join(project, 'agent-device.json'),
233+
JSON.stringify({ session: 'project-session', platform: 'ios' }),
234+
'utf8',
235+
);
236+
const remoteConfig = path.join(project, 'agent-device.remote.json');
237+
fs.writeFileSync(
238+
remoteConfig,
239+
JSON.stringify({
240+
session: 'remote-session',
241+
platform: 'android',
242+
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
243+
}),
244+
'utf8',
245+
);
246+
247+
const result = await runCliCapture(['snapshot', '--remote-config', remoteConfig, '--json'], {
248+
cwd: project,
249+
env: {
250+
HOME: home,
251+
AGENT_DEVICE_SESSION: 'env-session',
252+
AGENT_DEVICE_PLATFORM: 'ios',
253+
},
254+
});
255+
256+
assert.equal(result.code, null);
257+
assert.equal(result.calls.length, 1);
258+
assert.equal(result.calls[0]?.session, 'remote-session');
259+
assert.equal(result.calls[0]?.flags?.platform, 'android');
260+
assert.equal(
261+
result.calls[0]?.flags?.daemonBaseUrl,
262+
'http://remote-mac.example.test:9124/agent-device',
263+
);
264+
265+
fs.rmSync(root, { recursive: true, force: true });
266+
});
267+
268+
test('missing explicit remote config path returns parse error before daemon dispatch', async () => {
269+
const { root, home, project } = makeTempWorkspace();
270+
271+
const result = await runCliCapture(['snapshot', '--remote-config', './missing.remote.json'], {
272+
cwd: project,
273+
env: { HOME: home },
274+
});
275+
276+
assert.equal(result.code, 1);
277+
assert.match(result.stderr, /Remote config file not found/);
278+
assert.equal(result.calls.length, 0);
279+
280+
fs.rmSync(root, { recursive: true, force: true });
281+
});
282+
228283
test('config and env defaults include session lock policy flags', async () => {
229284
const { root, home, project } = makeTempWorkspace();
230285
fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true });

0 commit comments

Comments
 (0)