Skip to content

Commit a6bc1d3

Browse files
committed
fix: include lease scope in Metro bridge calls
1 parent ac01393 commit a6bc1d3

17 files changed

+243
-4
lines changed

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,9 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
126126
metroPublicBaseUrl: 'https://sandbox.example.test',
127127
metroProxyBaseUrl: 'https://proxy.example.test',
128128
metroBearerToken: 'secret',
129+
tenant: 'tenant-1',
130+
runId: 'run-1',
131+
leaseId: 'lease-1',
129132
metroPreparePort: 9090,
130133
metroKind: 'expo',
131134
metroRuntimeFile: './.agent-device/metro-runtime.json',
@@ -143,6 +146,11 @@ test('metro prepare forwards normalized options to client.metro.prepare', async
143146
publicBaseUrl: 'https://sandbox.example.test',
144147
proxyBaseUrl: 'https://proxy.example.test',
145148
bearerToken: 'secret',
149+
bridgeScope: {
150+
tenantId: 'tenant-1',
151+
runId: 'run-1',
152+
leaseId: 'lease-1',
153+
},
146154
port: 9090,
147155
kind: 'expo',
148156
runtimeFilePath: './.agent-device/metro-runtime.json',
@@ -366,6 +374,9 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
366374
metroProjectRoot: './apps/demo',
367375
metroPublicBaseUrl: 'https://sandbox.example.test',
368376
metroProxyBaseUrl: 'https://proxy.example.test',
377+
tenant: 'tenant-1',
378+
runId: 'run-1',
379+
leaseId: 'lease-1',
369380
metroPreparePort: 9090,
370381
}),
371382
);
@@ -421,6 +432,11 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
421432
publicBaseUrl: 'https://sandbox.example.test',
422433
proxyBaseUrl: 'https://proxy.example.test',
423434
bearerToken: undefined,
435+
bridgeScope: {
436+
tenantId: 'tenant-1',
437+
runId: 'run-1',
438+
leaseId: 'lease-1',
439+
},
424440
port: 9090,
425441
listenHost: undefined,
426442
statusHost: undefined,

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ vi.mock('../client-metro-companion.ts', () => ({
1111
import { ensureMetroCompanion } from '../client-metro-companion.ts';
1212
import { prepareMetroRuntime } from '../client-metro.ts';
1313

14+
const TEST_BRIDGE_SCOPE = {
15+
tenantId: 'tenant-1',
16+
runId: 'run-1',
17+
leaseId: 'lease-1',
18+
};
19+
1420
afterEach(() => {
1521
vi.useRealTimers();
1622
vi.clearAllMocks();
@@ -96,6 +102,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
96102
publicBaseUrl: 'https://public.example.test',
97103
proxyBaseUrl: 'https://proxy.example.test',
98104
proxyBearerToken: 'shared-token',
105+
bridgeScope: TEST_BRIDGE_SCOPE,
99106
metroPort: 8081,
100107
reuseExisting: true,
101108
installDependenciesIfNeeded: false,
@@ -117,6 +124,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
117124
projectRoot,
118125
serverBaseUrl: 'https://proxy.example.test',
119126
bearerToken: 'shared-token',
127+
bridgeScope: TEST_BRIDGE_SCOPE,
120128
localBaseUrl: 'http://127.0.0.1:8081',
121129
launchUrl: undefined,
122130
profileKey: undefined,
@@ -126,6 +134,26 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee
126134
assert.equal(fetchMock.mock.calls.length, 3);
127135
assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge');
128136
assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge');
137+
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), {
138+
tenantId: 'tenant-1',
139+
runId: 'run-1',
140+
leaseId: 'lease-1',
141+
ios_runtime: {
142+
metro_bundle_url:
143+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
144+
},
145+
timeout_ms: 10000,
146+
});
147+
assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), {
148+
tenantId: 'tenant-1',
149+
runId: 'run-1',
150+
leaseId: 'lease-1',
151+
ios_runtime: {
152+
metro_bundle_url:
153+
'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false',
154+
},
155+
timeout_ms: 10000,
156+
});
129157
} finally {
130158
fs.rmSync(tempRoot, { recursive: true, force: true });
131159
}
@@ -165,6 +193,7 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu
165193
publicBaseUrl: 'https://public.example.test',
166194
proxyBaseUrl: 'https://proxy.example.test',
167195
proxyBearerToken: 'shared-token',
196+
bridgeScope: TEST_BRIDGE_SCOPE,
168197
metroPort: 8081,
169198
reuseExisting: true,
170199
installDependenciesIfNeeded: false,
@@ -231,6 +260,7 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan
231260
publicBaseUrl: 'https://public.example.test',
232261
proxyBaseUrl: 'https://proxy.example.test',
233262
proxyBearerToken: 'shared-token',
263+
bridgeScope: TEST_BRIDGE_SCOPE,
234264
metroPort: 8081,
235265
reuseExisting: true,
236266
installDependenciesIfNeeded: false,
@@ -335,6 +365,7 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com
335365
publicBaseUrl: 'https://public.example.test',
336366
proxyBaseUrl: 'https://proxy.example.test',
337367
proxyBearerToken: 'shared-token',
368+
bridgeScope: TEST_BRIDGE_SCOPE,
338369
metroPort: 8081,
339370
reuseExisting: true,
340371
installDependenciesIfNeeded: false,

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

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv
178178
const upstreamMessage = createDeferred<string>();
179179
const bridgePong = createDeferred<void>();
180180
const bridgeSocketReady = createDeferred<NodeJS.WritableStream>();
181+
const registrationBody = createDeferred<Record<string, unknown>>();
181182
const bridgeOpen = createDeferred<void>();
182183
const bridgeFrame = createDeferred<string>();
183184
const bridgeClose = createDeferred<CloseFrame>();
@@ -233,8 +234,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
233234
const bridgeServer = http.createServer((req, res) => {
234235
const url = new URL(req.url || '/', 'http://127.0.0.1');
235236
if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') {
236-
req.resume();
237+
const chunks: Buffer[] = [];
238+
req.on('data', (chunk) => {
239+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
240+
});
237241
req.on('end', () => {
242+
registrationBody.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8')));
238243
res.writeHead(200, { 'content-type': 'application/json' });
239244
res.end(
240245
JSON.stringify({
@@ -325,6 +330,9 @@ test('metro companion worker proxies websocket frames to the local upstream serv
325330
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
326331
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
327332
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`,
333+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
334+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
335+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
328336
},
329337
stdio: ['ignore', 'pipe', 'pipe'],
330338
},
@@ -349,6 +357,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv
349357
waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'),
350358
earlyExit,
351359
]);
360+
assert.deepEqual(await waitFor(registrationBody.promise, 5_000, 'companion registration'), {
361+
tenantId: 'tenant-1',
362+
runId: 'run-1',
363+
leaseId: 'lease-1',
364+
local_base_url: `http://127.0.0.1:${upstreamPort}`,
365+
});
352366
await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]);
353367
await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]);
354368
bridgeSocket.write(
@@ -467,6 +481,9 @@ test('metro companion worker reconnects after the bridge closes immediately afte
467481
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
468482
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
469483
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
484+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
485+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
486+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
470487
},
471488
stdio: ['ignore', 'pipe', 'pipe'],
472489
},
@@ -571,6 +588,9 @@ test('metro companion worker exits after its state file is removed', async () =>
571588
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`,
572589
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
573590
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`,
591+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
592+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
593+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
574594
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
575595
},
576596
stdio: ['ignore', 'pipe', 'pipe'],
@@ -615,6 +635,9 @@ test('metro companion worker exits immediately when its state file is already mi
615635
AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1',
616636
AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token',
617637
AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1',
638+
AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1',
639+
AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1',
640+
AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1',
618641
AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath,
619642
},
620643
stdio: ['ignore', 'pipe', 'pipe'],

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import {
2424
} from '../utils/process-identity.ts';
2525
import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts';
2626

27+
const TEST_BRIDGE_SCOPE = {
28+
tenantId: 'tenant-1',
29+
runId: 'run-1',
30+
leaseId: 'lease-1',
31+
};
32+
2733
afterEach(() => {
2834
vi.clearAllMocks();
2935
vi.restoreAllMocks();
@@ -59,6 +65,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
5965
serverBaseUrl: 'https://bridge.example.test',
6066
bearerToken: 'token',
6167
localBaseUrl: 'http://127.0.0.1:8081',
68+
bridgeScope: TEST_BRIDGE_SCOPE,
6269
launchUrl: 'myapp://staging',
6370
profileKey: '/tmp/staging.json',
6471
consumerKey: 'session-a',
@@ -68,6 +75,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
6875
serverBaseUrl: 'https://bridge.example.test',
6976
bearerToken: 'token',
7077
localBaseUrl: 'http://127.0.0.1:8081',
78+
bridgeScope: TEST_BRIDGE_SCOPE,
7179
launchUrl: 'myapp://staging',
7280
profileKey: '/tmp/staging.json',
7381
consumerKey: 'session-b',
@@ -77,6 +85,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => {
7785
serverBaseUrl: 'https://bridge.example.test',
7886
bearerToken: 'token',
7987
localBaseUrl: 'http://127.0.0.1:8081',
88+
bridgeScope: TEST_BRIDGE_SCOPE,
8089
launchUrl: 'myapp://prod',
8190
profileKey: '/tmp/prod.json',
8291
consumerKey: 'session-prod',
@@ -155,6 +164,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
155164
serverBaseUrl: 'https://bridge.example.test',
156165
bearerToken: 'token',
157166
localBaseUrl: 'http://127.0.0.1:8081',
167+
bridgeScope: TEST_BRIDGE_SCOPE,
158168
launchUrl: 'myapp://first',
159169
profileKey: '/tmp/profile.json',
160170
consumerKey: 'session-a',
@@ -164,6 +174,7 @@ test('launchUrl changes force a companion respawn for the same profile', async (
164174
serverBaseUrl: 'https://bridge.example.test',
165175
bearerToken: 'token',
166176
localBaseUrl: 'http://127.0.0.1:8081',
177+
bridgeScope: TEST_BRIDGE_SCOPE,
167178
launchUrl: 'myapp://second',
168179
profileKey: '/tmp/profile.json',
169180
consumerKey: 'session-a',

src/__tests__/client-metro.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,14 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
5050
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
5151
}
5252
const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as {
53+
tenantId?: string;
54+
runId?: string;
55+
leaseId?: string;
5356
ios_runtime?: { metro_bundle_url?: string };
5457
};
58+
assert.equal(body.tenantId, 'tenant-1');
59+
assert.equal(body.runId, 'run-1');
60+
assert.equal(body.leaseId, 'lease-1');
5561
assert.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/);
5662

5763
res.statusCode = 200;
@@ -114,6 +120,11 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim
114120
publicBaseUrl: `http://127.0.0.1:${metroPort}`,
115121
proxyBaseUrl: `http://127.0.0.1:${proxyPort}`,
116122
proxyBearerToken: TEST_TOKEN,
123+
bridgeScope: {
124+
tenantId: 'tenant-1',
125+
runId: 'run-1',
126+
leaseId: 'lease-1',
127+
},
117128
metroPort,
118129
reuseExisting: false,
119130
installDependenciesIfNeeded: false,
@@ -172,6 +183,19 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => {
172183
error.code === 'INVALID_ARGS' &&
173184
error.message.includes('AGENT_DEVICE_PROXY_TOKEN'),
174185
);
186+
187+
await assert.rejects(
188+
() =>
189+
prepareMetroRuntime({
190+
publicBaseUrl: 'https://sandbox.example.test',
191+
proxyBaseUrl: 'https://proxy.example.test',
192+
proxyBearerToken: TEST_TOKEN,
193+
}),
194+
(error) =>
195+
error instanceof AppError &&
196+
error.code === 'INVALID_ARGS' &&
197+
error.message.includes('tenantId, runId, and leaseId bridge scope'),
198+
);
175199
});
176200

177201
function writeFakeNpx(binDir: string): void {

src/__tests__/metro-public.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,12 @@ import {
2525
stopMetroTunnel,
2626
} from '../metro.ts';
2727

28+
const TEST_BRIDGE_SCOPE = {
29+
tenantId: 'tenant-1',
30+
runId: 'run-1',
31+
leaseId: 'lease-1',
32+
};
33+
2834
afterEach(() => {
2935
vi.clearAllMocks();
3036
vi.restoreAllMocks();
@@ -66,6 +72,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
6672
publicBaseUrl: 'https://public.example.test',
6773
proxyBaseUrl: 'https://proxy.example.test',
6874
proxyBearerToken: 'token',
75+
bridgeScope: TEST_BRIDGE_SCOPE,
6976
profileKey: '/tmp/profile.remote.json',
7077
consumerKey: 'session-a',
7178
port: 8081,
@@ -75,6 +82,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
7582
serverBaseUrl: 'https://proxy.example.test',
7683
bearerToken: 'token',
7784
localBaseUrl: 'http://127.0.0.1:8081',
85+
bridgeScope: TEST_BRIDGE_SCOPE,
7886
});
7987
await stopMetroTunnel({
8088
projectRoot: '/tmp/project',
@@ -90,6 +98,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => {
9098
publicBaseUrl: 'https://public.example.test',
9199
proxyBaseUrl: 'https://proxy.example.test',
92100
proxyBearerToken: 'token',
101+
bridgeScope: TEST_BRIDGE_SCOPE,
93102
launchUrl: undefined,
94103
companionProfileKey: '/tmp/profile.remote.json',
95104
companionConsumerKey: 'session-a',

src/__tests__/remote-connection.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,11 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a
177177
const state = readRemoteConnectionState({ stateDir, session: 'adc-android' });
178178
assert.equal(observedBackend, 'android-instance');
179179
assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath);
180+
assert.deepEqual(observedPrepare?.bridgeScope, {
181+
tenantId: 'acme',
182+
runId: 'run-123',
183+
leaseId: 'lease-1',
184+
});
180185
assert.equal(state?.leaseId, 'lease-1');
181186
assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath));
182187
assert.deepEqual(state?.daemon, {

src/cli/commands/connection.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,11 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) =>
8686
allocatedForThisCommand = true;
8787
}
8888

89-
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session);
89+
const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session, {
90+
tenantId: lease.tenantId,
91+
runId: lease.runId,
92+
leaseId: lease.leaseId,
93+
});
9094
metroCleanup = metro.cleanup;
9195
const now = new Date().toISOString();
9296
const state: RemoteConnectionState = {
@@ -243,6 +247,11 @@ async function prepareConnectedMetro(
243247
client: AgentDeviceClient,
244248
remoteConfigPath: string,
245249
session: string,
250+
bridgeScope: {
251+
tenantId: string;
252+
runId: string;
253+
leaseId: string;
254+
},
246255
): Promise<{
247256
runtime?: SessionRuntimeHints;
248257
cleanup?: NonNullable<RemoteConnectionState['metro']>;
@@ -265,6 +274,7 @@ async function prepareConnectedMetro(
265274
publicBaseUrl: flags.metroPublicBaseUrl,
266275
proxyBaseUrl: flags.metroProxyBaseUrl,
267276
bearerToken: flags.metroBearerToken,
277+
bridgeScope,
268278
launchUrl: flags.launchUrl,
269279
companionProfileKey: remoteConfigPath,
270280
companionConsumerKey: session,

src/cli/commands/metro.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,14 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c
2020
publicBaseUrl: flags.metroPublicBaseUrl,
2121
proxyBaseUrl: flags.metroProxyBaseUrl,
2222
bearerToken: flags.metroBearerToken,
23+
bridgeScope:
24+
flags.tenant && flags.runId && flags.leaseId
25+
? {
26+
tenantId: flags.tenant,
27+
runId: flags.runId,
28+
leaseId: flags.leaseId,
29+
}
30+
: undefined,
2331
startupTimeoutMs: flags.metroStartupTimeoutMs,
2432
probeTimeoutMs: flags.metroProbeTimeoutMs,
2533
reuseExisting: flags.metroNoReuseExisting ? false : undefined,

0 commit comments

Comments
 (0)