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
2 changes: 1 addition & 1 deletion skills/agent-device/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@ Use this skill as a router with mandatory defaults. Read this file first. For no
- Need logs, network, alerts, permissions, or failure triage: [references/debugging.md](references/debugging.md)
- Need screenshots, diff, recording, replay maintenance, or perf data: [references/verification.md](references/verification.md)
- Need desktop surfaces, menu bar behavior, or macOS-specific interaction rules: [references/macos-desktop.md](references/macos-desktop.md)
- Need remote HTTP transport, `--remote-config` launches, or tenant leases on a remote macOS host: [references/remote-tenancy.md](references/remote-tenancy.md)
- Need remote HTTP transport, `connect --remote-config`, or tenant leases on a remote macOS host: [references/remote-tenancy.md](references/remote-tenancy.md)
This includes remote React Native runs where `agent-device` now prepares Metro locally and manages the local Metro companion tunnel automatically.
118 changes: 60 additions & 58 deletions skills/agent-device/references/remote-tenancy.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,95 +2,97 @@

## When to open this file

Open this file for remote daemon HTTP flows, including `--remote-config` launches, that let an agent running in a Linux sandbox talk to another `agent-device` instance on a remote macOS host in order to control devices that are not available locally. This file covers daemon URL setup, authentication, lease allocation, and tenant-scoped command admission.
Open this file for remote daemon HTTP flows that let an agent running in a Linux sandbox talk to another `agent-device` instance on a remote macOS host in order to control devices that are not available locally. This file covers daemon URL setup, authentication, `connect`, tenant lease scope, and remote Metro companion lifecycle.

## Main commands to reach for first

- `agent-device open <app> --remote-config <path> --relaunch`
- `AGENT_DEVICE_DAEMON_BASE_URL=...`
- `agent-device connect --remote-config <path>`
- `agent-device connection status`
- `agent-device disconnect`
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...`
- `agent-device --tenant ... --session-isolation tenant --run-id ... --lease-id ...`

## Most common mistake to avoid

Do not run a tenant-isolated command without matching `tenant`, `run`, and `lease` scope. Admission checks require all three to line up.
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.

## Preferred remote launch path
## Preferred remote flow

Use this when the agent needs the simplest remote control flow: a Linux sandbox agent talks over HTTP to `agent-device` on a remote macOS host and launches the target app through a checked-in `--remote-config` profile.

```bash
agent-device open com.example.myapp --remote-config ./agent-device.remote.json --relaunch
export AGENT_DEVICE_DAEMON_AUTH_TOKEN="YOUR_TOKEN"
export AGENT_DEVICE_PROXY_TOKEN="$AGENT_DEVICE_DAEMON_AUTH_TOKEN"

agent-device connect \
--remote-config ./remote-config.json

agent-device install com.example.app ./app.apk
agent-device open com.example.app --relaunch
agent-device snapshot -i
agent-device fill @e3 "test@example.com"
agent-device disconnect
```

- This is the preferred remote launch path for sandbox or cloud agents.
- `agent-device` prepares local Metro and auto-starts the local Metro companion tunnel when the remote bridge needs a path back to the developer machine.
- `close --remote-config ...` cleans up the managed companion process for that project/profile, but leaves the developer’s Metro server running.
- For Android React Native relaunch flows, install or reinstall the APK first, then relaunch by installed package name.
- Do not use `open <apk|aab> --relaunch`; remote runtime hints are applied through the installed app sandbox.
`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.

## Lease flow example
Use `agent-device connection status --session adc-android` to inspect the active connection without reading JSON state manually. Status output must not include auth tokens.

```bash
export AGENT_DEVICE_DAEMON_BASE_URL=<trusted-daemon-base-url>
export AGENT_DEVICE_DAEMON_AUTH_TOKEN=<token>

agent-device \
--tenant acme \
--session-isolation tenant \
--run-id run-123 \
--lease-id <lease-id> \
session list --json
```
## Remote config shape

Low-level lease operations exist for host-side automation, but do not point them at arbitrary hosts. The remote daemon executes device-control commands, so only use a trusted daemon base URL and an auth token managed by the same operator boundary.
Example `remote-config.json` shape:

Lease lifecycle methods exposed by the daemon:
```json
{
"daemonBaseUrl": "https://bridge.example.com/agent-device",
"daemonTransport": "http",
"tenant": "acme",
"runId": "run-123",
"sessionIsolation": "tenant",
"session": "adc-android",
"platform": "android",
"leaseBackend": "android-instance",
"metroProjectRoot": ".",
"metroPublicBaseUrl": "http://127.0.0.1:8081",
"metroProxyBaseUrl": "https://bridge.example.com/metro/acme/run-123"
}
```

