Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 13 additions & 5 deletions skills/agent-device/references/remote-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux

## Most common mistake to avoid

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.
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.

## Preferred remote flow

Expand All @@ -34,7 +34,7 @@ agent-device fill @e3 "test@example.com"
agent-device disconnect
```

`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.
`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.

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

Expand Down Expand Up @@ -63,18 +63,26 @@ Example `remote-config.json` shape:
"tenant": "acme",
"runId": "run-123",
"sessionIsolation": "tenant",
"session": "adc-android",
"platform": "android",
"metroPublicBaseUrl": "http://127.0.0.1:8081"
}
```

Optional overrides stay available for advanced cases:

```json
{
"session": "adc-android",
"leaseBackend": "android-instance",
"metroProjectRoot": ".",
"metroPublicBaseUrl": "http://127.0.0.1:8081",
"metroKind": "expo",
"metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123"
}
```

- Keep secrets in env/config managed by the operator boundary. Do not persist auth tokens in connection state.
- Omit Metro fields for non-React Native flows.
- 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.
- 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.
- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope.
- 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.
- Use a lease backend that matches the bridge target platform, for example `android-instance`, `ios-instance`, or an explicit `--lease-backend` override.
Expand Down
138 changes: 63 additions & 75 deletions src/__tests__/cli-batch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import os from 'node:os';
import path from 'node:path';
import { runCli } from '../cli.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
import { installIsolatedCliTestEnv } from './cli-test-env.ts';

class ExitSignal extends Error {
public readonly code: number;
Expand All @@ -25,6 +26,9 @@ type RunResult = {
async function runCliCapture(
argv: string[],
responder?: (req: Omit<DaemonRequest, 'token'>) => Promise<DaemonResponse>,
options?: {
env?: Record<string, string | undefined>;
},
): Promise<RunResult> {
let stdout = '';
let stderr = '';
Expand All @@ -34,9 +38,11 @@ async function runCliCapture(
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const originalStateDir = process.env.AGENT_DEVICE_STATE_DIR;
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-cli-batch-'));
process.env.AGENT_DEVICE_STATE_DIR = stateDir;
const restoreEnv = installIsolatedCliTestEnv({
...(options?.env ?? {}),
AGENT_DEVICE_STATE_DIR: stateDir,
});

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

test('batch forwards strip lock policy for nested steps when bound session uses strip mode', async () => {
const previousSession = process.env.AGENT_DEVICE_SESSION;
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
const previousLock = process.env.AGENT_DEVICE_SESSION_LOCK;
process.env.AGENT_DEVICE_SESSION = 'qa-ios';
process.env.AGENT_DEVICE_PLATFORM = 'ios';
process.env.AGENT_DEVICE_SESSION_LOCK = 'strip';

try {
const result = await runCliCapture([
const result = await runCliCapture(
[
'batch',
'--steps',
'[{"command":"snapshot","flags":{"platform":"android","serial":"emulator-5554"}}]',
'--json',
]);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.platform, 'android');
assert.equal(stepFlags.serial, 'emulator-5554');
} finally {
if (previousSession === undefined) delete process.env.AGENT_DEVICE_SESSION;
else process.env.AGENT_DEVICE_SESSION = previousSession;
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
if (previousLock === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCK;
else process.env.AGENT_DEVICE_SESSION_LOCK = previousLock;
}
],
undefined,
{
env: {
AGENT_DEVICE_SESSION: 'qa-ios',
AGENT_DEVICE_PLATFORM: 'ios',
AGENT_DEVICE_SESSION_LOCK: 'strip',
},
},
);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.platform, 'android');
assert.equal(stepFlags.serial, 'emulator-5554');
});

test('batch forwards reject lock policy for target retargeting', async () => {
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED;
process.env.AGENT_DEVICE_PLATFORM = 'ios';
process.env.AGENT_DEVICE_SESSION_LOCKED = '1';

try {
const result = await runCliCapture([
'batch',
'--steps',
'[{"command":"open","flags":{"target":"tv"}}]',
'--json',
]);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.target, 'tv');
} finally {
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED;
else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked;
}
const result = await runCliCapture(
['batch', '--steps', '[{"command":"open","flags":{"target":"tv"}}]', '--json'],
undefined,
{
env: {
AGENT_DEVICE_PLATFORM: 'ios',
AGENT_DEVICE_SESSION_LOCKED: '1',
},
},
);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'reject');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.target, 'tv');
});

