Skip to content

Commit 2fd1f3f

Browse files
committed
fix: defer remote connect lease and metro setup
1 parent afcd375 commit 2fd1f3f

19 files changed

Lines changed: 1479 additions & 582 deletions

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

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux
1313

1414
## Most common mistake to avoid
1515

16-
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, lease, and prepared Metro runtime context.
16+
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.
1717

1818
## Preferred remote flow
1919

@@ -34,7 +34,7 @@ agent-device fill @e3 "test@example.com"
3434
agent-device disconnect
3535
```
3636

37-
`connect` resolves the remote profile, verifies daemon reachability through the normal client path, allocates or refreshes the tenant lease, prepares local Metro when the profile has Metro fields, starts the local Metro companion when the bridge needs it, and writes local non-secret connection state for later commands. `disconnect` closes the session when possible, stops the Metro companion owned by that connection, releases the lease, and removes local connection state.
37+
`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.
3838

3939
After `connect`, normal `agent-device` commands use the active remote connection. Do not repeat `--remote-config` on every command.
4040

@@ -63,18 +63,26 @@ Example `remote-config.json` shape:
6363
"tenant": "acme",
6464
"runId": "run-123",
6565
"sessionIsolation": "tenant",
66-
"session": "adc-android",
6766
"platform": "android",
67+
"metroPublicBaseUrl": "http://127.0.0.1:8081"
68+
}
69+
```
70+
71+
Optional overrides stay available for advanced cases:
72+
73+
```json
74+
{
75+
"session": "adc-android",
6876
"leaseBackend": "android-instance",
6977
"metroProjectRoot": ".",
70-
"metroPublicBaseUrl": "http://127.0.0.1:8081",
78+
"metroKind": "expo",
7179
"metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123"
7280
}
7381
```
7482

7583
- Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state.
7684
- Omit Metro fields for non-React Native flows.
77-
- Put `tenant`, `runId`, `session`, `sessionIsolation`, `platform`, and `leaseBackend` in the remote profile when possible so agents can run `agent-device connect --remote-config ./remote-config.json` without extra scope flags.
85+
- 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.
7886
- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope.
7987
- 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.
8088
- Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override.

src/__tests__/cli-batch.test.ts

Lines changed: 63 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import os from 'node:os';
55
import path from 'node:path';
66
import { runCli } from '../cli.ts';
77
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
8+
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
89

910
class ExitSignal extends Error {
1011
public readonly code: number;
@@ -25,6 +26,9 @@ type RunResult = {
2526
async function runCliCapture(
2627
argv: string[],
2728
responder?: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>,
29+
options?: {
30+
env?: Record<string, string | undefined>;
31+
},
2832
): Promise<RunResult> {
2933
let stdout = '';
3034
let stderr = '';
@@ -34,9 +38,11 @@ async function runCliCapture(
3438
const originalExit = process.exit;
3539
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3640
const originalStderrWrite = process.stderr.write.bind(process.stderr);
37-
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
3841
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-batch-'));
39-
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
42+
const restoreEnv = installIsolatedCliTestEnv({
43+
...(options?.env ?? {}),
44+
AGENT_DEVICE_STATE_DIR: stateDir,
45+
});
4046

4147
(process as any).exit = ((nextCode?: number) => {
4248
throw new ExitSignal(nextCode ?? 0);
@@ -64,8 +70,7 @@ async function runCliCapture(
6470
if (error instanceof ExitSignal) code = error.code;
6571
else throw error;
6672
} finally {
67-
if (originalStateDir === undefined) delete process.env.AGENT_DEVICE_STATE_DIR;
68-
else process.env.AGENT_DEVICE_STATE_DIR = originalStateDir;
73+
restoreEnv();
6974
fs.rmSync(stateDir, { recursive: true, force: true });
7075
process.exit = originalExit;
7176
process.stdout.write = originalStdoutWrite;
@@ -131,93 +136,76 @@ test('batch --steps-file rejects invalid JSON payload', async () => {
131136
});
132137

133138
test('batch forwards strip lock policy for nested steps when bound session uses strip mode', async () => {
134-
const previousSession = process.env.AGENT_DEVICE_SESSION;
135-
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
136-
const previousLock = process.env.AGENT_DEVICE_SESSION_LOCK;
137-
process.env.AGENT_DEVICE_SESSION = 'qa-ios';
138-
process.env.AGENT_DEVICE_PLATFORM = 'ios';
139-
process.env.AGENT_DEVICE_SESSION_LOCK = 'strip';
140-
141-
try {
142-
const result = await runCliCapture([
139+
const result = await runCliCapture(
140+
[
143141
'batch',
144142
'--steps',
145143
'[{"command":"snapshot","flags":{"platform":"android","serial":"emulator-5554"}}]',
146144
'--json',
147-
]);
148-
assert.equal(result.code, null);
149-
assert.equal(result.calls.length, 1);
150-
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
151-
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
152-
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
153-
assert.equal(stepFlags.platform, 'android');
154-
assert.equal(stepFlags.serial, 'emulator-5554');
155-
} finally {
156-
if (previousSession === undefined) delete process.env.AGENT_DEVICE_SESSION;
157-
else process.env.AGENT_DEVICE_SESSION = previousSession;
158-
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
159-
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
160-
if (previousLock === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCK;
161-
else process.env.AGENT_DEVICE_SESSION_LOCK = previousLock;
162-
}
145+
],
146+
undefined,
147+
{
148+
env: {
149+
AGENT_DEVICE_SESSION: 'qa-ios',
150+
AGENT_DEVICE_PLATFORM: 'ios',
151+
AGENT_DEVICE_SESSION_LOCK: 'strip',
152+
},
153+
},
154+
);
155+
assert.equal(result.code, null);
156+
assert.equal(result.calls.length, 1);
157+
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
158+
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
159+
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
160+
assert.equal(stepFlags.platform, 'android');
161+
assert.equal(stepFlags.serial, 'emulator-5554');
163162
});
164163

165164
test('batch forwards reject lock policy for target retargeting', async () => {
166-
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
167-
const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED;
168-
process.env.AGENT_DEVICE_PLATFORM = 'ios';
169-
process.env.AGENT_DEVICE_SESSION_LOCKED = '1';
170-
171-
try {
172-
const result = await runCliCapture([
173-
'batch',
174-
'--steps',
175-
'[{"command":"open","flags":{"target":"tv"}}]',
176-
'--json',
177-
]);
178-
assert.equal(result.code, null);
179-
assert.equal(result.calls.length, 1);
180-
assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject');
181-
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
182-
assert.equal(stepFlags.target, 'tv');
183-
} finally {
184-
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
185-
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
186-
if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED;
187-
else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked;
188-
}
165+
const result = await runCliCapture(
166+
['batch', '--steps', '[{"command":"open","flags":{"target":"tv"}}]', '--json'],
167+
undefined,
168+
{
169+
env: {
170+
AGENT_DEVICE_PLATFORM: 'ios',
171+
AGENT_DEVICE_SESSION_LOCKED: '1',
172+
},
173+
},
174+
);
175+
assert.equal(result.code, null);
176+
assert.equal(result.calls.length, 1);
177+
assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject');
178+
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
179+
assert.equal(stepFlags.target, 'tv');
189180
});
190181

191182
test('batch session lock flags apply to nested steps without env configuration', async () => {
192-
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
193-
const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED;
194-
process.env.AGENT_DEVICE_PLATFORM = 'ios';
195-
process.env.AGENT_DEVICE_SESSION_LOCKED = '0';
196-
197-
try {
198-
const result = await runCliCapture([
183+
const result = await runCliCapture(
184+
[
199185
'batch',
200186
'--session-lock',
201187
'strip',
202188
'--steps',
203189
'[{"command":"snapshot","flags":{"target":"tv","serial":"emulator-5554"}}]',
204190
'--json',
205-
]);
206-
assert.equal(result.code, null);
207-
assert.equal(result.calls.length, 1);
208-
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
209-
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
210-
assert.equal(result.calls[0]?.flags?.platform, 'ios');
211-
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
212-
assert.equal(stepFlags.platform, 'ios');
213-
assert.equal(stepFlags.target, 'tv');
214-
assert.equal(stepFlags.serial, 'emulator-5554');
215-
} finally {
216-
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
217-
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
218-
if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED;
219-
else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked;
220-
}
191+
],
192+
undefined,
193+
{
194+
env: {
195+
AGENT_DEVICE_PLATFORM: 'ios',
196+
AGENT_DEVICE_SESSION_LOCKED: '0',
197+
},
198+
},
199+
);
200+
assert.equal(result.code, null);
201+
assert.equal(result.calls.length, 1);
202+
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
203+
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
204+
assert.equal(result.calls[0]?.flags?.platform, 'ios');
205+
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
206+
assert.equal(stepFlags.platform, 'ios');
207+
assert.equal(stepFlags.target, 'tv');
208+
assert.equal(stepFlags.serial, 'emulator-5554');
221209
});
222210

223211
test('batch step without explicit platform inherits parent platform over env default', async () => {

src/__tests__/cli-clipboard.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { test } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { runCli } from '../cli.ts';
44
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
5+
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
56

67
class ExitSignal extends Error {
78
public readonly code: number;
@@ -31,6 +32,7 @@ async function runCliCapture(
3132
const originalExit = process.exit;
3233
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3334
const originalStderrWrite = process.stderr.write.bind(process.stderr);
35+
const restoreEnv = installIsolatedCliTestEnv();
3436

3537
(process as any).exit = ((nextCode?: number) => {
3638
throw new ExitSignal(nextCode ?? 0);
@@ -55,6 +57,7 @@ async function runCliCapture(
5557
if (error instanceof ExitSignal) code = error.code;
5658
else throw error;
5759
} finally {
60+
restoreEnv();
5861
process.exit = originalExit;
5962
process.stdout.write = originalStdoutWrite;
6063
process.stderr.write = originalStderrWrite;

src/__tests__/cli-close.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
33
import { runCli } from '../cli.ts';
44
import { AppError } from '../utils/errors.ts';
55
import type { DaemonResponse } from '../daemon-client.ts';
6+
import { installIsolatedCliTestEnv } from './cli-test-env.ts';
67

78
class ExitSignal extends Error {
89
public readonly code: number;
@@ -29,6 +30,7 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
2930
const originalExit = process.exit;
3031
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
3132
const originalStderrWrite = process.stderr.write.bind(process.stderr);
33+
const restoreEnv = installIsolatedCliTestEnv();
3234

3335
(process as any).exit = ((nextCode?: number) => {
3436
throw new ExitSignal(nextCode ?? 0);
@@ -56,6 +58,7 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
5658
if (error instanceof ExitSignal) code = error.code;
5759
else throw error;
5860
} finally {
61+
restoreEnv();
5962
process.exit = originalExit;
6063
process.stdout.write = originalStdoutWrite;
6164
process.stderr.write = originalStderrWrite;
@@ -77,6 +80,7 @@ async function runCliCaptureWithErrorDetails(
7780
const originalExit = process.exit;
7881
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
7982
const originalStderrWrite = process.stderr.write.bind(process.stderr);
83+
const restoreEnv = installIsolatedCliTestEnv();
8084

8185
(process as any).exit = ((nextCode?: number) => {
8286
throw new ExitSignal(nextCode ?? 0);
@@ -101,6 +105,7 @@ async function runCliCaptureWithErrorDetails(
101105
if (error instanceof ExitSignal) code = error.code;
102106
else throw error;
103107
} finally {
108+
restoreEnv();
104109
process.exit = originalExit;
105110
process.stdout.write = originalStdoutWrite;
106111
process.stderr.write = originalStderrWrite;

0 commit comments

Comments
 (0)