- `agent_device.lease.allocate`
- `agent_device.lease.heartbeat`
- `agent_device.lease.release`
- `agent_device.command`
- 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.
- 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.

## Transport prerequisites

- Start the daemon in HTTP mode with `AGENT_DEVICE_DAEMON_SERVER_MODE=http|dual`.
- Point the client at the remote host with `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]`.
- Start the daemon in HTTP mode with `AGENT_DEVICE_DAEMON_SERVER_MODE=http|dual` on the host.
- Point the profile or env at the remote host with `daemonBaseUrl` or `AGENT_DEVICE_DAEMON_BASE_URL=http(s)://host:port[/base-path]`.
- For non-loopback remote hosts, set `AGENT_DEVICE_DAEMON_AUTH_TOKEN` or `--daemon-auth-token`. The client rejects non-loopback remote daemon URLs without auth.
- Direct JSON-RPC callers can authenticate with request params, `Authorization: Bearer <token>`, or `x-agent-device-token`.
- Prefer an auth hook such as `AGENT_DEVICE_HTTP_AUTH_HOOK` when the host needs caller validation or tenant injection.

## Lease lifecycle

Use JSON-RPC methods on `POST /rpc`:

- `agent_device.lease.allocate`
- `agent_device.lease.heartbeat`
- `agent_device.lease.release`

Keep the lease alive for the duration of the run and release it when the tenant-scoped work is complete.

Host-level lease knobs:

- `AGENT_DEVICE_MAX_SIMULATOR_LEASES`
- `AGENT_DEVICE_LEASE_TTL_MS`
- `AGENT_DEVICE_LEASE_MIN_TTL_MS`
- `AGENT_DEVICE_LEASE_MAX_TTL_MS`

## Command admission contract
## Manual lease debug fallback

For tenant-isolated command execution, pass all four CLI flags together:
The main agent flow should use `connect`. Use manual JSON-RPC only for host-side automation or daemon-side auth/scope debugging, and only against trusted daemon hosts.

```bash
agent-device \
--tenant acme \
--session-isolation tenant \
--run-id run-123 \
--lease-id <lease-id> \
session list --json
curl -fsS "$AGENT_DEVICE_DAEMON_BASE_URL/rpc" \
-H "Authorization: Bearer $AGENT_DEVICE_DAEMON_AUTH_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": "lease-1",
"method": "agent_device.lease.allocate",
"params": {
"tenantId": "acme",
"runId": "run-123",
"backend": "android-instance"
}
}'
```

The CLI sends `AGENT_DEVICE_DAEMON_AUTH_TOKEN` in both the JSON-RPC request token field and HTTP auth headers so existing daemon auth paths continue to work.
Related daemon methods are `agent_device.lease.allocate`, `agent_device.lease.heartbeat`, `agent_device.lease.release`, and `agent_device.command`.

## Failure semantics and trust notes

Expand Down
222 changes: 15 additions & 207 deletions src/__tests__/cli-client-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,213 +433,6 @@ test('metro prepare with --remote-config loads profile defaults', async () => {
assert.equal(payload.kind, 'react-native');
});

test('open with --remote-config prepares Metro and forwards inline runtime hints', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-'));
const configDir = path.join(tmpRoot, 'config');
fs.mkdirSync(configDir, { recursive: true });
const remoteConfigPath = path.join(configDir, 'remote.json');
fs.writeFileSync(
remoteConfigPath,
JSON.stringify({
platform: 'android',
metroProjectRoot: './apps/demo',
metroRuntimeFile: './.agent-device-cloud/metro-runtime.json',
metroPublicBaseUrl: 'https://sandbox.example.test',
metroProxyBaseUrl: 'https://proxy.example.test',
metroPreparePort: 9090,
}),
);
const parsed = resolveCliOptions(
['open', 'com.example.app', '--remote-config', remoteConfigPath],
{
cwd: tmpRoot,
env: process.env,
},
);

let observedPrepare: MetroPrepareOptions | undefined;
let observedOpen: AppOpenOptions | undefined;
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
prepareMetro: async (options) => {
observedPrepare = options;
return {
projectRoot: '/tmp/project',
kind: 'react-native',
dependenciesInstalled: false,
packageManager: null,
started: false,
reused: true,
pid: 0,
logPath: '/tmp/project/.agent-device/metro.log',
statusUrl: 'http://127.0.0.1:8081/status',
runtimeFilePath: null,
iosRuntime: {
platform: 'ios',
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
},
androidRuntime: {
platform: 'android',
metroHost: '10.0.2.2',
metroPort: 9090,
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
launchUrl: 'myapp://dev',
},
bridge: null,
};
},
open: async (options) => {
observedOpen = options;
return {
session: options.session ?? 'default',
runtime: options.runtime,
identifiers: { session: options.session ?? 'default' },
};
},
});

