Skip to content

Commit 40fe5e2

Browse files
authored
fix: improve cloud remote auth UX (#452)
1 parent 5a3cf94 commit 40fe5e2

12 files changed

Lines changed: 270 additions & 14 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Open this file for remote daemon HTTP flows that let an agent running in a Linux
1515
- `agent-device disconnect --remote-config <path>`
1616
- `agent-device connection status`
1717
- `agent-device auth status`
18-
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=...` for CI/service-token automation
18+
- `AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_...` for CI/service-token automation
1919

2020
## Most common mistake to avoid
2121

@@ -42,7 +42,7 @@ agent-device fill @e3 "test@example.com"
4242
agent-device disconnect
4343
```
4444

45-
After `connect`, normal commands use the active remote connection. If cloud credentials are missing, `connect` starts login automatically in an interactive shell and stores a revocable CLI session that silently mints short-lived `adc_agent_...` command tokens. The cloud side remains responsible for token expiry, tenant/run claim checks, revocation, one-time device approval, and polling rate limits. End with `disconnect` to release the lease and stop the owned Metro companion.
45+
After `connect`, normal commands use the active remote connection. If cloud credentials are missing, `connect` starts login automatically in an interactive local shell and stores a revocable CLI session that silently mints short-lived `adc_agent_...` command tokens. Linux sandboxes, CI, and other non-interactive shells should set `AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_...` instead. The cloud side remains responsible for token expiry, tenant/run claim checks, revocation, one-time device approval, and polling rate limits. End with `disconnect` to release the lease and stop the owned Metro companion.
4646

4747
### Self-contained script flow
4848

@@ -72,7 +72,7 @@ The first command that needs a lease or Metro runtime prepares and persists it.
7272
## Behavior summary
7373

7474
- `connect` stores local non-secret connection state and defers tenant lease allocation plus Metro preparation until a later command needs them.
75-
- Commands such as `install-from-source`, `open`, `snapshot`, and `apps` allocate or refresh the lease when needed.
75+
- Commands such as `install-from-source`, `open`, `snapshot`, `devices`, and `apps` allocate or refresh the lease when needed.
7676
- `open` prepares Metro runtime hints when the remote profile has Metro fields and no compatible runtime is already saved.
7777
- `metro reload` reuses saved Metro runtime hints and asks Metro to reload connected React Native apps without restarting the native process.
7878
- `batch` also prepares Metro when any step opens an app and that step does not provide its own runtime.
@@ -126,6 +126,7 @@ Optional overrides stay available for advanced cases:
126126
- 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.
127127
- Explicit command-line flags override connected defaults. Use them intentionally when switching session, platform, target, tenant, run, or lease scope.
128128
- 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. `metroProxyBaseUrl` is the bridge origin, not a prebuilt `/api/metro/...` route.
129+
- Set `AGENT_DEVICE_CLOUD_BASE_URL` to the bridge/control-plane API origin. It does not need to be the dashboard origin; `/api-keys` on the bridge can redirect to the dashboard for service-token setup.
129130
- For cloud stock React Native iOS, use the bridge descriptor's wildcard HTTPS Metro hints directly; do not install or launch the XCTest runner just to make Metro reachable.
130131
- Android keeps using bridge-provided `/api/metro/runtimes/<runtimeId>/...` Metro routes.
131132
- `metroPublicBaseUrl` is only needed for direct/non-bridge bundle hints. Bridged profiles can omit it.

src/__tests__/cli-config.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -535,6 +535,74 @@ test('normal commands accept direct remote-config usage', async () => {
535535
fs.rmSync(root, { recursive: true, force: true });
536536
});
537537

538+
test('devices allocates a pending remote lease before listing devices', async () => {
539+
const { root, home, project } = makeTempWorkspace();
540+
const stateDir = path.join(root, 'state');
541+
const remoteConfig = path.join(project, 'agent-device.remote.json');
542+
fs.writeFileSync(
543+
remoteConfig,
544+
JSON.stringify({
545+
daemonBaseUrl: 'http://remote-mac.example.test:9124/agent-device',
546+
tenant: 'acme',
547+
runId: 'run-123',
548+
platform: 'android',
549+
}),
550+
'utf8',
551+
);
552+
553+
const connectResult = await runCliCapture(
554+
['connect', '--remote-config', remoteConfig, '--state-dir', stateDir, '--json'],
555+
{ cwd: project, env: { HOME: home } },
556+
);
557+
assert.equal(connectResult.code, null);
558+
559+
const result = await runCliCapture(['devices', '--state-dir', stateDir, '--json'], {
560+
cwd: project,
561+
env: { HOME: home },
562+
sendToDaemon: async (req) => {
563+
if (req.command === 'lease_allocate') {
564+
return {
565+
ok: true,
566+
data: {
567+
lease: {
568+
leaseId: 'lease-devices-001',
569+
tenantId: 'acme',
570+
runId: 'run-123',
571+
backend: 'android-instance',
572+
},
573+
},
574+
};
575+
}
576+
if (req.command === 'devices') {
577+
return {
578+
ok: true,
579+
data: {
580+
devices: [
581+
{
582+
id: 'emulator-5554',
583+
name: 'Pixel 8',
584+
platform: 'android',
585+
kind: 'emulator',
586+
booted: true,
587+
},
588+
],
589+
},
590+
};
591+
}
592+
throw new Error(`unexpected daemon command: ${req.command}`);
593+
},
594+
});
595+
596+
assert.equal(result.code, null);
597+
assert.equal(result.calls.length, 2);
598+
assert.equal(result.calls[0]?.command, 'lease_allocate');
599+
assert.equal(result.calls[1]?.command, 'devices');
600+
assert.equal(result.calls[1]?.flags?.leaseId, 'lease-devices-001');
601+
assert.equal(result.calls[1]?.meta?.leaseId, 'lease-devices-001');
602+
603+
fs.rmSync(root, { recursive: true, force: true });
604+
});
605+
538606
test('direct remote-config command does not fall back to unrelated active session', async () => {
539607
const { root, home, project } = makeTempWorkspace();
540608
const stateDir = path.join(root, 'state');

src/__tests__/cli-help.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ test('appstate --help prints command help and skips daemon dispatch', async () =
3434
assert.match(result.stdout, /Global flags:/);
3535
});
3636

37+
test('connect help documents cloud auth environment origins', async () => {
38+
const result = await runCliCapture(['help', 'connect']);
39+
assert.equal(result.code, 0);
40+
assert.equal(result.calls.length, 0);
41+
assert.match(result.stdout, /AGENT_DEVICE_CLOUD_BASE_URL/);
42+
assert.match(result.stdout, /bridge\/control-plane API origin/);
43+
assert.match(result.stdout, /AGENT_DEVICE_DAEMON_AUTH_TOKEN/);
44+
});
45+
3746
test('help react-devtools prints passthrough command help and skips daemon dispatch', async () => {
3847
const result = await runCliCapture(['help', 'react-devtools']);
3948
assert.equal(result.code, 0);

src/__tests__/remote-connection.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ test('connect reports deferred Metro runtime preparation when remote config has
189189
});
190190
});
191191

192+
assert.match(stdout, /Lease allocation is pending/);
193+
assert.match(stdout, /open, snapshot, or devices/);
192194
assert.match(stdout, /Metro runtime is not prepared yet/);
193195
assert.match(stdout, /metro prepare --remote-config/);
194196
assert.equal(readActiveConnectionState({ stateDir })?.runtime, undefined);
@@ -677,6 +679,66 @@ test('deferred materialization heartbeats an existing lease before dispatch', as
677679
fs.rmSync(tempRoot, { recursive: true, force: true });
678680
});
679681

