diff --git a/src/__tests__/cli-client-commands.test.ts b/src/__tests__/cli-client-commands.test.ts index b15ec39e..2d4ca338 100644 --- a/src/__tests__/cli-client-commands.test.ts +++ b/src/__tests__/cli-client-commands.test.ts @@ -126,6 +126,9 @@ test('metro prepare forwards normalized options to client.metro.prepare', async metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', metroBearerToken: 'secret', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', metroPreparePort: 9090, metroKind: 'expo', metroRuntimeFile: './.agent-device/metro-runtime.json', @@ -143,6 +146,11 @@ test('metro prepare forwards normalized options to client.metro.prepare', async publicBaseUrl: 'https://sandbox.example.test', proxyBaseUrl: 'https://proxy.example.test', bearerToken: 'secret', + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, port: 9090, kind: 'expo', runtimeFilePath: './.agent-device/metro-runtime.json', @@ -366,6 +374,9 @@ test('metro prepare with --remote-config loads profile defaults', async () => { metroProjectRoot: './apps/demo', metroPublicBaseUrl: 'https://sandbox.example.test', metroProxyBaseUrl: 'https://proxy.example.test', + tenant: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', metroPreparePort: 9090, }), ); @@ -421,6 +432,11 @@ test('metro prepare with --remote-config loads profile defaults', async () => { publicBaseUrl: 'https://sandbox.example.test', proxyBaseUrl: 'https://proxy.example.test', bearerToken: undefined, + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, port: 9090, listenHost: undefined, statusHost: undefined, diff --git a/src/__tests__/client-metro-auto-companion.test.ts b/src/__tests__/client-metro-auto-companion.test.ts index 266146e9..ff5809b9 100644 --- a/src/__tests__/client-metro-auto-companion.test.ts +++ b/src/__tests__/client-metro-auto-companion.test.ts @@ -11,6 +11,12 @@ vi.mock('../client-metro-companion.ts', () => ({ import { ensureMetroCompanion } from '../client-metro-companion.ts'; import { prepareMetroRuntime } from '../client-metro.ts'; +const TEST_BRIDGE_SCOPE = { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', +}; + afterEach(() => { vi.useRealTimers(); vi.clearAllMocks(); @@ -96,6 +102,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, metroPort: 8081, reuseExisting: true, installDependenciesIfNeeded: false, @@ -117,6 +124,7 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee projectRoot, serverBaseUrl: 'https://proxy.example.test', bearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, localBaseUrl: 'http://127.0.0.1:8081', launchUrl: undefined, profileKey: undefined, @@ -126,6 +134,26 @@ test('prepareMetroRuntime starts the local companion only after bridge setup nee assert.equal(fetchMock.mock.calls.length, 3); assert.equal(fetchMock.mock.calls[1]?.[0], 'https://proxy.example.test/api/metro/bridge'); assert.equal(fetchMock.mock.calls[2]?.[0], 'https://proxy.example.test/api/metro/bridge'); + assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[1]?.[1]?.body)), { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + ios_runtime: { + metro_bundle_url: + 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', + }, + timeout_ms: 10000, + }); + assert.deepEqual(JSON.parse(String(fetchMock.mock.calls[2]?.[1]?.body)), { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + ios_runtime: { + metro_bundle_url: + 'https://public.example.test/index.bundle?platform=ios&dev=true&minify=false', + }, + timeout_ms: 10000, + }); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } @@ -165,6 +193,7 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, metroPort: 8081, reuseExisting: true, installDependenciesIfNeeded: false, @@ -183,6 +212,57 @@ test('prepareMetroRuntime preserves the initial bridge error if companion startu } }); +test('prepareMetroRuntime fails fast when initial bridge failure is non-retryable', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-')); + const projectRoot = path.join(tempRoot, 'project'); + fs.mkdirSync(path.join(projectRoot, 'node_modules'), { recursive: true }); + fs.writeFileSync( + path.join(projectRoot, 'package.json'), + JSON.stringify({ + name: 'metro-initial-bridge-non-retryable-test', + private: true, + dependencies: { + 'react-native': '0.0.0-test', + }, + }), + ); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce({ + ok: true, + status: 200, + text: async () => 'packager-status:running', + }); + fetchMock.mockResolvedValueOnce({ + ok: false, + status: 401, + text: async () => JSON.stringify({ ok: false, error: 'invalid scope' }), + }); + vi.stubGlobal('fetch', fetchMock); + + try { + await assert.rejects( + () => + prepareMetroRuntime({ + projectRoot, + publicBaseUrl: 'https://public.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, + metroPort: 8081, + reuseExisting: true, + installDependenciesIfNeeded: false, + probeTimeoutMs: 10, + }), + /\/api\/metro\/bridge failed \(401\)/, + ); + assert.equal(vi.mocked(ensureMetroCompanion).mock.calls.length, 0); + assert.equal(fetchMock.mock.calls.length, 2); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + test('prepareMetroRuntime fails fast on non-retryable bridge errors after companion startup', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-metro-companion-401-')); const projectRoot = path.join(tempRoot, 'project'); @@ -231,6 +311,7 @@ test('prepareMetroRuntime fails fast on non-retryable bridge errors after compan publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, metroPort: 8081, reuseExisting: true, installDependenciesIfNeeded: false, @@ -335,6 +416,7 @@ test('prepareMetroRuntime retries malformed retryable bridge responses after com publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'shared-token', + bridgeScope: TEST_BRIDGE_SCOPE, metroPort: 8081, reuseExisting: true, installDependenciesIfNeeded: false, diff --git a/src/__tests__/client-metro-companion-worker.test.ts b/src/__tests__/client-metro-companion-worker.test.ts index abeab23e..7de9257a 100644 --- a/src/__tests__/client-metro-companion-worker.test.ts +++ b/src/__tests__/client-metro-companion-worker.test.ts @@ -178,6 +178,7 @@ test('metro companion worker proxies websocket frames to the local upstream serv const upstreamMessage = createDeferred(); const bridgePong = createDeferred(); const bridgeSocketReady = createDeferred(); + const registrationBody = createDeferred>(); const bridgeOpen = createDeferred(); const bridgeFrame = createDeferred(); const bridgeClose = createDeferred(); @@ -233,8 +234,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv const bridgeServer = http.createServer((req, res) => { const url = new URL(req.url || '/', 'http://127.0.0.1'); if (req.method === 'POST' && url.pathname === '/api/metro/companion/register') { - req.resume(); + const chunks: Buffer[] = []; + req.on('data', (chunk) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); req.on('end', () => { + registrationBody.resolve(JSON.parse(Buffer.concat(chunks).toString('utf8'))); res.writeHead(200, { 'content-type': 'application/json' }); res.end( JSON.stringify({ @@ -325,6 +330,9 @@ test('metro companion worker proxies websocket frames to the local upstream serv AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`, AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${upstreamPort}`, + AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1', }, stdio: ['ignore', 'pipe', 'pipe'], }, @@ -349,6 +357,12 @@ test('metro companion worker proxies websocket frames to the local upstream serv waitFor(bridgeSocketReady.promise, 5_000, 'bridge websocket connection'), earlyExit, ]); + assert.deepEqual(await waitFor(registrationBody.promise, 5_000, 'companion registration'), { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + local_base_url: `http://127.0.0.1:${upstreamPort}`, + }); await Promise.race([waitFor(bridgePong.promise, 5_000, 'bridge pong'), earlyExit]); await Promise.race([waitFor(bridgeOpen.promise, 5_000, 'bridge ws-open-result'), earlyExit]); bridgeSocket.write( @@ -467,6 +481,9 @@ test('metro companion worker reconnects after the bridge closes immediately afte AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`, AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`, + AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1', }, stdio: ['ignore', 'pipe', 'pipe'], }, @@ -571,6 +588,9 @@ test('metro companion worker exits after its state file is removed', async () => AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: `http://127.0.0.1:${bridgePort}`, AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: `http://127.0.0.1:${localPort}`, + AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1', AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath, }, stdio: ['ignore', 'pipe', 'pipe'], @@ -615,6 +635,9 @@ test('metro companion worker exits immediately when its state file is already mi AGENT_DEVICE_METRO_COMPANION_SERVER_BASE_URL: 'http://127.0.0.1:1', AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN: 'test-token', AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL: 'http://127.0.0.1:1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID: 'tenant-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID: 'run-1', + AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID: 'lease-1', AGENT_DEVICE_METRO_COMPANION_STATE_PATH: statePath, }, stdio: ['ignore', 'pipe', 'pipe'], diff --git a/src/__tests__/client-metro-companion.test.ts b/src/__tests__/client-metro-companion.test.ts index 7616e744..82cc89f0 100644 --- a/src/__tests__/client-metro-companion.test.ts +++ b/src/__tests__/client-metro-companion.test.ts @@ -24,6 +24,12 @@ import { } from '../utils/process-identity.ts'; import { ensureMetroCompanion, stopMetroCompanion } from '../client-metro-companion.ts'; +const TEST_BRIDGE_SCOPE = { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', +}; + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); @@ -59,6 +65,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: 'myapp://staging', profileKey: '/tmp/staging.json', consumerKey: 'session-a', @@ -68,6 +75,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: 'myapp://staging', profileKey: '/tmp/staging.json', consumerKey: 'session-b', @@ -77,6 +85,7 @@ test('companion ownership is profile-scoped and consumer-counted', async () => { serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: 'myapp://prod', profileKey: '/tmp/prod.json', consumerKey: 'session-prod', @@ -155,6 +164,7 @@ test('launchUrl changes force a companion respawn for the same profile', async ( serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: 'myapp://first', profileKey: '/tmp/profile.json', consumerKey: 'session-a', @@ -164,6 +174,7 @@ test('launchUrl changes force a companion respawn for the same profile', async ( serverBaseUrl: 'https://bridge.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: 'myapp://second', profileKey: '/tmp/profile.json', consumerKey: 'session-a', @@ -184,3 +195,54 @@ test('launchUrl changes force a companion respawn for the same profile', async ( fs.rmSync(projectRoot, { recursive: true, force: true }); } }); + +test('legacy state without bridge scope is stopped before respawn', async () => { + const projectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'agent-device-metro-companion-legacy-'), + ); + const statePath = path.join(projectRoot, '.agent-device', 'metro-companion.json'); + try { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); + fs.writeFileSync( + statePath, + `${JSON.stringify({ + pid: 555, + startTime: 'start-555', + command: `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`, + serverBaseUrl: 'https://bridge.example.test', + localBaseUrl: 'http://127.0.0.1:8081', + tokenHash: 'legacy-token-hash', + consumers: ['session-a'], + })}\n`, + ); + + vi.mocked(runCmdDetached).mockReturnValueOnce(666); + vi.mocked(isProcessAlive).mockReturnValue(true); + vi.mocked(readProcessStartTime).mockReturnValue('start-555'); + vi.mocked(readProcessCommand).mockReturnValue( + `${process.execPath} src/metro-companion.ts --agent-device-run-metro-companion`, + ); + vi.mocked(waitForProcessExit).mockResolvedValue(true); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true); + + const spawned = await ensureMetroCompanion({ + projectRoot, + serverBaseUrl: 'https://bridge.example.test', + bearerToken: 'token', + localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, + consumerKey: 'session-a', + }); + + assert.equal(spawned.spawned, true); + assert.equal(spawned.pid, 666); + assert.equal(vi.mocked(runCmdDetached).mock.calls.length, 1); + assert.deepEqual(killSpy.mock.calls[0], [555, 'SIGTERM']); + const state = JSON.parse(fs.readFileSync(spawned.statePath, 'utf8')) as { + bridgeScope?: unknown; + }; + assert.deepEqual(state.bridgeScope, TEST_BRIDGE_SCOPE); + } finally { + fs.rmSync(projectRoot, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/client-metro.test.ts b/src/__tests__/client-metro.test.ts index 7edd9649..ec1ad820 100644 --- a/src/__tests__/client-metro.test.ts +++ b/src/__tests__/client-metro.test.ts @@ -50,8 +50,14 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); } const body = JSON.parse(Buffer.concat(chunks).toString('utf8')) as { + tenantId?: string; + runId?: string; + leaseId?: string; ios_runtime?: { metro_bundle_url?: string }; }; + assert.equal(body.tenantId, 'tenant-1'); + assert.equal(body.runId, 'run-1'); + assert.equal(body.leaseId, 'lease-1'); assert.match(body.ios_runtime?.metro_bundle_url ?? '', /index\.bundle\?platform=ios/); res.statusCode = 200; @@ -114,6 +120,11 @@ test('prepareMetroRuntime starts Metro, bridges through proxy, and writes runtim publicBaseUrl: `http://127.0.0.1:${metroPort}`, proxyBaseUrl: `http://127.0.0.1:${proxyPort}`, proxyBearerToken: TEST_TOKEN, + bridgeScope: { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', + }, metroPort, reuseExisting: false, installDependenciesIfNeeded: false, @@ -172,6 +183,19 @@ test('prepareMetroRuntime rejects incomplete proxy configuration', async () => { error.code === 'INVALID_ARGS' && error.message.includes('AGENT_DEVICE_PROXY_TOKEN'), ); + + await assert.rejects( + () => + prepareMetroRuntime({ + publicBaseUrl: 'https://sandbox.example.test', + proxyBaseUrl: 'https://proxy.example.test', + proxyBearerToken: TEST_TOKEN, + }), + (error) => + error instanceof AppError && + error.code === 'INVALID_ARGS' && + error.message.includes('tenantId, runId, and leaseId bridge scope'), + ); }); function writeFakeNpx(binDir: string): void { diff --git a/src/__tests__/metro-public.test.ts b/src/__tests__/metro-public.test.ts index 55d804ec..c3c8c67d 100644 --- a/src/__tests__/metro-public.test.ts +++ b/src/__tests__/metro-public.test.ts @@ -25,6 +25,12 @@ import { stopMetroTunnel, } from '../metro.ts'; +const TEST_BRIDGE_SCOPE = { + tenantId: 'tenant-1', + runId: 'run-1', + leaseId: 'lease-1', +}; + afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); @@ -66,6 +72,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => { publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'token', + bridgeScope: TEST_BRIDGE_SCOPE, profileKey: '/tmp/profile.remote.json', consumerKey: 'session-a', port: 8081, @@ -75,6 +82,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => { serverBaseUrl: 'https://proxy.example.test', bearerToken: 'token', localBaseUrl: 'http://127.0.0.1:8081', + bridgeScope: TEST_BRIDGE_SCOPE, }); await stopMetroTunnel({ projectRoot: '/tmp/project', @@ -90,6 +98,7 @@ test('public metro helpers expose stable Node-facing wrappers', async () => { publicBaseUrl: 'https://public.example.test', proxyBaseUrl: 'https://proxy.example.test', proxyBearerToken: 'token', + bridgeScope: TEST_BRIDGE_SCOPE, launchUrl: undefined, companionProfileKey: '/tmp/profile.remote.json', companionConsumerKey: 'session-a', diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 4d0371bf..ffe5d496 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -177,6 +177,11 @@ test('connect allocates a lease, prepares Metro, and writes connection state', a const state = readRemoteConnectionState({ stateDir, session: 'adc-android' }); assert.equal(observedBackend, 'android-instance'); assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath); + assert.deepEqual(observedPrepare?.bridgeScope, { + tenantId: 'acme', + runId: 'run-123', + leaseId: 'lease-1', + }); assert.equal(state?.leaseId, 'lease-1'); assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(remoteConfigPath)); assert.deepEqual(state?.daemon, { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index ef84efa5..be03daca 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -86,7 +86,11 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => allocatedForThisCommand = true; } - const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session); + const metro = await prepareConnectedMetro(flags, client, remoteConfig.resolvedPath, session, { + tenantId: lease.tenantId, + runId: lease.runId, + leaseId: lease.leaseId, + }); metroCleanup = metro.cleanup; const now = new Date().toISOString(); const state: RemoteConnectionState = { @@ -243,6 +247,11 @@ async function prepareConnectedMetro( client: AgentDeviceClient, remoteConfigPath: string, session: string, + bridgeScope: { + tenantId: string; + runId: string; + leaseId: string; + }, ): Promise<{ runtime?: SessionRuntimeHints; cleanup?: NonNullable; @@ -265,6 +274,7 @@ async function prepareConnectedMetro( publicBaseUrl: flags.metroPublicBaseUrl, proxyBaseUrl: flags.metroProxyBaseUrl, bearerToken: flags.metroBearerToken, + bridgeScope, launchUrl: flags.launchUrl, companionProfileKey: remoteConfigPath, companionConsumerKey: session, diff --git a/src/cli/commands/metro.ts b/src/cli/commands/metro.ts index 2b2a553d..d3015400 100644 --- a/src/cli/commands/metro.ts +++ b/src/cli/commands/metro.ts @@ -20,6 +20,14 @@ export const metroCommand: ClientCommandHandler = async ({ positionals, flags, c publicBaseUrl: flags.metroPublicBaseUrl, proxyBaseUrl: flags.metroProxyBaseUrl, bearerToken: flags.metroBearerToken, + bridgeScope: + flags.tenant && flags.runId && flags.leaseId + ? { + tenantId: flags.tenant, + runId: flags.runId, + leaseId: flags.leaseId, + } + : undefined, startupTimeoutMs: flags.metroStartupTimeoutMs, probeTimeoutMs: flags.metroProbeTimeoutMs, reuseExisting: flags.metroNoReuseExisting ? false : undefined, diff --git a/src/client-metro-companion-contract.ts b/src/client-metro-companion-contract.ts index c494605d..7d51a60e 100644 --- a/src/client-metro-companion-contract.ts +++ b/src/client-metro-companion-contract.ts @@ -1,3 +1,5 @@ +import type { MetroBridgeScope } from './client-metro.ts'; + export const METRO_COMPANION_RUN_ARG = '--agent-device-run-metro-companion'; export const METRO_COMPANION_RECONNECT_DELAY_MS = 1_000; export const METRO_COMPANION_LEASE_CHECK_INTERVAL_MS = 250; @@ -8,13 +10,18 @@ export const ENV_BEARER_TOKEN = 'AGENT_DEVICE_METRO_COMPANION_BEARER_TOKEN'; export const ENV_LOCAL_BASE_URL = 'AGENT_DEVICE_METRO_COMPANION_LOCAL_BASE_URL'; export const ENV_LAUNCH_URL = 'AGENT_DEVICE_METRO_COMPANION_LAUNCH_URL'; export const ENV_STATE_PATH = 'AGENT_DEVICE_METRO_COMPANION_STATE_PATH'; +export const ENV_SCOPE_TENANT_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_TENANT_ID'; +export const ENV_SCOPE_RUN_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_RUN_ID'; +export const ENV_SCOPE_LEASE_ID = 'AGENT_DEVICE_METRO_COMPANION_SCOPE_LEASE_ID'; export type { MetroTunnelRequestMessage as MetroCompanionRequest } from './metro.ts'; +export type { MetroBridgeScope }; export type CompanionOptions = { serverBaseUrl: string; bearerToken: string; localBaseUrl: string; + bridgeScope: MetroBridgeScope; launchUrl?: string; statePath?: string; }; diff --git a/src/client-metro-companion-worker.ts b/src/client-metro-companion-worker.ts index 7239b6bb..ee5491cf 100644 --- a/src/client-metro-companion-worker.ts +++ b/src/client-metro-companion-worker.ts @@ -5,6 +5,9 @@ import { ENV_LAUNCH_URL, ENV_LOCAL_BASE_URL, ENV_SERVER_BASE_URL, + ENV_SCOPE_LEASE_ID, + ENV_SCOPE_RUN_ID, + ENV_SCOPE_TENANT_ID, ENV_STATE_PATH, METRO_COMPANION_LEASE_CHECK_INTERVAL_MS, METRO_COMPANION_RECONNECT_DELAY_MS, @@ -29,6 +32,7 @@ async function registerCompanion(options: CompanionOptions): Promise<{ wsUrl: st method: 'POST', headers: createHeaders(options.serverBaseUrl, options.bearerToken), body: JSON.stringify({ + ...options.bridgeScope, local_base_url: normalizeBaseUrl(options.localBaseUrl), ...(options.launchUrl ? { launch_url: options.launchUrl } : {}), }), @@ -315,10 +319,21 @@ function readWorkerOptions(argv: string[], env: NodeJS.ProcessEnv): CompanionOpt if (!serverBaseUrl || !bearerToken || !localBaseUrl) { throw new Error('Metro companion worker is missing required environment configuration.'); } + const tenantId = env[ENV_SCOPE_TENANT_ID]?.trim(); + const runId = env[ENV_SCOPE_RUN_ID]?.trim(); + const leaseId = env[ENV_SCOPE_LEASE_ID]?.trim(); + if (!tenantId || !runId || !leaseId) { + throw new Error('Metro companion worker is missing required bridge scope configuration.'); + } return { serverBaseUrl, bearerToken, localBaseUrl, + bridgeScope: { + tenantId, + runId, + leaseId, + }, launchUrl: env[ENV_LAUNCH_URL]?.trim() || undefined, statePath: env[ENV_STATE_PATH]?.trim() || undefined, }; diff --git a/src/client-metro-companion.ts b/src/client-metro-companion.ts index b0ec7721..d415aa77 100644 --- a/src/client-metro-companion.ts +++ b/src/client-metro-companion.ts @@ -7,9 +7,13 @@ import { ENV_LAUNCH_URL, ENV_LOCAL_BASE_URL, ENV_SERVER_BASE_URL, + ENV_SCOPE_LEASE_ID, + ENV_SCOPE_RUN_ID, + ENV_SCOPE_TENANT_ID, ENV_STATE_PATH, METRO_COMPANION_RUN_ARG, } from './client-metro-companion-contract.ts'; +import type { MetroBridgeScope } from './client-metro-companion-contract.ts'; import { normalizeBaseUrl } from './utils/url.ts'; import { runCmdDetached } from './utils/exec.ts'; import { @@ -32,6 +36,7 @@ type CompanionState = { serverBaseUrl: string; localBaseUrl: string; launchUrl?: string; + bridgeScope?: MetroBridgeScope; tokenHash: string; consumers: string[]; }; @@ -41,6 +46,7 @@ export type EnsureMetroCompanionOptions = { serverBaseUrl: string; bearerToken: string; localBaseUrl: string; + bridgeScope: MetroBridgeScope; launchUrl?: string; profileKey?: string; consumerKey?: string; @@ -68,6 +74,27 @@ function normalizeOptionalString(input: string | undefined): string | undefined return input?.trim() ? input.trim() : undefined; } +function readCompanionScope(input: unknown): MetroBridgeScope | undefined { + if (!input || typeof input !== 'object' || Array.isArray(input)) return undefined; + const record = input as Partial; + if ( + typeof record.tenantId !== 'string' || + typeof record.runId !== 'string' || + typeof record.leaseId !== 'string' + ) { + return undefined; + } + return { + tenantId: record.tenantId, + runId: record.runId, + leaseId: record.leaseId, + }; +} + +function areCompanionScopesEqual(a: MetroBridgeScope, b: MetroBridgeScope): boolean { + return a.tenantId === b.tenantId && a.runId === b.runId && a.leaseId === b.leaseId; +} + function resolveCompanionPaths( projectRoot: string, profileKey?: string, @@ -109,6 +136,7 @@ function readCompanionState(statePath: string): CompanionState | null { launchUrl: normalizeOptionalString( typeof parsed.launchUrl === 'string' ? parsed.launchUrl : undefined, ), + bridgeScope: readCompanionScope(parsed.bridgeScope), tokenHash: parsed.tokenHash, consumers, }; @@ -178,10 +206,12 @@ function shouldReuseCompanion( } const command = readProcessCommand(state.pid); if (!command || !isMetroCompanionCommand(command)) return false; + if (!state.bridgeScope) return false; return ( state.serverBaseUrl === normalizeBaseUrl(options.serverBaseUrl) && state.localBaseUrl === normalizeBaseUrl(options.localBaseUrl) && state.launchUrl === normalizeOptionalString(options.launchUrl) && + areCompanionScopesEqual(state.bridgeScope, options.bridgeScope) && state.tokenHash === hashString(options.bearerToken) ); } @@ -250,6 +280,9 @@ function buildCompanionEnv( [ENV_LOCAL_BASE_URL]: normalizeBaseUrl(options.localBaseUrl), [ENV_STATE_PATH]: resolveCompanionPaths(options.projectRoot, options.profileKey).statePath, }; + nextEnv[ENV_SCOPE_TENANT_ID] = options.bridgeScope.tenantId; + nextEnv[ENV_SCOPE_RUN_ID] = options.bridgeScope.runId; + nextEnv[ENV_SCOPE_LEASE_ID] = options.bridgeScope.leaseId; if (options.launchUrl?.trim()) { nextEnv[ENV_LAUNCH_URL] = options.launchUrl.trim(); } else { @@ -297,6 +330,7 @@ function spawnCompanionProcess( serverBaseUrl: normalizeBaseUrl(options.serverBaseUrl), localBaseUrl: normalizeBaseUrl(options.localBaseUrl), launchUrl: normalizeOptionalString(options.launchUrl), + bridgeScope: options.bridgeScope, tokenHash: hashString(options.bearerToken), consumers: [], }; diff --git a/src/client-metro.ts b/src/client-metro.ts index cd007cbf..70ec80cf 100644 --- a/src/client-metro.ts +++ b/src/client-metro.ts @@ -20,6 +20,12 @@ export type MetroPrepareKind = 'auto' | 'react-native' | 'expo'; type ResolvedMetroKind = Exclude; type EnvSource = NodeJS.ProcessEnv | Record; +export type MetroBridgeScope = { + tenantId: string; + runId: string; + leaseId: string; +}; + type PackageJsonShape = { dependencies?: Record; devDependencies?: Record; @@ -43,6 +49,7 @@ export type PrepareMetroRuntimeOptions = { publicBaseUrl?: string; proxyBaseUrl?: string; proxyBearerToken?: string; + bridgeScope?: MetroBridgeScope; launchUrl?: string; companionProfileKey?: string; companionConsumerKey?: string; @@ -74,6 +81,7 @@ export type PrepareMetroRuntimeResult = { type ProxyBridgeRequestOptions = { baseUrl: string; bearerToken: string; + scope: MetroBridgeScope; runtime: MetroBridgeRuntimePayload; timeoutMs: number; }; @@ -359,6 +367,7 @@ async function configureMetroBridge(input: ProxyBridgeRequestOptions): Promise