const handled = await tryRunClientBackedCommand({
command: 'open',
positionals: ['com.example.app'],
flags: { ...parsed.flags, relaunch: true },
client,
});

assert.equal(handled, true);
assert.deepEqual(observedPrepare, {
projectRoot: path.join(configDir, 'apps/demo'),
kind: undefined,
publicBaseUrl: 'https://sandbox.example.test',
proxyBaseUrl: 'https://proxy.example.test',
bearerToken: undefined,
launchUrl: undefined,
companionProfileKey: remoteConfigPath,
companionConsumerKey: undefined,
port: 9090,
listenHost: undefined,
statusHost: undefined,
startupTimeoutMs: undefined,
probeTimeoutMs: undefined,
reuseExisting: undefined,
installDependenciesIfNeeded: undefined,
runtimeFilePath: path.join(configDir, '.agent-device-cloud/metro-runtime.json'),
});
assert.deepEqual(observedOpen?.runtime, {
platform: 'android',
metroHost: '10.0.2.2',
metroPort: 9090,
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
launchUrl: 'myapp://dev',
});
});

test('open with --remote-config does not reload the profile after CLI parsing', async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-path-'));
const configDir = path.join(tmpRoot, 'config');
fs.mkdirSync(configDir, { recursive: true });
const remoteConfigPath = path.join(configDir, 'remote.json');
fs.writeFileSync(
remoteConfigPath,
JSON.stringify({
platform: 'android',
metroProjectRoot: './apps/demo',
metroPublicBaseUrl: 'https://sandbox.example.test',
}),
);

const parsed = resolveCliOptions(
['open', 'com.example.app', '--remote-config', remoteConfigPath],
{
cwd: tmpRoot,
env: process.env,
},
);
fs.unlinkSync(remoteConfigPath);

let observedPrepare: MetroPrepareOptions | undefined;
const client = createStubClient({
installFromSource: async () => {
throw new Error('unexpected install call');
},
prepareMetro: async (options) => {
observedPrepare = options;
return {
projectRoot: '/tmp/project',
kind: 'react-native',
dependenciesInstalled: false,
packageManager: null,
started: false,
reused: true,
pid: 0,
logPath: '/tmp/project/.agent-device/metro.log',
statusUrl: 'http://127.0.0.1:8081/status',
runtimeFilePath: null,
iosRuntime: {
platform: 'ios',
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=ios',
},
androidRuntime: {
platform: 'android',
bundleUrl: 'https://sandbox.example.test/index.bundle?platform=android',
},
bridge: null,
};
},
});

const handled = await tryRunClientBackedCommand({
command: 'open',
positionals: ['com.example.app'],
flags: parsed.flags,
client,
});

assert.equal(handled, true);
assert.equal(observedPrepare?.companionProfileKey, remoteConfigPath);
});

test('open with --remote-config preserves CLI overrides over profile defaults', () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-remote-open-override-'));
const configDir = path.join(tmpRoot, 'config');
fs.mkdirSync(configDir, { recursive: true });
const remoteConfigPath = path.join(configDir, 'remote.json');
fs.writeFileSync(
remoteConfigPath,
JSON.stringify({
session: 'remote-session',
platform: 'android',
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
metroPublicBaseUrl: 'https://sandbox.example.test',
}),
);

const parsed = resolveCliOptions(
[
'open',
'com.example.app',
'--remote-config',
remoteConfigPath,
'--session',
'cli-session',
'--platform',
'ios',
'--daemon-base-url',
'http://cli-mac.example.test:9124/agent-device',
],
{
cwd: tmpRoot,
env: process.env,
},
);

assert.equal(parsed.flags.session, 'cli-session');
assert.equal(parsed.flags.platform, 'ios');
assert.equal(parsed.flags.daemonBaseUrl, 'http://cli-mac.example.test:9124/agent-device');
assert.equal(parsed.flags.metroPublicBaseUrl, 'https://sandbox.example.test');
});

test('install prints command-owned success output in human mode', async () => {
const client = createStubClient({
installFromSource: async () => {
Expand Down Expand Up @@ -750,6 +543,21 @@ function createStubClient(params: {
identifiers: { session: options.session ?? 'default' },
}),
},
leases: {
allocate: async (options) => ({
leaseId: 'lease-1',
tenantId: options.tenant,
runId: options.runId,
backend: options.leaseBackend ?? 'ios-simulator',
}),
heartbeat: async (options) => ({
leaseId: options.leaseId,
tenantId: options.tenant ?? 'tenant',
runId: options.runId ?? 'run',
backend: options.leaseBackend ?? 'ios-simulator',
}),
release: async () => ({ released: true }),
},
metro: {
prepare:
params.prepareMetro ??
Expand Down
Loading
Loading