682+
test('deferred materialization allocates pending lease for devices', async () => {
683+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-devices-'));
684+
const stateDir = path.join(tempRoot, '.state');
685+
const remoteConfigPath = path.join(tempRoot, 'remote.json');
686+
fs.writeFileSync(remoteConfigPath, JSON.stringify({ daemonBaseUrl: 'https://daemon.example' }));
687+
writeRemoteConnectionState({
688+
stateDir,
689+
state: {
690+
version: 1,
691+
session: 'adc-android',
692+
remoteConfigPath,
693+
remoteConfigHash: hashRemoteConfigFile(remoteConfigPath),
694+
daemon: { baseUrl: 'https://daemon.example' },
695+
tenant: 'acme',
696+
runId: 'run-123',
697+
platform: 'android',
698+
connectedAt: new Date().toISOString(),
699+
updatedAt: new Date().toISOString(),
700+
},
701+
});
702+
let allocateCount = 0;
703+
704+
const materialized = await materializeRemoteConnectionForCommand({
705+
command: 'devices',
706+
flags: {
707+
json: true,
708+
help: false,
709+
version: false,
710+
stateDir,
711+
remoteConfig: remoteConfigPath,
712+
daemonBaseUrl: 'https://daemon.example',
713+
tenant: 'acme',
714+
runId: 'run-123',
715+
session: 'adc-android',
716+
platform: 'android',
717+
},
718+
client: createTestClient({
719+
allocate: async (request) => {
720+
allocateCount += 1;
721+
return {
722+
leaseId: 'lease-devices',
723+
tenantId: request.tenant,
724+
runId: request.runId,
725+
backend: request.leaseBackend ?? 'android-instance',
726+
};
727+
},
728+
}),
729+
});
730+
731+
assert.equal(allocateCount, 1);
732+
assert.equal(materialized.flags.leaseId, 'lease-devices');
733+
assert.equal(materialized.flags.leaseBackend, 'android-instance');
734+
assert.equal(
735+
readRemoteConnectionState({ stateDir, session: 'adc-android' })?.leaseId,
736+
'lease-devices',
737+
);
738+
739+
fs.rmSync(tempRoot, { recursive: true, force: true });
740+
});
741+
680742
test('deferred materialization reallocates when the persisted lease is inactive', async () => {
681743
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-stale-lease-'));
682744
const stateDir = path.join(tempRoot, '.state');

src/cli.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ const REMOTE_MATERIALIZATION_DEFERRED_COMMANDS = new Set([
6060
'connect',
6161
'connection',
6262
'close',
63-
'devices',
6463
'disconnect',
6564
'ensure-simulator',
6665
'metro',

src/cli/__tests__/auth-session.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
summarizeCliSession,
1313
writeCliSession,
1414
} from '../auth-session.ts';
15+
import { normalizeError } from '../../utils/errors.ts';
1516

1617
const baseFlags = {
1718
json: false,
@@ -63,6 +64,31 @@ test('remote auth fails in CI with service token instructions', async () => {
6364
fs.rmSync(tempRoot, { recursive: true, force: true });
6465
});
6566

67+
test('non-interactive auth hint preserves safe API-token setup URL', async () => {
68+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-auth-url-hint-'));
69+
70+
try {
71+
await resolveRemoteAuth({
72+
command: 'connect',
73+
flags: { ...baseFlags, daemonBaseUrl: 'https://bridge.agent-device.dev' },
74+
stateDir: tempRoot,
75+
allowInteractiveLogin: true,
76+
env: {
77+
CI: 'true',
78+
AGENT_DEVICE_CLOUD_BASE_URL: 'https://bridge.agent-device.dev',
79+
},
80+
io: { stdinIsTTY: true, stdoutIsTTY: true },
81+
});
82+
assert.fail('expected non-interactive auth to fail');
83+
} catch (error) {
84+
const normalized = normalizeError(error);
85+
assert.match(normalized.hint ?? '', /https:\/\/bridge\.agent-device\.dev\/api-keys/);
86+
assert.doesNotMatch(normalized.hint ?? '', /\[REDACTED\]/);
87+
} finally {
88+
fs.rmSync(tempRoot, { recursive: true, force: true });
89+
}
90+
});
91+
6692
test('remote auth leaves non-cloud remote daemons to existing daemon auth validation', async () => {
6793
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-auth-non-cloud-'));
6894
writeCliSession({

src/cli/commands/connection-runtime.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ const leaseDeferredCommands = new Set([
2121
'connect',
2222
'connection',
2323
'close',
24-
'devices',
2524
'disconnect',
2625
'ensure-simulator',
2726
'metro',

src/cli/commands/connection.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,15 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) =>
108108
await stopReactDevtoolsCleanup({ stateDir, state: previous });
109109
await releasePreviousLease(client, previous);
110110
}
111+
const leasePreparation = buildLeasePreparationNotice(state);
111112
const runtimePreparation = buildRuntimePreparationNotice(flags, state);
112113

113114
writeCommandOutput(flags, serializeConnectionState(state, runtimePreparation), () =>
114115
[
115116
`Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${
116117
state.leaseId ? `lease ${state.leaseId}` : 'lease pending'
117118
}`,
119+
leasePreparation?.message,
118120
runtimePreparation?.message,
119121
]
120122
.filter((line): line is string => Boolean(line))
@@ -185,13 +187,15 @@ export const connectionCommand: ClientCommandHandler = async ({ positionals, fla
185187
);
186188
return true;
187189
}
190+
const leasePreparation = buildLeasePreparationNotice(state);
188191
const runtimePreparation = buildRuntimePreparationNoticeFromState(state);
189192
writeCommandOutput(flags, serializeConnectionState(state, runtimePreparation), () =>
190193
[
191194
`Connected remote session "${state.session}".`,
192195
`tenant=${state.tenant} runId=${state.runId} leaseId=${state.leaseId ?? 'pending'} backend=${state.leaseBackend ?? 'pending'}`,
193196
`remoteConfig=${state.remoteConfigPath}`,
194197
state.runtime ? 'metro=prepared' : 'metro=not-prepared',
198+
leasePreparation?.message,
195199
runtimePreparation?.message,
196200
]
197201
.filter((line): line is string => Boolean(line))
@@ -256,6 +260,12 @@ type RuntimePreparationNotice = {
256260
nextStep: string;
257261
};
258262

263+
type LeasePreparationNotice = {
264+
status: 'deferred';
265+
message: string;
266+
nextSteps: string[];
267+
};
268+
259269
function buildRuntimePreparationNotice(
260270
flags: CliFlags,
261271
state: RemoteConnectionState,
@@ -274,6 +284,29 @@ function buildRuntimePreparationNoticeFromState(
274284
return buildDeferredRuntimeNotice(state.remoteConfigPath);
275285
}
276286

287+
function buildLeasePreparationNotice(
288+
state: RemoteConnectionState,
289+
): LeasePreparationNotice | undefined {
290+
if (state.leaseId) return undefined;
291+
const needsPlatform =
292+
state.platform === undefined && state.leaseBackend === undefined
293+
? ' Add --platform ios|android if the profile does not set a platform.'
294+
: '';
295+
const nextSteps = [
296+
'agent-device install-from-source <artifact-url> --platform ios|android',
297+
'agent-device open <app-id> --relaunch',
298+
'agent-device snapshot -i',
299+
'agent-device devices',
300+
];
301+
return {
302+
status: 'deferred',
303+
nextSteps,
304+
message:
305+
'Lease allocation is pending; run install-from-source, open, snapshot, or devices when ready to allocate or refresh the lease.' +
306+
needsPlatform,
307+
};
308+
}
309+
277310
function buildDeferredRuntimeNotice(remoteConfigPath: string): RuntimePreparationNotice {
278311
const nextStep = `agent-device metro prepare --remote-config ${remoteConfigPath}`;
279312
return {
@@ -308,6 +341,7 @@ function serializeConnectionState(
308341
state: RemoteConnectionState,
309342
runtimePreparation?: RuntimePreparationNotice,
310343
): Record<string, unknown> {
344+
const leasePreparation = buildLeasePreparationNotice(state);
311345
return {
312346
connected: true,
313347
session: state.session,
@@ -324,6 +358,7 @@ function serializeConnectionState(
324358
metro: state.metro
325359
? { prepared: true, projectRoot: state.metro.projectRoot }
326360
: { prepared: false },
361+
...(leasePreparation ? { leasePreparation } : {}),
327362
...(runtimePreparation ? { runtimePreparation } : {}),
328363
connectedAt: state.connectedAt,
329364
updatedAt: state.updatedAt,

src/utils/__tests__/diagnostics.test.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ test('diagnostics redacts sensitive fields', async () => {
6363
data: {
6464
token: 'secret-token',
6565
text: 'sensitive text',
66+
responseText:
67+
'access_token=oauth-access refresh_token:oauth-refresh password=https://secret.example/token',
68+
setupHint: 'Create a service/API token: https://bridge.agent-device.dev/api-keys',
6669
nested: { authorization: 'Bearer abc' },
6770
agentToken: 'adc_agent_secret',
6871
deviceUrl: 'https://cloud.agent-device.dev/device?user_code=ABCD-EFGH',
@@ -83,9 +86,17 @@ test('diagnostics redacts sensitive fields', async () => {
8386
const payload = rows[0]?.data ?? {};
8487
assert.equal(payload.token, '[REDACTED]');
8588
assert.equal(payload.text, 'sensitive text');
89+
assert.equal(
90+
payload.responseText,
91+
'access_token=[REDACTED] refresh_token:[REDACTED] password=[REDACTED]',
92+
);
93+
assert.equal(
94+
payload.setupHint,
95+
'Create a service/API token: https://bridge.agent-device.dev/api-keys',
96+
);
8697
assert.equal(payload.nested?.authorization, '[REDACTED]');
8798
assert.equal(payload.agentToken, '[REDACTED]');
88-
assert.equal(payload.deviceUrl, '[REDACTED]');
99+
assert.equal(payload.deviceUrl, 'https://cloud.agent-device.dev/device?REDACTED');
89100
assert.equal(payload.userCode, '[REDACTED]');
90101
assert.equal(payload.safe, 'ok');
91102
} finally {

0 commit comments

Comments
 (0)