Skip to content

Commit ee6cc7c

Browse files
authored
fix: use bridge metro runtime descriptors (#425)
1 parent 33083b5 commit ee6cc7c

17 files changed

Lines changed: 252 additions & 121 deletions

skills/agent-device/references/remote-tenancy.md

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -103,28 +103,31 @@ Example `remote-config.json` shape:
103103
"tenant": "acme",
104104
"runId": "run-123",
105105
"sessionIsolation": "tenant",
106-
"platform": "android",
107-
"metroPublicBaseUrl": "http://127.0.0.1:8081"
106+
"platform": "ios",
107+
"metroProxyBaseUrl": "https://bridge.example.com"
108108
}
109109
```
110110

111111
Optional overrides stay available for advanced cases:
112112

113113
```json
114114
{
115-
"session": "adc-android",
116-
"leaseBackend": "android-instance",
115+
"session": "adc-ios",
116+
"leaseBackend": "ios-instance",
117117
"metroProjectRoot": ".",
118118
"metroKind": "expo",
119-
"metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123"
119+
"metroPublicBaseUrl": "http://127.0.0.1:8081"
120120
}
121121
```
122122

123123
- Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state.
124124
- Omit Metro fields for non-React Native flows.
125125
- 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.
126126
- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope.
127-
- 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.
127+
- 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. `metroProxyBaseUrl` is the bridge origin, not a prebuilt `/api/metro/...` route.
128+
- For cloud stock React Native iOS, use the bridge descriptor's wildcard HTTPS Metro hints directly; do not install or launch the XCTest runner just to make Metro reachable.
129+
- Android keeps using bridge-provided `/api/metro/runtimes/<runtimeId>/...` Metro routes.
130+
- `metroPublicBaseUrl` is only needed for direct/non-bridge bundle hints. Bridged profiles can omit it.
128131
- Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override.
129132

130133
## Transport prerequisites

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

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,35 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
166166
assert.equal(payload.runtimeFilePath, null);
167167
});
168168

169+
test('metro prepare rejects when no public or proxy base URL is provided', async () => {
170+
const client = createStubClient({
171+
installFromSource: async () => {
172+
throw new Error('unexpected install call');
173+
},
174+
prepareMetro: async () => {
175+
throw new Error('unexpected metro prepare call');
176+
},
177+
});
178+
179+
await assert.rejects(
180+
() =>
181+
tryRunClientBackedCommand({
182+
command: 'metro',
183+
positionals: ['prepare'],
184+
flags: {
185+
json: false,
186+
help: false,
187+
version: false,
188+
},
189+
client,
190+
}),
191+
(error) =>
192+
error instanceof AppError &&
193+
error.code === 'INVALID_ARGS' &&
194+
error.message.includes('--public-base-url <url> or --proxy-base-url <url>'),
195+
);
196+
});
197+
169198
test('screenshot forwards --overlay-refs to the client capture API', async () => {
170199
let observed:
171200
| {
@@ -426,7 +455,6 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
426455
remoteConfigPath,
427456
JSON.stringify({
428457
metroProjectRoot: './apps/demo',
429-
metroPublicBaseUrl: 'https://sandbox.example.test',
430458
metroProxyBaseUrl: 'https://proxy.example.test',
431459
tenant: 'tenant-1',
432460
runId: 'run-1',
@@ -483,7 +511,7 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
483511
assert.deepEqual(observedPrepare, {
484512
projectRoot: path.join(configDir, 'apps/demo'),
485513
kind: undefined,
486-
publicBaseUrl: 'https://sandbox.example.test',
514+
publicBaseUrl: undefined,
487515
proxyBaseUrl: 'https://proxy.example.test',
488516
bearerToken: undefined,
489517
bridgeScope: {

src/__tests__/client-metro-auto-companion.test.ts

Lines changed: 95 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -67,16 +67,17 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
6767
enabled: true,
6868
base_url: 'https://proxy.example.test',
6969
status_url: 'https://proxy.example.test/status',
70-
bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
70+
bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
7171
ios_runtime: {
72-
metro_host: '127.0.0.1',
73-
metro_port: 8081,
74-
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
72+
metro_host: 'runtime-1.metro.agent-device.dev',
73+
metro_port: 443,
74+
metro_bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
7575
},
7676
android_runtime: {
77-
metro_host: '10.0.2.2',
78-
metro_port: 8081,
79-
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android',
77+
metro_host: 'proxy.example.test',
78+
metro_port: 443,
79+
metro_bundle_url:
80+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
8081
},
8182
upstream: {
8283
bundle_url:
@@ -99,7 +100,6 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
99100
try {
100101
const result = await prepareMetroRuntime({
101102
projectRoot,
102-
publicBaseUrl: 'https://public.example.test',
103103
proxyBaseUrl: 'https://proxy.example.test',
104104
proxyBearerToken: 'shared-token',
105105
bridgeScope: TEST_BRIDGE_SCOPE,
@@ -113,11 +113,13 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
113113
assert.equal(result.bridge?.enabled, true);
114114
assert.equal(
115115
result.iosRuntime.bundleUrl,
116-
'https://proxy.example.test/index.bundle?platform=ios',
116+
'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
117117
);
118+
assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev');
119+
assert.equal(result.iosRuntime.metroPort, 443);
118120
assert.equal(
119121
result.androidRuntime.bundleUrl,
120-
'https://proxy.example.test/index.bundle?platform=android',
122+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
121123
);
122124
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 1);
123125
assert.deepEqual(vi.mocked(ensureMetroCompanion).mock.calls[0]?.[0], {
@@ -138,27 +140,91 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
138140
tenantId: 'tenant-1',
139141
runId: 'run-1',
140142
leaseId: 'lease-1',
141-
ios_runtime: {
142-
metro_bundle_url:
143-
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
144-
},
145143
timeout_ms: 10000,
146144
});
147145
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), {
148146
tenantId: 'tenant-1',
149147
runId: 'run-1',
150148
leaseId: 'lease-1',
151-
ios_runtime: {
152-
metro_bundle_url:
153-
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
154-
},
155149
timeout_ms: 10000,
156150
});
157151
} finally {
158152
fs.rmSync(tempRoot, { recursive: true, force: true });
159153
}
160154
});
161155

156+
test('prepareMetroRuntime rejects bridged descriptors without iOS bundle URLs', async () => {
157+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-descriptor-'));
158+
const projectRoot = path.join(tempRoot, 'project');
159+
fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true });
160+
fs.writeFileSync(
161+
path.join(projectRoot, 'package.json'),
162+
JSON.stringify({
163+
name: 'metro-descriptor-test',
164+
private: true,
165+
dependencies: {
166+
'react-native': '0.0.0-test',
167+
},
168+
}),
169+
);
170+
171+
const fetchMock = vi.fn();
172+
fetchMock.mockResolvedValueOnce({
173+
ok: true,
174+
status: 200,
175+
text: async () => 'packager-status:running',
176+
});
177+
fetchMock.mockResolvedValueOnce({
178+
ok: true,
179+
status: 200,
180+
text: async () =>
181+
JSON.stringify({
182+
ok: true,
183+
data: {
184+
enabled: true,
185+
base_url: 'https://proxy.example.test',
186+
status_url: 'https://proxy.example.test/status',
187+
bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
188+
ios_runtime: {},
189+
android_runtime: {
190+
metro_bundle_url:
191+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
192+
},
193+
upstream: {
194+
bundle_url:
195+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
196+
},
197+
probe: {
198+
reachable: true,
199+
status_code: 200,
200+
latency_ms: 5,
201+
detail: 'ok',
202+
},
203+
},
204+
}),
205+
});
206+
vi.stubGlobal('fetch', fetchMock);
207+
208+
try {
209+
await assert.rejects(
210+
() =>
211+
prepareMetroRuntime({
212+
projectRoot,
213+
proxyBaseUrl: 'https://proxy.example.test',
214+
proxyBearerToken: 'shared-token',
215+
bridgeScope: TEST_BRIDGE_SCOPE,
216+
metroPort: 8081,
217+
reuseExisting: true,
218+
installDependenciesIfNeeded: false,
219+
}),
220+
/bridge descriptor is missing ios_runtime\.metro_bundle_url/,
221+
);
222+
assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0);
223+
} finally {
224+
fs.rmSync(tempRoot, { recursive: true, force: true });
225+
}
226+
});
227+
162228
test('prepareMetroRuntime preserves the initial bridge error if companion startup fails', async () => {
163229
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-error-'));
164230
const projectRoot = path.join(tempRoot, 'project');
@@ -387,12 +453,17 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
387453
enabled: true,
388454
base_url: 'https://proxy.example.test',
389455
status_url: 'https://proxy.example.test/status',
390-
bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
456+
bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
391457
ios_runtime: {
392-
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=ios',
458+
metro_host: 'runtime-1.metro.agent-device.dev',
459+
metro_port: 443,
460+
metro_bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
393461
},
394462
android_runtime: {
395-
metro_bundle_url: 'https://proxy.example.test/index.bundle?platform=android',
463+
metro_host: 'proxy.example.test',
464+
metro_port: 443,
465+
metro_bundle_url:
466+
'https://proxy.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
396467
},
397468
upstream: {
398469
bundle_url:
@@ -429,8 +500,10 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
429500
assert.equal(result.bridge?.enabled, true);
430501
assert.equal(
431502
result.iosRuntime.bundleUrl,
432-
'https://proxy.example.test/index.bundle?platform=ios',
503+
'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
433504
);
505+
assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev');
506+
assert.equal(result.iosRuntime.metroPort, 443);
434507
assert.equal(fetchMock.mock.calls.length, 4);
435508
} finally {
436509
fs.rmSync(tempRoot, { recursive: true, force: true });

src/__tests__/client-metro.test.ts

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
5858
assert.equal(body.tenantId, 'tenant-1');
5959
assert.equal(body.runId, 'run-1');
6060
assert.equal(body.leaseId, 'lease-1');
61-
assert.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/);
61+
assert.equal(body.ios_runtime, undefined);
6262

6363
res.statusCode = 200;
6464
res.setHeader('content-type', 'application/json');
@@ -72,16 +72,16 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
7272
status_url: 'http://127.0.0.1:8081/status',
7373
bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false',
7474
ios_runtime: {
75-
metro_host: '127.0.0.1',
76-
metro_port: 8081,
75+
metro_host: 'runtime-1.metro.agent-device.dev',
76+
metro_port: 443,
7777
metro_bundle_url:
78-
'http://127.0.0.1:8081/index.bundle?platform=ios&dev=true&minify=false',
78+
'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios&dev=true&minify=false',
7979
},
8080
android_runtime: {
81-
metro_host: '10.0.2.2',
82-
metro_port: 8081,
81+
metro_host: 'bridge.example.test',
82+
metro_port: 443,
8383
metro_bundle_url:
84-
'http://10.0.2.2:8081/index.bundle?platform=android&dev=true&minify=false',
84+
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android&dev=true&minify=false',
8585
},
8686
upstream: {
8787
bundle_url: `http://127.0.0.1:${metroPort}/index.bundle?platform=ios&dev=true&minify=false`,
@@ -140,9 +140,10 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
140140
assert.equal(result.started, true);
141141
assert.equal(result.reused, false);
142142
assert.equal(result.bridge?.enabled, true);
143-
assert.equal(result.iosRuntime.metroHost, '127.0.0.1');
143+
assert.equal(result.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev');
144+
assert.equal(result.iosRuntime.metroPort, 443);
144145
assert.equal(result.iosRuntime.platform, 'ios');
145-
assert.equal(result.androidRuntime.metroHost, '10.0.2.2');
146+
assert.equal(result.androidRuntime.metroHost, 'bridge.example.test');
146147
assert.equal(result.androidRuntime.platform, 'android');
147148
assert.deepEqual(requests, ['/api/metro/bridge']);
148149

@@ -151,10 +152,10 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
151152
androidRuntime: { metroHost?: string; metroPort?: number; platform?: string };
152153
runtimeFilePath?: string;
153154
};
154-
assert.equal(written.iosRuntime.metroHost, '127.0.0.1');
155-
assert.equal(written.iosRuntime.metroPort, 8081);
155+
assert.equal(written.iosRuntime.metroHost, 'runtime-1.metro.agent-device.dev');
156+
assert.equal(written.iosRuntime.metroPort, 443);
156157
assert.equal(written.iosRuntime.platform, 'ios');
157-
assert.equal(written.androidRuntime.metroHost, '10.0.2.2');
158+
assert.equal(written.androidRuntime.metroHost, 'bridge.example.test');
158159
assert.equal(written.androidRuntime.platform, 'android');
159160
assert.equal(written.runtimeFilePath, runtimeFilePath);
160161
} finally {

src/__tests__/metro-protocol-public.test.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,15 @@ import {
1515
enabled: true,
1616
base_url: 'https://bridge.example.test',
1717
ios_runtime: {
18-
metro_host: '127.0.0.1',
19-
metro_port: 8081,
20-
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=ios',
18+
metro_host: 'runtime-1.metro.agent-device.dev',
19+
metro_port: 443,
20+
metro_bundle_url: 'https://runtime-1.metro.agent-device.dev/index.bundle?platform=ios',
2121
},
2222
android_runtime: {
23-
metro_host: '10.0.2.2',
24-
metro_port: 8081,
25-
metro_bundle_url: 'https://bridge.example.test/index.bundle?platform=android',
23+
metro_host: 'bridge.example.test',
24+
metro_port: 443,
25+
metro_bundle_url:
26+
'https://bridge.example.test/api/metro/runtimes/runtime-1/index.bundle?platform=android',
2627
},
2728
upstream: { bundle_url: 'http://127.0.0.1:8081/index.bundle?platform=ios' },
2829
probe: { reachable: true, status_code: 200, latency_ms: 4, detail: 'ok' },

src/cli/commands/connection-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,10 +188,10 @@ export async function prepareConnectedMetro(
188188
'Deferred Metro preparation requires platform "ios" or "android".',
189189
);
190190
}
191-
if (!flags.metroPublicBaseUrl) {
191+
if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) {
192192
throw new AppError(
193193
'INVALID_ARGS',
194-
'Deferred Metro preparation requires metroPublicBaseUrl when Metro settings are provided.',
194+
'Deferred Metro preparation requires metroPublicBaseUrl or metroProxyBaseUrl when Metro settings are provided.',
195195
);
196196
}
197197
const prepared = await client.metro.prepare({

src/cli/commands/metro.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,11 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c
77
if (action !== 'prepare') {
88
throw new AppError('INVALID_ARGS', 'metro only supports prepare');
99
}
10-
if (!flags.metroPublicBaseUrl) {
11-
throw new AppError('INVALID_ARGS', 'metro prepare requires --public-base-url <url>.');
10+
if (!flags.metroPublicBaseUrl && !flags.metroProxyBaseUrl) {
11+
throw new AppError(
12+
'INVALID_ARGS',
13+
'metro prepare requires --public-base-url <url> or --proxy-base-url <url>.',
14+
);
1215
}
1316

1417
const result = await client.metro.prepare({

0 commit comments

Comments
 (0)