test('batch session lock flags apply to nested steps without env configuration', async () => {
const previousPlatform = process.env.AGENT_DEVICE_PLATFORM;
const previousLocked = process.env.AGENT_DEVICE_SESSION_LOCKED;
process.env.AGENT_DEVICE_PLATFORM = 'ios';
process.env.AGENT_DEVICE_SESSION_LOCKED = '0';

try {
const result = await runCliCapture([
const result = await runCliCapture(
[
'batch',
'--session-lock',
'strip',
'--steps',
'[{"command":"snapshot","flags":{"target":"tv","serial":"emulator-5554"}}]',
'--json',
]);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
assert.equal(result.calls[0]?.flags?.platform, 'ios');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.platform, 'ios');
assert.equal(stepFlags.target, 'tv');
assert.equal(stepFlags.serial, 'emulator-5554');
} finally {
if (previousPlatform === undefined) delete process.env.AGENT_DEVICE_PLATFORM;
else process.env.AGENT_DEVICE_PLATFORM = previousPlatform;
if (previousLocked === undefined) delete process.env.AGENT_DEVICE_SESSION_LOCKED;
else process.env.AGENT_DEVICE_SESSION_LOCKED = previousLocked;
}
],
undefined,
{
env: {
AGENT_DEVICE_PLATFORM: 'ios',
AGENT_DEVICE_SESSION_LOCKED: '0',
},
},
);
assert.equal(result.code, null);
assert.equal(result.calls.length, 1);
assert.equal(result.calls[0]?.meta?.lockPolicy, 'strip');
assert.equal(result.calls[0]?.meta?.lockPlatform, 'ios');
assert.equal(result.calls[0]?.flags?.platform, 'ios');
const stepFlags = (result.calls[0]?.flags?.batchSteps ?? [])[0]?.flags ?? {};
assert.equal(stepFlags.platform, 'ios');
assert.equal(stepFlags.target, 'tv');
assert.equal(stepFlags.serial, 'emulator-5554');
});

test('batch step without explicit platform inherits parent platform over env default', async () => {
Expand Down
3 changes: 3 additions & 0 deletions src/__tests__/cli-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { test } from 'vitest';
import assert from 'node:assert/strict';
import { runCli } from '../cli.ts';
import type { DaemonRequest, DaemonResponse } from '../daemon-client.ts';
import { installIsolatedCliTestEnv } from './cli-test-env.ts';

class ExitSignal extends Error {
public readonly code: number;
Expand Down Expand Up @@ -31,6 +32,7 @@ async function runCliCapture(
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const restoreEnv = installIsolatedCliTestEnv();

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
Expand All @@ -55,6 +57,7 @@ async function runCliCapture(
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
restoreEnv();
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand Down
5 changes: 5 additions & 0 deletions src/__tests__/cli-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import { runCli } from '../cli.ts';
import { AppError } from '../utils/errors.ts';
import type { DaemonResponse } from '../daemon-client.ts';
import { installIsolatedCliTestEnv } from './cli-test-env.ts';

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

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
Expand Down Expand Up @@ -56,6 +58,7 @@ async function runCliCapture(argv: string[]): Promise<RunResult> {
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
restoreEnv();
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand All @@ -77,6 +80,7 @@ async function runCliCaptureWithErrorDetails(
const originalExit = process.exit;
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
const originalStderrWrite = process.stderr.write.bind(process.stderr);
const restoreEnv = installIsolatedCliTestEnv();

(process as any).exit = ((nextCode?: number) => {
throw new ExitSignal(nextCode ?? 0);
Expand All @@ -101,6 +105,7 @@ async function runCliCaptureWithErrorDetails(
if (error instanceof ExitSignal) code = error.code;
else throw error;
} finally {
restoreEnv();
process.exit = originalExit;
process.stdout.write = originalStdoutWrite;
process.stderr.write = originalStderrWrite;
Expand Down
Loading
Loading