From 22f402bf85ec0e77552884a3b4b6a865b9239147 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 15:14:18 -0400 Subject: [PATCH 1/7] feat: discover cloud config during connect --- src/__tests__/remote-connection.test.ts | 139 +++++++++++++++++++ src/cli/auth-session.ts | 67 +++++++++- src/cli/cloud-connection-profile.ts | 171 ++++++++++++++++++++++++ src/cli/commands/connection-runtime.ts | 13 +- src/cli/commands/connection.ts | 97 +++++++++----- src/utils/command-schema.ts | 2 +- src/utils/remote-config.ts | 2 +- 7 files changed, 443 insertions(+), 48 deletions(-) create mode 100644 src/cli/cloud-connection-profile.ts diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index 0ae3f4415..bd0e6c330 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -12,12 +12,17 @@ vi.mock('../client-react-devtools-companion.ts', () => ({ stopReactDevtoolsCompanion: vi.fn(), })); +vi.mock('../cli/auth-session.ts', () => ({ + resolveCloudAccessForConnect: vi.fn(), +})); + import { connectCommand, connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; +import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; import { @@ -31,8 +36,11 @@ import type { AgentDeviceClient } from '../client.ts'; afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); }); +const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); + const unexpectedCommandCall = async (): Promise => { throw new Error('unexpected call'); }; @@ -158,6 +166,137 @@ test('connect auto-generates a local session and writes minimal remote state', a fs.rmSync(tempRoot, { recursive: true, force: true }); }); +test('connect without remote config generates one from cloud connection profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-')); + const stateDir = path.join(tempRoot, '.state'); + mockedResolveCloudAccessForConnect.mockResolvedValue({ + accessToken: 'adc_agent_cloud', + cloudBaseUrl: 'https://cloud.example', + source: 'login', + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + ok: true, + connection: { + version: 1, + remoteConfigProfile: { + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + sessionIsolation: 'tenant', + metroKind: 'auto', + metroPublicBaseUrl: 'http://127.0.0.1:8081', + metroProxyBaseUrl: 'https://bridge.example.com', + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + + const connectOnce = async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: createTestClient(), + }); + }; + + await captureStdout(connectOnce); + await captureStdout(connectOnce); + + const state = readActiveConnectionState({ stateDir }); + assert.equal(state?.tenant, 'acme'); + assert.equal(state?.runId, 'demo-run-001'); + assert.equal(state?.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); + assert.match( + state?.remoteConfigPath ?? '', + /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/, + ); + assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(state?.remoteConfigPath ?? '')); + const generatedConfig = JSON.parse(fs.readFileSync(state?.remoteConfigPath ?? '', 'utf8')) as { + tenant?: string; + metroProxyBaseUrl?: string; + }; + assert.deepEqual(Object.keys(generatedConfig), [ + 'daemonBaseUrl', + 'daemonTransport', + 'metroKind', + 'metroProxyBaseUrl', + 'metroPublicBaseUrl', + 'runId', + 'sessionIsolation', + 'tenant', + ]); + assert.equal(generatedConfig.tenant, 'acme'); + assert.equal(generatedConfig.metroProxyBaseUrl, 'https://bridge.example.com'); + assert.equal( + vi.mocked(fetch).mock.calls[0]?.[0]?.toString(), + 'https://cloud.example/api/control-plane/connection-profile', + ); + assert.equal(vi.mocked(fetch).mock.calls.length, 2); + + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); + +test('connect without remote config accepts legacy remoteConfig profile response', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); + const stateDir = path.join(tempRoot, '.state'); + mockedResolveCloudAccessForConnect.mockResolvedValue({ + accessToken: 'adc_agent_cloud', + cloudBaseUrl: 'https://cloud.example', + source: 'login', + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response( + JSON.stringify({ + ok: true, + connection: { + remoteConfig: JSON.stringify({ + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + }), + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ), + ); + + await captureStdout(async () => { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: createTestClient(), + }); + }); + + const state = readActiveConnectionState({ stateDir }); + assert.equal(state?.tenant, 'acme'); + assert.equal(state?.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); + fs.rmSync(tempRoot, { recursive: true, force: true }); +}); test('connect reports deferred Metro runtime preparation when remote config has Metro settings', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-metro-notice-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index a592467d7..8d59eae5d 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -143,13 +143,70 @@ export async function resolveRemoteAuth(options: { }; } +export async function resolveCloudAccessForConnect(options: { + stateDir: string; + flags: CliFlags; + env?: EnvMap; + io?: AuthIo; +}): Promise<{ + accessToken: string; + cloudBaseUrl: string; + source: 'flag' | 'env' | 'cli-session' | 'login'; +}> { + const env = options.env ?? options.io?.env ?? process.env; + if (hasToken(options.flags.daemonAuthToken)) { + return { + accessToken: options.flags.daemonAuthToken, + cloudBaseUrl: resolveCloudBaseUrl(env), + source: 'flag', + }; + } + if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { + return { + accessToken: env.AGENT_DEVICE_DAEMON_AUTH_TOKEN, + cloudBaseUrl: resolveCloudBaseUrl(env), + source: 'env', + }; + } + const session = readCliSession({ stateDir: options.stateDir }); + if (session && !isExpired(session.expiresAt, options.io?.now)) { + const refreshed = await refreshAgentToken({ + session, + flags: options.flags, + env, + io: options.io, + }); + return { + accessToken: refreshed.accessToken, + cloudBaseUrl: resolveCloudBaseUrl(env, session.cloudBaseUrl), + source: 'cli-session', + }; + } + const login = await loginWithDeviceAuth({ + stateDir: options.stateDir, + flags: options.flags, + env, + io: options.io, + commandLabel: 'agent-device connect', + }); + return { + accessToken: login.accessToken, + cloudBaseUrl: login.session.cloudBaseUrl, + source: 'login', + }; +} + export async function loginWithDeviceAuth(options: { stateDir: string; flags: CliFlags; env?: EnvMap; io?: AuthIo; commandLabel?: string; -}): Promise<{ accessToken: string; expiresAt?: string; session: CliSessionRecord }> { +}): Promise<{ + accessToken: string; + expiresAt?: string; + session: CliSessionRecord; +}> { const env = options.env ?? options.io?.env ?? process.env; const authMode = detectAuthMode(env, options.io); if (authMode === 'non-interactive') { @@ -211,7 +268,11 @@ export async function loginWithDeviceAuth(options: { expiresAt: approved.cliSession?.expiresAt, }; writeCliSession({ stateDir: options.stateDir, session }); - return { accessToken: approved.accessToken, expiresAt: approved.expiresAt, session }; + return { + accessToken: approved.accessToken, + expiresAt: approved.expiresAt, + session, + }; } export function readCliSession(options: { stateDir: string }): CliSessionRecord | null { @@ -445,7 +506,7 @@ function buildNonInteractiveLoginError(command: string, env: EnvMap): AppError { ); } -function resolveCloudBaseUrl(env: EnvMap, fallback?: string): string { +export function resolveCloudBaseUrl(env: EnvMap, fallback?: string): string { const raw = env.AGENT_DEVICE_CLOUD_BASE_URL ?? fallback ?? DEFAULT_CLOUD_BASE_URL; try { return new URL(raw).toString().replace(/\/+$/, ''); diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts new file mode 100644 index 000000000..581eab26a --- /dev/null +++ b/src/cli/cloud-connection-profile.ts @@ -0,0 +1,171 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveRemoteConfigProfile } from '../remote-config.ts'; +import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { profileToCliFlags } from '../utils/remote-config.ts'; +import { AppError } from '../utils/errors.ts'; +import type { CliFlags } from '../utils/command-schema.ts'; +import { resolveCloudAccessForConnect } from './auth-session.ts'; + +const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; +const HTTP_TIMEOUT_MS = 15_000; + +type CloudConnectionProfileResponse = { + connection?: { + version?: number; + remoteConfig?: string; + remoteConfigProfile?: unknown; + }; +}; + +type EnvMap = Record; + +export async function resolveCloudConnectProfile(options: { + flags: CliFlags; + stateDir: string; + cwd: string; + env?: EnvMap; + fetchImpl?: typeof fetch; +}): Promise<{ flags: CliFlags; remoteConfigPath: string }> { + if (options.flags.noLogin) { + throw new AppError('INVALID_ARGS', 'connect without --remote-config requires cloud auth.', { + hint: 'Remove --no-login, pass --remote-config , or set AGENT_DEVICE_DAEMON_AUTH_TOKEN.', + }); + } + const auth = await resolveCloudAccessForConnect({ + stateDir: options.stateDir, + flags: options.flags, + env: options.env, + io: { + env: options.env, + fetch: options.fetchImpl, + }, + }); + const profile = await fetchConnectionProfile({ + cloudBaseUrl: auth.cloudBaseUrl, + accessToken: auth.accessToken, + fetchImpl: options.fetchImpl, + }); + const remoteConfigPath = writeGeneratedRemoteConfig({ + stateDir: options.stateDir, + profile, + }); + const remoteConfig = resolveRemoteConfigProfile({ + configPath: remoteConfigPath, + cwd: options.cwd, + env: options.env, + }); + return { + flags: { + ...profileToCliFlags(remoteConfig.profile), + ...options.flags, + remoteConfig: remoteConfig.resolvedPath, + daemonAuthToken: auth.accessToken, + }, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + +async function fetchConnectionProfile(options: { + cloudBaseUrl: string; + accessToken: string; + fetchImpl?: typeof fetch; +}): Promise { + const fetchImpl = options.fetchImpl ?? fetch; + const response = await fetchImpl(new URL(CONNECTION_PROFILE_PATH, options.cloudBaseUrl), { + method: 'GET', + headers: { authorization: `Bearer ${options.accessToken}` }, + signal: AbortSignal.timeout(HTTP_TIMEOUT_MS), + }); + const text = await response.text(); + let parsed: unknown = {}; + if (text.trim()) { + try { + parsed = JSON.parse(text); + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + `Cloud connection profile endpoint returned invalid JSON (${response.status}).`, + { status: response.status }, + error instanceof Error ? error : undefined, + ); + } + } + if (!response.ok) { + throw new AppError('UNAUTHORIZED', 'Cloud connection profile endpoint rejected the request.', { + status: response.status, + response: parsed, + }); + } + return parseConnectionProfile(parsed); +} + +function parseConnectionProfile(value: unknown): RemoteConfigProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('COMMAND_FAILED', 'Cloud connection profile response is invalid.'); + } + const connection = (value as CloudConnectionProfileResponse).connection; + if (!connection || typeof connection !== 'object') { + throw new AppError('COMMAND_FAILED', 'Cloud connection profile response is missing profile.'); + } + if ( + connection.remoteConfigProfile && + typeof connection.remoteConfigProfile === 'object' && + !Array.isArray(connection.remoteConfigProfile) + ) { + return connection.remoteConfigProfile as RemoteConfigProfile; + } + if (typeof connection.remoteConfig === 'string') { + try { + const parsed = JSON.parse(connection.remoteConfig) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as RemoteConfigProfile; + } + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile returned invalid remote config JSON.', + {}, + error instanceof Error ? error : undefined, + ); + } + } + throw new AppError('COMMAND_FAILED', 'Cloud connection profile did not include remote config.'); +} + +function writeGeneratedRemoteConfig(options: { + stateDir: string; + profile: RemoteConfigProfile; +}): string { + const normalized = normalizeJson(options.profile); + const configDir = path.join(options.stateDir, 'remote-connections', 'generated'); + fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); + const configPath = path.join(configDir, `cloud-${profileHash(normalized)}.json`); + fs.writeFileSync(configPath, `${JSON.stringify(normalized, null, 2)}\n`, { mode: 0o600 }); + try { + fs.chmodSync(configPath, 0o600); + } catch { + // Best effort on filesystems that do not support POSIX mode bits. + } + return configPath; +} + +function profileHash(value: unknown): string { + return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16); +} + +function normalizeJson(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(normalizeJson); + } + if (value && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .filter(([, entryValue]) => entryValue !== undefined) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, entryValue]) => [key, normalizeJson(entryValue)]), + ); + } + return value; +} diff --git a/src/cli/commands/connection-runtime.ts b/src/cli/commands/connection-runtime.ts index 2b0c86bdb..5799f6d15 100644 --- a/src/cli/commands/connection-runtime.ts +++ b/src/cli/commands/connection-runtime.ts @@ -10,7 +10,7 @@ import { writeRemoteConnectionState, type RemoteConnectionState, } from '../../remote-connection-state.ts'; -import { REMOTE_CONFIG_FIELD_SPECS, type RemoteConfigProfile } from '../../remote-config-schema.ts'; +import { profileToCliFlags } from '../../utils/remote-config.ts'; import type { BatchStep } from '../../core/batch.ts'; import { AppError } from '../../utils/errors.ts'; import type { LeaseBackend, SessionRuntimeHints } from '../../contracts.ts'; @@ -342,17 +342,6 @@ function selectCompatibleRuntime( return isRuntimeCompatibleWithPlatform(runtime, platform) ? runtime : undefined; } -function profileToCliFlags(profile: RemoteConfigProfile): Partial { - const flags: Partial = {}; - for (const spec of REMOTE_CONFIG_FIELD_SPECS) { - const value = profile[spec.key]; - if (value !== undefined) { - (flags as Record)[spec.key] = value; - } - } - return flags; -} - function createRemoteConnectionStateFromFlags( flags: CliFlags, remoteConfigPath: string, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index a7a4f6715..d340530f8 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -12,6 +12,7 @@ import { type RemoteConnectionState, } from '../../remote-connection-state.ts'; import { AppError } from '../../utils/errors.ts'; +import { resolveCloudConnectProfile } from '../cloud-connection-profile.ts'; import { hasDeferredMetroConfig, releasePreviousLease, @@ -25,11 +26,13 @@ import type { CliFlags } from '../../utils/command-schema.ts'; import type { ClientCommandHandler } from './router-types.ts'; export const connectCommand: ClientCommandHandler = async ({ flags, client }) => { - if (!flags.remoteConfig) { - throw new AppError('INVALID_ARGS', 'connect requires --remote-config .'); - } - const tenant = flags.tenant; - const runId = flags.runId; + const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; + const resolved = flags.remoteConfig + ? resolveRemoteConnectFlags(flags) + : await resolveGeneratedCloudConnectFlags(flags, stateDir); + const connectFlags = resolved.flags; + const tenant = connectFlags.tenant; + const runId = connectFlags.runId; if (!tenant) { throw new AppError( 'INVALID_ARGS', @@ -42,23 +45,17 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => 'connect requires runId in remote config or via --run-id .', ); } - if (!flags.daemonBaseUrl) { + if (!connectFlags.daemonBaseUrl) { throw new AppError( 'INVALID_ARGS', 'connect requires daemonBaseUrl in remote config, config, env, or --daemon-base-url.', ); } - const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; - const activeState = flags.session ? null : readActiveConnectionState({ stateDir }); - const session = flags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); - const remoteConfig = resolveRemoteConfigProfile({ - configPath: flags.remoteConfig, - cwd: process.cwd(), - env: process.env, - }); - const remoteConfigHash = hashRemoteConfigFile(remoteConfig.resolvedPath); - const daemon = buildDaemonState(flags); + const activeState = connectFlags.session ? null : readActiveConnectionState({ stateDir }); + const session = connectFlags.session ?? activeState?.session ?? createRemoteSessionName(stateDir); + const remoteConfigHash = hashRemoteConfigFile(resolved.remoteConfigPath); + const daemon = buildDaemonState(connectFlags); const previous = activeState?.session === session ? activeState @@ -66,15 +63,15 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => if ( previous && !isCompatibleConnection(previous, { - flags, + flags: connectFlags, session, - remoteConfigPath: remoteConfig.resolvedPath, + remoteConfigPath: resolved.remoteConfigPath, remoteConfigHash, - desiredLeaseBackend: resolveRequestedLeaseBackend(flags), + desiredLeaseBackend: resolveRequestedLeaseBackend(connectFlags), daemon, }) ) { - if (!flags.force) { + if (!connectFlags.force) { throw new AppError( 'INVALID_ARGS', 'A different remote connection is already active for this session. Re-run connect with --force to replace it.', @@ -87,31 +84,34 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => const state: RemoteConnectionState = { version: 1, session, - remoteConfigPath: remoteConfig.resolvedPath, + remoteConfigPath: resolved.remoteConfigPath, remoteConfigHash, daemon, tenant, runId, - leaseId: previous && !flags.force ? previous.leaseId : undefined, + leaseId: previous && !connectFlags.force ? previous.leaseId : undefined, leaseBackend: - previous && !flags.force ? previous.leaseBackend : resolveRequestedLeaseBackend(flags), - platform: flags.platform ?? (previous && !flags.force ? previous.platform : undefined), - target: flags.target ?? (previous && !flags.force ? previous.target : undefined), - runtime: previous && !flags.force ? previous.runtime : undefined, - metro: previous && !flags.force ? previous.metro : undefined, - connectedAt: previous && !flags.force ? previous.connectedAt : now, + previous && !connectFlags.force + ? previous.leaseBackend + : resolveRequestedLeaseBackend(connectFlags), + platform: + connectFlags.platform ?? (previous && !connectFlags.force ? previous.platform : undefined), + target: connectFlags.target ?? (previous && !connectFlags.force ? previous.target : undefined), + runtime: previous && !connectFlags.force ? previous.runtime : undefined, + metro: previous && !connectFlags.force ? previous.metro : undefined, + connectedAt: previous && !connectFlags.force ? previous.connectedAt : now, updatedAt: now, }; writeRemoteConnectionState({ stateDir, state }); - if (previous && flags.force) { + if (previous && connectFlags.force) { await stopMetroCleanup(previous.metro); await stopReactDevtoolsCleanup({ stateDir, state: previous }); await releasePreviousLease(client, previous); } const leasePreparation = buildLeasePreparationNotice(state); - const runtimePreparation = buildRuntimePreparationNotice(flags, state); + const runtimePreparation = buildRuntimePreparationNotice(connectFlags, state); - writeCommandOutput(flags, serializeConnectionState(state, runtimePreparation), () => + writeCommandOutput(connectFlags, serializeConnectionState(state, runtimePreparation), () => [ `Connected remote session "${session}" tenant "${tenant}" run "${runId}" ${ state.leaseId ? `lease ${state.leaseId}` : 'lease pending' @@ -125,6 +125,41 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => return true; }; +function resolveRemoteConnectFlags(flags: CliFlags): { + flags: CliFlags; + remoteConfigPath: string; +} { + if (!flags.remoteConfig) { + throw new AppError('INVALID_ARGS', 'connect requires --remote-config .'); + } + const remoteConfig = resolveRemoteConfigProfile({ + configPath: flags.remoteConfig, + cwd: process.cwd(), + env: process.env, + }); + return { + flags, + remoteConfigPath: remoteConfig.resolvedPath, + }; +} + +async function resolveGeneratedCloudConnectFlags( + flags: CliFlags, + stateDir: string, +): Promise<{ flags: CliFlags; remoteConfigPath: string }> { + if (flags.noLogin) { + throw new AppError('INVALID_ARGS', 'connect without --remote-config requires cloud auth.', { + hint: 'Remove --no-login, pass --remote-config , or set AGENT_DEVICE_DAEMON_AUTH_TOKEN.', + }); + } + return resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); +} + export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 70b03d47d..7abd18d7c 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1437,7 +1437,7 @@ const COMMAND_SCHEMAS: Record = { }, connect: { usageOverride: - 'connect --remote-config [--tenant ] [--run-id ] [--lease-backend ] [--force] [--no-login]', + 'connect [--remote-config ] [--tenant ] [--run-id ] [--lease-backend ] [--force] [--no-login]', helpDescription: 'Connect to a remote daemon, authenticate when needed, and save remote session state. AGENT_DEVICE_CLOUD_BASE_URL is the bridge/control-plane API origin; use AGENT_DEVICE_DAEMON_AUTH_TOKEN=adc_live_... for CI/service-token automation.', summary: 'Connect to remote daemon', diff --git a/src/utils/remote-config.ts b/src/utils/remote-config.ts index ad10a9620..8abc40b60 100644 --- a/src/utils/remote-config.ts +++ b/src/utils/remote-config.ts @@ -8,7 +8,7 @@ const REMOTE_CONFIG_DEFAULT_FLAG_KEYS = REMOTE_CONFIG_FIELD_SPECS.map( (spec) => spec.key, ) as readonly (keyof RemoteConfigProfile)[]; -function profileToCliFlags(profile: RemoteConfigProfile): Partial { +export function profileToCliFlags(profile: RemoteConfigProfile): Partial { const flags: Partial = {}; for (const key of REMOTE_CONFIG_DEFAULT_FLAG_KEYS) { const value = profile[key]; From 00823ffc736bb98b7248d1bd9085f0cf70bc3a56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 15:52:32 -0400 Subject: [PATCH 2/7] test: isolate cloud connect profile coverage --- src/__tests__/cloud-connect-profile.test.ts | 151 ++++++++++++++++++++ src/__tests__/remote-connection.test.ts | 139 ------------------ src/cli/auth-session.ts | 60 +++++--- src/cli/cloud-connection-profile.ts | 44 +++--- 4 files changed, 216 insertions(+), 178 deletions(-) create mode 100644 src/__tests__/cloud-connect-profile.test.ts diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts new file mode 100644 index 000000000..53c58abe7 --- /dev/null +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -0,0 +1,151 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { connectCommand } from '../cli/commands/connection.ts'; +import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; +import { + hashRemoteConfigFile, + readActiveConnectionState, + type RemoteConnectionState, +} from '../remote-connection-state.ts'; +import type { AgentDeviceClient } from '../client.ts'; + +vi.mock('../cli/auth-session.ts', () => ({ + resolveCloudAccessForConnect: vi.fn(), +})); + +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); + +test('connect without remote config generates one from cloud connection profile', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-')); + const stateDir = path.join(tempRoot, '.state'); + const fetchMock = mockCloudConnectionProfile({ + remoteConfigProfile: { + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + sessionIsolation: 'tenant', + metroKind: 'auto', + metroPublicBaseUrl: 'http://127.0.0.1:8081', + metroProxyBaseUrl: 'https://bridge.example.com', + }, + }); + + try { + await connectWithGeneratedCloudProfile(stateDir); + await connectWithGeneratedCloudProfile(stateDir); + + assertGeneratedProfileState(readRequiredActiveState(stateDir)); + assert.equal( + fetchProfileUrl(fetchMock), + 'https://cloud.example/api/control-plane/connection-profile', + ); + assert.equal(fetchMock.mock.calls.length, 2); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect without remote config accepts legacy remoteConfig profile response', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); + const stateDir = path.join(tempRoot, '.state'); + mockCloudConnectionProfile({ + remoteConfig: JSON.stringify({ + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + }), + }); + + try { + await connectWithGeneratedCloudProfile(stateDir); + + const state = readRequiredActiveState(stateDir); + assert.equal(state.tenant, 'acme'); + assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function mockCloudConnectionProfile(connection: Record): ReturnType { + mockedResolveCloudAccessForConnect.mockResolvedValue({ + accessToken: 'adc_agent_cloud', + cloudBaseUrl: 'https://cloud.example', + source: 'login', + }); + const fetchMock = vi.fn( + async () => + new Response(JSON.stringify({ ok: true, connection }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +function assertGeneratedProfileState(state: RemoteConnectionState): void { + assert.equal(state.tenant, 'acme'); + assert.equal(state.runId, 'demo-run-001'); + assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); + assert.match(state.remoteConfigPath, /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/); + assert.equal(state.remoteConfigHash, hashRemoteConfigFile(state.remoteConfigPath)); + assert.deepEqual(readGeneratedConfigKeys(state.remoteConfigPath), [ + 'daemonBaseUrl', + 'daemonTransport', + 'metroKind', + 'metroProxyBaseUrl', + 'metroPublicBaseUrl', + 'runId', + 'sessionIsolation', + 'tenant', + ]); + assert.equal(readGeneratedConfig(state.remoteConfigPath).tenant, 'acme'); +} + +function fetchProfileUrl(fetchMock: ReturnType): string | undefined { + return fetchMock.mock.calls[0]?.[0]?.toString(); +} + +async function connectWithGeneratedCloudProfile(stateDir: string): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + stateDir, + }, + client: {} as AgentDeviceClient, + }); + } finally { + stdoutWrite.mockRestore(); + } +} + +function readGeneratedConfig(configPath: string): { tenant?: string } { + return JSON.parse(fs.readFileSync(configPath, 'utf8')) as { tenant?: string }; +} + +function readGeneratedConfigKeys(configPath: string): string[] { + return Object.keys(readGeneratedConfig(configPath)); +} + +function readRequiredActiveState(stateDir: string): RemoteConnectionState { + const state = readActiveConnectionState({ stateDir }); + assert.ok(state); + return state; +} diff --git a/src/__tests__/remote-connection.test.ts b/src/__tests__/remote-connection.test.ts index bd0e6c330..0ae3f4415 100644 --- a/src/__tests__/remote-connection.test.ts +++ b/src/__tests__/remote-connection.test.ts @@ -12,17 +12,12 @@ vi.mock('../client-react-devtools-companion.ts', () => ({ stopReactDevtoolsCompanion: vi.fn(), })); -vi.mock('../cli/auth-session.ts', () => ({ - resolveCloudAccessForConnect: vi.fn(), -})); - import { connectCommand, connectionCommand, disconnectCommand, } from '../cli/commands/connection.ts'; import { materializeRemoteConnectionForCommand } from '../cli/commands/connection-runtime.ts'; -import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; import { stopMetroCompanion } from '../client-metro-companion.ts'; import { AppError } from '../utils/errors.ts'; import { @@ -36,11 +31,8 @@ import type { AgentDeviceClient } from '../client.ts'; afterEach(() => { vi.clearAllMocks(); vi.restoreAllMocks(); - vi.unstubAllGlobals(); }); -const mockedResolveCloudAccessForConnect = vi.mocked(resolveCloudAccessForConnect); - const unexpectedCommandCall = async (): Promise => { throw new Error('unexpected call'); }; @@ -166,137 +158,6 @@ test('connect auto-generates a local session and writes minimal remote state', a fs.rmSync(tempRoot, { recursive: true, force: true }); }); -test('connect without remote config generates one from cloud connection profile', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-')); - const stateDir = path.join(tempRoot, '.state'); - mockedResolveCloudAccessForConnect.mockResolvedValue({ - accessToken: 'adc_agent_cloud', - cloudBaseUrl: 'https://cloud.example', - source: 'login', - }); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response( - JSON.stringify({ - ok: true, - connection: { - version: 1, - remoteConfigProfile: { - daemonBaseUrl: 'https://bridge.example.com/agent-device', - daemonTransport: 'http', - tenant: 'acme', - runId: 'demo-run-001', - sessionIsolation: 'tenant', - metroKind: 'auto', - metroPublicBaseUrl: 'http://127.0.0.1:8081', - metroProxyBaseUrl: 'https://bridge.example.com', - }, - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ), - ); - - const connectOnce = async () => { - await connectCommand({ - positionals: [], - flags: { - json: true, - help: false, - version: false, - stateDir, - }, - client: createTestClient(), - }); - }; - - await captureStdout(connectOnce); - await captureStdout(connectOnce); - - const state = readActiveConnectionState({ stateDir }); - assert.equal(state?.tenant, 'acme'); - assert.equal(state?.runId, 'demo-run-001'); - assert.equal(state?.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); - assert.match( - state?.remoteConfigPath ?? '', - /remote-connections\/generated\/cloud-[a-f0-9]{16}\.json$/, - ); - assert.equal(state?.remoteConfigHash, hashRemoteConfigFile(state?.remoteConfigPath ?? '')); - const generatedConfig = JSON.parse(fs.readFileSync(state?.remoteConfigPath ?? '', 'utf8')) as { - tenant?: string; - metroProxyBaseUrl?: string; - }; - assert.deepEqual(Object.keys(generatedConfig), [ - 'daemonBaseUrl', - 'daemonTransport', - 'metroKind', - 'metroProxyBaseUrl', - 'metroPublicBaseUrl', - 'runId', - 'sessionIsolation', - 'tenant', - ]); - assert.equal(generatedConfig.tenant, 'acme'); - assert.equal(generatedConfig.metroProxyBaseUrl, 'https://bridge.example.com'); - assert.equal( - vi.mocked(fetch).mock.calls[0]?.[0]?.toString(), - 'https://cloud.example/api/control-plane/connection-profile', - ); - assert.equal(vi.mocked(fetch).mock.calls.length, 2); - - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); - -test('connect without remote config accepts legacy remoteConfig profile response', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); - const stateDir = path.join(tempRoot, '.state'); - mockedResolveCloudAccessForConnect.mockResolvedValue({ - accessToken: 'adc_agent_cloud', - cloudBaseUrl: 'https://cloud.example', - source: 'login', - }); - vi.stubGlobal( - 'fetch', - vi.fn( - async () => - new Response( - JSON.stringify({ - ok: true, - connection: { - remoteConfig: JSON.stringify({ - daemonBaseUrl: 'https://bridge.example.com/agent-device', - daemonTransport: 'http', - tenant: 'acme', - runId: 'demo-run-001', - }), - }, - }), - { status: 200, headers: { 'content-type': 'application/json' } }, - ), - ), - ); - - await captureStdout(async () => { - await connectCommand({ - positionals: [], - flags: { - json: true, - help: false, - version: false, - stateDir, - }, - client: createTestClient(), - }); - }); - - const state = readActiveConnectionState({ stateDir }); - assert.equal(state?.tenant, 'acme'); - assert.equal(state?.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); - fs.rmSync(tempRoot, { recursive: true, force: true }); -}); test('connect reports deferred Metro runtime preparation when remote config has Metro settings', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-metro-notice-')); const stateDir = path.join(tempRoot, '.state'); diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index 8d59eae5d..9d7c18c48 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -108,16 +108,15 @@ export async function resolveRemoteAuth(options: { return { flags: options.flags, source: 'none' }; } - const session = readCliSession({ stateDir: options.stateDir }); - if (session && !isExpired(session.expiresAt, options.io?.now)) { - const refreshed = await refreshAgentToken({ - session, - flags: options.flags, - env, - io: options.io, - }); + const sessionAccess = await resolveCliSessionAccess({ + stateDir: options.stateDir, + flags: options.flags, + env, + io: options.io, + }); + if (sessionAccess) { return { - flags: { ...options.flags, daemonAuthToken: refreshed.accessToken }, + flags: { ...options.flags, daemonAuthToken: sessionAccess.accessToken }, source: 'cli-session', }; } @@ -168,17 +167,16 @@ export async function resolveCloudAccessForConnect(options: { source: 'env', }; } - const session = readCliSession({ stateDir: options.stateDir }); - if (session && !isExpired(session.expiresAt, options.io?.now)) { - const refreshed = await refreshAgentToken({ - session, - flags: options.flags, - env, - io: options.io, - }); + const sessionAccess = await resolveCliSessionAccess({ + stateDir: options.stateDir, + flags: options.flags, + env, + io: options.io, + }); + if (sessionAccess) { return { - accessToken: refreshed.accessToken, - cloudBaseUrl: resolveCloudBaseUrl(env, session.cloudBaseUrl), + accessToken: sessionAccess.accessToken, + cloudBaseUrl: sessionAccess.cloudBaseUrl, source: 'cli-session', }; } @@ -347,6 +345,28 @@ export function summarizeCliSession(options: { stateDir: string; now?: () => num }; } +async function resolveCliSessionAccess(options: { + stateDir: string; + flags: CliFlags; + env: EnvMap; + io?: AuthIo; +}): Promise<{ accessToken: string; cloudBaseUrl: string } | null> { + const session = readCliSession({ stateDir: options.stateDir }); + if (!session || isExpired(session.expiresAt, options.io?.now)) { + return null; + } + const refreshed = await refreshAgentToken({ + session, + flags: options.flags, + env: options.env, + io: options.io, + }); + return { + accessToken: refreshed.accessToken, + cloudBaseUrl: resolveCloudBaseUrl(options.env, session.cloudBaseUrl), + }; +} + async function refreshAgentToken(options: { session: CliSessionRecord; flags: CliFlags; @@ -506,7 +526,7 @@ function buildNonInteractiveLoginError(command: string, env: EnvMap): AppError { ); } -export function resolveCloudBaseUrl(env: EnvMap, fallback?: string): string { +function resolveCloudBaseUrl(env: EnvMap, fallback?: string): string { const raw = env.AGENT_DEVICE_CLOUD_BASE_URL ?? fallback ?? DEFAULT_CLOUD_BASE_URL; try { return new URL(raw).toString().replace(/\/+$/, ''); diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 581eab26a..e9c9c2baa 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -109,31 +109,37 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { if (!connection || typeof connection !== 'object') { throw new AppError('COMMAND_FAILED', 'Cloud connection profile response is missing profile.'); } - if ( - connection.remoteConfigProfile && - typeof connection.remoteConfigProfile === 'object' && - !Array.isArray(connection.remoteConfigProfile) - ) { + if (isRemoteConfigProfileObject(connection.remoteConfigProfile)) { return connection.remoteConfigProfile as RemoteConfigProfile; } - if (typeof connection.remoteConfig === 'string') { - try { - const parsed = JSON.parse(connection.remoteConfig) as unknown; - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - return parsed as RemoteConfigProfile; - } - } catch (error) { - throw new AppError( - 'COMMAND_FAILED', - 'Cloud connection profile returned invalid remote config JSON.', - {}, - error instanceof Error ? error : undefined, - ); - } + const legacyProfile = parseLegacyRemoteConfig(connection.remoteConfig); + if (legacyProfile) { + return legacyProfile; } throw new AppError('COMMAND_FAILED', 'Cloud connection profile did not include remote config.'); } +function isRemoteConfigProfileObject(value: unknown): value is RemoteConfigProfile { + return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +} + +function parseLegacyRemoteConfig(value: unknown): RemoteConfigProfile | undefined { + if (typeof value !== 'string') { + return undefined; + } + try { + const parsed = JSON.parse(value) as unknown; + return isRemoteConfigProfileObject(parsed) ? parsed : undefined; + } catch (error) { + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile returned invalid remote config JSON.', + {}, + error instanceof Error ? error : undefined, + ); + } +} + function writeGeneratedRemoteConfig(options: { stateDir: string; profile: RemoteConfigProfile; From 5c5af708dd095eb4ec15d3c41afa11b7fa85800d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 15:58:27 -0400 Subject: [PATCH 3/7] docs: document cloud connect discovery --- src/utils/__tests__/args.test.ts | 2 ++ src/utils/command-schema.ts | 15 ++++++++---- .../suites/agent-device-smoke-suite.ts | 23 +++++++++++++++++++ website/docs/docs/commands.md | 20 ++++++++++++---- 4 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index fd8f86b29..2ca510829 100644 --- a/src/utils/__tests__/args.test.ts +++ b/src/utils/__tests__/args.test.ts @@ -897,6 +897,8 @@ test('workflow help keeps common copyable command forms', () => { test('usageForCommand resolves remote help topic', () => { const help = usageForCommand('remote'); if (help === null) throw new Error('Expected remote help text'); + assert.match(help, /agent-device connect/); + assert.match(help, /without --remote-config/); assert.match(help, /agent-device open com\.example\.app --remote-config \.\/remote-config\.json/); assert.match(help, /disconnect --remote-config \.\/remote-config\.json/); assert.match(help, /Script flow, per-command config/); diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 7abd18d7c..96006e5fc 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -333,7 +333,7 @@ Validation and evidence: Android animations: settings animations off/on, not animations disable/restore. Debug logs: logs clear --restart, logs mark, reproduce, then logs path; do not split clear/restart into separate stop/start commands. Network headers: network dump --include headers; do not write network log headers. - Remote config: connect --remote-config ./remote-config.json, open, snapshot, disconnect. + Remote/cloud: connect to discover a cloud profile, or connect --remote-config ./remote-config.json for a local profile; then open, snapshot, disconnect. macOS menu bar: open ... --platform macos --surface menubar; snapshot -i --platform macos --surface menubar. React Native dev loop: @@ -477,9 +477,15 @@ Use snapshot, screenshot, logs, network, and perf for device/app runtime evidenc summary: 'Remote config, tenant, lease, and remote host flow', body: `agent-device help remote -Use remote config when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. +Use remote config or the cloud connection profile when a profile owns daemon URL, auth, tenant, run, lease, device scope, and Metro hints. Do not restate those as individual flags unless overriding intentionally. -Normal flow: +Cloud profile flow: + agent-device connect + agent-device open com.example.app + agent-device snapshot + agent-device disconnect + +Local profile flow: agent-device connect --remote-config ./remote-config.json agent-device open com.example.app agent-device snapshot @@ -492,7 +498,8 @@ Script flow, per-command config: Rules: connect and disconnect are top-level commands. Do not write agent-device remote connect or agent-device remote disconnect. - Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id in ordinary remote flows. + Use connect without --remote-config when the cloud control plane owns the connection profile. + Prefer --remote-config over --daemon-base-url, --tenant, --run-id, and --lease-id when using a local profile. For self-contained scripts, pass the same --remote-config to every operational command, including disconnect; a preceding connect is optional but not required. For remote artifact installs, use install-from-source or install-from-source --github-actions-artifact org/repo:artifact; do not download CI artifacts locally first. After connect, let the active remote connection supply runtime hints. diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6b22e9fff..70e328e28 100644 --- a/test/skillgym/suites/agent-device-smoke-suite.ts +++ b/test/skillgym/suites/agent-device-smoke-suite.ts @@ -1155,6 +1155,29 @@ const SKILL_GUIDANCE_CASES: TestCase[] = [ outputs: [/(?:^|\n)(?:agent-device\s+)?keyboard\s+(?:status|get)(?:\s|$)/i], forbiddenOutputs: [plannedCommand('fill'), plannedCommand('type'), /keyboard dismiss/i], }), + makeCase({ + id: 'remote-cloud-connect-flow', + contract: [ + 'Cloud control plane owns the connection profile', + 'No local remote config path was provided', + 'App package: com.callstack.agentdevicetester', + 'The cloud profile owns tenant, run, lease, and Metro hints', + ], + task: 'Plan a remote flow that discovers the cloud connection profile, opens the app, captures a snapshot, and disconnects cleanly.', + outputs: [ + plannedCommand('connect'), + plannedCommand('open'), + plannedCommand('snapshot'), + plannedCommand('disconnect'), + ], + forbiddenOutputs: [ + /--remote-config/i, + /--daemon-base-url/i, + /--tenant/i, + /--run-id/i, + plannedCommand('screenshot'), + ], + }), makeCase({ id: 'remote-config-connect-flow', contract: [ diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index 8ed1a8a7d..dfcb9a797 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -68,8 +68,8 @@ agent-device app-switcher - Tenant-scoped daemon runs can pass `--tenant`, `--session-isolation tenant`, `--run-id`, and `--lease-id` to enforce lease admission. - Remote daemon clients can pass `--daemon-base-url http(s)://host:port[/base-path]` to skip local daemon discovery/startup and call a remote HTTP daemon directly. - Use `--daemon-auth-token ` (or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`) for explicit service/API-token automation against non-loopback remote daemon URLs; the client sends it in both the JSON-RPC request token and HTTP auth headers. -- For human cloud access, `connect --remote-config ...` refreshes a stored CLI session into a short-lived `adc_agent_...` token. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. -- For remote `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). +- For human cloud access, `connect` can discover a cloud connection profile, while `connect --remote-config ...` uses a local profile. Both refresh a stored CLI session into a short-lived `adc_agent_...` token when needed. If no CLI session exists, interactive shells start login automatically; CI and non-interactive shells fail with API-token setup instructions. Use `--no-login` to disable implicit login. `AGENT_DEVICE_CLOUD_BASE_URL` is the bridge/control-plane API origin; its `/api-keys` route may redirect to the dashboard for token creation. +- For remote `connect` and `connect --remote-config` flows, see [Remote Metro workflow](#remote-metro-workflow). - Android React Native relaunch flows require an installed package name for `open --relaunch`; install/reinstall the APK first, then relaunch by package. `open --relaunch` is rejected because runtime hints are written through the installed app sandbox. - For Metro-backed React Native JS changes, use `metro reload` before `open --relaunch`; it mirrors pressing `r` in the Metro terminal and keeps the native process alive. - Remote daemon screenshots and recordings are downloaded back to the caller path, so `screenshot page.png` and `record start session.mp4` remain usable when the daemon runs on another host. @@ -712,7 +712,16 @@ agent-device trace stop session.trace ## Remote Metro workflow -Example `agent-device.remote.json`: +When the cloud control plane owns the connection profile, connect can discover it directly: + +```bash +agent-device connect +agent-device open com.example.myapp --relaunch +agent-device snapshot -i +agent-device disconnect +``` + +For local profile files, create an `agent-device.remote.json`: ```json { @@ -745,8 +754,9 @@ agent-device snapshot --remote-config ./agent-device.remote.json -i agent-device disconnect --remote-config ./agent-device.remote.json ``` -- `--remote-config ` points to a remote workflow profile that captures stable host, tenant/run, and any optional session, platform, lease backend, or Metro overrides for `connect`. -- `connect --remote-config ...` is the main agent flow. It generates a local session name when needed, authenticates to cloud when credentials are missing, stores the remote scope locally, and defers tenant lease allocation plus Metro preparation until a later command needs them. +- `connect` without `--remote-config` authenticates to cloud when needed, fetches the connection profile, writes a generated local profile, stores the remote scope locally, and defers tenant lease allocation plus Metro preparation until a later command needs them. +- `--remote-config ` points to a local remote workflow profile that captures stable host, tenant/run, and any optional session, platform, lease backend, or Metro overrides for `connect`. +- `connect --remote-config ...` follows the same state and deferred-preparation flow using the local profile instead of cloud discovery. - Auth management commands are available for inspection and recovery: `agent-device auth status`, `agent-device auth login`, and `agent-device auth logout`. Human login stores a revocable CLI session locally; it does not create or persist an `adc_live_...` service token. - Cloud auth uses three credential classes: `adc_agent_...` short-lived command tokens, revocable CLI session refresh credentials, and explicit `adc_live_...` service/API tokens for CI. The CLI implements credential selection, CI refusal, local storage permissions, logout, and output redaction; the cloud API must enforce token expiry, tenant/run scope, revocation, one-time device approval, polling rate limits, and dashboard/API separation. - `AGENT_DEVICE_CLOUD_BASE_URL` should point at the bridge/control-plane API origin, not necessarily the dashboard origin. API-token setup links use `/api-keys` on that origin so the bridge can redirect users to the right dashboard page. From ee5144fe2b154e3a1d5efed2aba22159b4817181 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 16:02:58 -0400 Subject: [PATCH 4/7] fix: honor no-login after cloud auth reuse --- src/__tests__/cloud-connect-auth.test.ts | 101 +++++++++++++++++++++++ src/cli/auth-session.ts | 5 ++ src/cli/cloud-connection-profile.ts | 5 -- src/cli/commands/connection.ts | 5 -- 4 files changed, 106 insertions(+), 10 deletions(-) create mode 100644 src/__tests__/cloud-connect-auth.test.ts diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts new file mode 100644 index 000000000..c2fd239a0 --- /dev/null +++ b/src/__tests__/cloud-connect-auth.test.ts @@ -0,0 +1,101 @@ +import { afterEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { connectCommand } from '../cli/commands/connection.ts'; +import { resolveCloudAccessForConnect } from '../cli/auth-session.ts'; +import { readActiveConnectionState } from '../remote-connection-state.ts'; +import type { AgentDeviceClient } from '../client.ts'; + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +test('cloud connect reuses explicit env auth when login is disabled', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-auth-')); + const stateDir = path.join(tempRoot, '.state'); + const fetchMock = mockConnectionProfileFetch(); + vi.stubEnv('AGENT_DEVICE_DAEMON_AUTH_TOKEN', 'adc_live_service'); + vi.stubEnv('AGENT_DEVICE_CLOUD_BASE_URL', 'https://cloud.example'); + + try { + await connectWithNoLogin(stateDir); + + const state = readActiveConnectionState({ stateDir }); + assert.equal(state?.tenant, 'acme'); + assert.equal(fetchMock.mock.calls.length, 1); + assert.equal( + fetchMock.mock.calls[0]?.[0]?.toString(), + 'https://cloud.example/api/control-plane/connection-profile', + ); + const request = fetchMock.mock.calls[0]?.[1] as RequestInit | undefined; + assert.ok(request); + const headers = request.headers as Record; + assert.equal(headers.authorization, 'Bearer adc_live_service'); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('cloud access with no-login reports auth requirement after reuse options are exhausted', async () => { + await assert.rejects( + resolveCloudAccessForConnect({ + stateDir: fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-auth-miss-')), + flags: { + json: false, + help: false, + version: false, + noLogin: true, + }, + env: {}, + io: { + env: {}, + fetch: vi.fn(), + }, + }), + /Cloud connection profile authentication is required/, + ); +}); + +function mockConnectionProfileFetch(): ReturnType { + const fetchMock = vi.fn( + async () => + new Response( + JSON.stringify({ + connection: { + remoteConfigProfile: { + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + }, + }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +async function connectWithNoLogin(stateDir: string): Promise { + const stdoutWrite = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + try { + await connectCommand({ + positionals: [], + flags: { + json: true, + help: false, + version: false, + noLogin: true, + stateDir, + }, + client: {} as AgentDeviceClient, + }); + } finally { + stdoutWrite.mockRestore(); + } +} diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index 9d7c18c48..4842abbb0 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -180,6 +180,11 @@ export async function resolveCloudAccessForConnect(options: { source: 'cli-session', }; } + if (options.flags.noLogin) { + throw new AppError('UNAUTHORIZED', 'Cloud connection profile authentication is required.', { + hint: 'Run agent-device auth login, unset --no-login, or set AGENT_DEVICE_DAEMON_AUTH_TOKEN.', + }); + } const login = await loginWithDeviceAuth({ stateDir: options.stateDir, flags: options.flags, diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index e9c9c2baa..f3653ccc9 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -28,11 +28,6 @@ export async function resolveCloudConnectProfile(options: { env?: EnvMap; fetchImpl?: typeof fetch; }): Promise<{ flags: CliFlags; remoteConfigPath: string }> { - if (options.flags.noLogin) { - throw new AppError('INVALID_ARGS', 'connect without --remote-config requires cloud auth.', { - hint: 'Remove --no-login, pass --remote-config , or set AGENT_DEVICE_DAEMON_AUTH_TOKEN.', - }); - } const auth = await resolveCloudAccessForConnect({ stateDir: options.stateDir, flags: options.flags, diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index d340530f8..b64246743 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -147,11 +147,6 @@ async function resolveGeneratedCloudConnectFlags( flags: CliFlags, stateDir: string, ): Promise<{ flags: CliFlags; remoteConfigPath: string }> { - if (flags.noLogin) { - throw new AppError('INVALID_ARGS', 'connect without --remote-config requires cloud auth.', { - hint: 'Remove --no-login, pass --remote-config , or set AGENT_DEVICE_DAEMON_AUTH_TOKEN.', - }); - } return resolveCloudConnectProfile({ flags, stateDir, From 02bdfcbd62082d7bc1e410ec85969d1be1b37027 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 16:05:41 -0400 Subject: [PATCH 5/7] fix: validate cloud connection profiles --- src/__tests__/cloud-connect-profile.test.ts | 57 +++++++++++++++++ src/cli/cloud-connection-profile.ts | 68 ++++++++++++++++++--- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index 53c58abe7..a19f4e6db 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -78,6 +78,63 @@ test('connect without remote config accepts legacy remoteConfig profile response } }); +test('connect without remote config reports cloud profile authorization failures', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-denied-')); + const stateDir = path.join(tempRoot, '.state'); + mockedResolveCloudAccessForConnect.mockResolvedValue({ + accessToken: 'adc_agent_cloud', + cloudBaseUrl: 'https://cloud.example', + source: 'login', + }); + vi.stubGlobal( + 'fetch', + vi.fn( + async () => + new Response(JSON.stringify({ error: 'forbidden' }), { + status: 403, + headers: { 'content-type': 'application/json' }, + }), + ), + ); + + try { + await assert.rejects(connectWithGeneratedCloudProfile(stateDir), (error: unknown) => { + assert.equal((error as { code?: string }).code, 'UNAUTHORIZED'); + assert.match( + (error as Error).message, + /Cloud connection profile endpoint rejected the request/, + ); + return true; + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +test('connect without remote config rejects unsupported cloud profile keys before writing config', async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-invalid-')); + const stateDir = path.join(tempRoot, '.state'); + mockCloudConnectionProfile({ + remoteConfigProfile: { + daemonBaseUrl: 'https://bridge.example.com/agent-device', + tenant: 'acme', + runId: 'demo-run-001', + typoTenant: 'wrong', + }, + }); + + try { + await assert.rejects(connectWithGeneratedCloudProfile(stateDir), (error: unknown) => { + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match((error as Error).message, /unsupported remote config key/); + return true; + }); + assert.equal(fs.existsSync(path.join(stateDir, 'remote-connections', 'generated')), false); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + function mockCloudConnectionProfile(connection: Record): ReturnType { mockedResolveCloudAccessForConnect.mockResolvedValue({ accessToken: 'adc_agent_cloud', diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index f3653ccc9..c27c6a038 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -2,9 +2,13 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { resolveRemoteConfigProfile } from '../remote-config.ts'; -import type { RemoteConfigProfile } from '../remote-config-schema.ts'; +import { + REMOTE_CONFIG_FIELD_SPECS, + type RemoteConfigProfile, + type ResolvedRemoteConfigProfile, +} from '../remote-config-schema.ts'; import { profileToCliFlags } from '../utils/remote-config.ts'; -import { AppError } from '../utils/errors.ts'; +import { AppError, asAppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/command-schema.ts'; import { resolveCloudAccessForConnect } from './auth-session.ts'; @@ -13,13 +17,13 @@ const HTTP_TIMEOUT_MS = 15_000; type CloudConnectionProfileResponse = { connection?: { - version?: number; remoteConfig?: string; remoteConfigProfile?: unknown; }; }; type EnvMap = Record; +const REMOTE_CONFIG_KEYS = new Set(REMOTE_CONFIG_FIELD_SPECS.map((spec) => spec.key)); export async function resolveCloudConnectProfile(options: { flags: CliFlags; @@ -46,7 +50,7 @@ export async function resolveCloudConnectProfile(options: { stateDir: options.stateDir, profile, }); - const remoteConfig = resolveRemoteConfigProfile({ + const remoteConfig = resolveGeneratedRemoteConfigProfile({ configPath: remoteConfigPath, cwd: options.cwd, env: options.env, @@ -104,8 +108,8 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { if (!connection || typeof connection !== 'object') { throw new AppError('COMMAND_FAILED', 'Cloud connection profile response is missing profile.'); } - if (isRemoteConfigProfileObject(connection.remoteConfigProfile)) { - return connection.remoteConfigProfile as RemoteConfigProfile; + if (connection.remoteConfigProfile !== undefined) { + return validateRemoteConfigProfile(connection.remoteConfigProfile, 'remoteConfigProfile'); } const legacyProfile = parseLegacyRemoteConfig(connection.remoteConfig); if (legacyProfile) { @@ -114,8 +118,29 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { throw new AppError('COMMAND_FAILED', 'Cloud connection profile did not include remote config.'); } -function isRemoteConfigProfileObject(value: unknown): value is RemoteConfigProfile { - return Boolean(value && typeof value === 'object' && !Array.isArray(value)); +function validateRemoteConfigProfile(value: unknown, source: string): RemoteConfigProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('COMMAND_FAILED', `Cloud connection profile ${source} is invalid.`); + } + const profile = value as Record; + const keys = Object.keys(profile); + if (keys.length === 0) { + throw new AppError('COMMAND_FAILED', `Cloud connection profile ${source} is empty.`); + } + const unsupportedKey = keys.find( + (key) => !REMOTE_CONFIG_KEYS.has(key as keyof RemoteConfigProfile), + ); + if (unsupportedKey) { + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile returned unsupported remote config key.', + { + key: unsupportedKey, + source, + }, + ); + } + return profile as RemoteConfigProfile; } function parseLegacyRemoteConfig(value: unknown): RemoteConfigProfile | undefined { @@ -124,8 +149,11 @@ function parseLegacyRemoteConfig(value: unknown): RemoteConfigProfile | undefine } try { const parsed = JSON.parse(value) as unknown; - return isRemoteConfigProfileObject(parsed) ? parsed : undefined; + return validateRemoteConfigProfile(parsed, 'remoteConfig'); } catch (error) { + if (error instanceof AppError) { + throw error; + } throw new AppError( 'COMMAND_FAILED', 'Cloud connection profile returned invalid remote config JSON.', @@ -135,6 +163,28 @@ function parseLegacyRemoteConfig(value: unknown): RemoteConfigProfile | undefine } } +function resolveGeneratedRemoteConfigProfile(options: { + configPath: string; + cwd: string; + env?: EnvMap; +}): ResolvedRemoteConfigProfile { + try { + // Re-read the generated file to reuse the standard env merge, type coercion, and path resolution. + return resolveRemoteConfigProfile(options); + } catch (error) { + const appError = asAppError(error); + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile returned invalid remote config.', + { + generatedConfigPath: options.configPath, + cause: appError.message, + }, + appError, + ); + } +} + function writeGeneratedRemoteConfig(options: { stateDir: string; profile: RemoteConfigProfile; From 55f9e97f40e51a08047ce7fb3e6c936241e7220c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 16:10:04 -0400 Subject: [PATCH 6/7] refactor: require object cloud profiles --- src/__tests__/cloud-connect-profile.test.ts | 12 ++++----- src/cli/cloud-connection-profile.ts | 30 +++------------------ website/docs/docs/commands.md | 30 +++++++++++++++++++++ 3 files changed, 40 insertions(+), 32 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index a19f4e6db..e6ae7269d 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -55,7 +55,7 @@ test('connect without remote config generates one from cloud connection profile' } }); -test('connect without remote config accepts legacy remoteConfig profile response', async () => { +test('connect without remote config rejects legacy remoteConfig string profile response', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-legacy-')); const stateDir = path.join(tempRoot, '.state'); mockCloudConnectionProfile({ @@ -68,11 +68,11 @@ test('connect without remote config accepts legacy remoteConfig profile response }); try { - await connectWithGeneratedCloudProfile(stateDir); - - const state = readRequiredActiveState(stateDir); - assert.equal(state.tenant, 'acme'); - assert.equal(state.daemon?.baseUrl, 'https://bridge.example.com/agent-device'); + await assert.rejects(connectWithGeneratedCloudProfile(stateDir), (error: unknown) => { + assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); + assert.match((error as Error).message, /did not include remoteConfigProfile/); + return true; + }); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index c27c6a038..3d7b40de0 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -17,7 +17,6 @@ const HTTP_TIMEOUT_MS = 15_000; type CloudConnectionProfileResponse = { connection?: { - remoteConfig?: string; remoteConfigProfile?: unknown; }; }; @@ -111,11 +110,10 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { if (connection.remoteConfigProfile !== undefined) { return validateRemoteConfigProfile(connection.remoteConfigProfile, 'remoteConfigProfile'); } - const legacyProfile = parseLegacyRemoteConfig(connection.remoteConfig); - if (legacyProfile) { - return legacyProfile; - } - throw new AppError('COMMAND_FAILED', 'Cloud connection profile did not include remote config.'); + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile did not include remoteConfigProfile.', + ); } function validateRemoteConfigProfile(value: unknown, source: string): RemoteConfigProfile { @@ -143,26 +141,6 @@ function validateRemoteConfigProfile(value: unknown, source: string): RemoteConf return profile as RemoteConfigProfile; } -function parseLegacyRemoteConfig(value: unknown): RemoteConfigProfile | undefined { - if (typeof value !== 'string') { - return undefined; - } - try { - const parsed = JSON.parse(value) as unknown; - return validateRemoteConfigProfile(parsed, 'remoteConfig'); - } catch (error) { - if (error instanceof AppError) { - throw error; - } - throw new AppError( - 'COMMAND_FAILED', - 'Cloud connection profile returned invalid remote config JSON.', - {}, - error instanceof Error ? error : undefined, - ); - } -} - function resolveGeneratedRemoteConfigProfile(options: { configPath: string; cwd: string; diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index dfcb9a797..fc1bb6859 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -755,6 +755,7 @@ agent-device disconnect --remote-config ./agent-device.remote.json ``` - `connect` without `--remote-config` authenticates to cloud when needed, fetches the connection profile, writes a generated local profile, stores the remote scope locally, and defers tenant lease allocation plus Metro preparation until a later command needs them. +- Cloud connection profile responses must return a JSON object at `connection.remoteConfigProfile`. The older `connection.remoteConfig` JSON string shape is no longer accepted. - `--remote-config ` points to a local remote workflow profile that captures stable host, tenant/run, and any optional session, platform, lease backend, or Metro overrides for `connect`. - `connect --remote-config ...` follows the same state and deferred-preparation flow using the local profile instead of cloud discovery. - Auth management commands are available for inspection and recovery: `agent-device auth status`, `agent-device auth login`, and `agent-device auth logout`. Human login stores a revocable CLI session locally; it does not create or persist an `adc_live_...` service token. @@ -771,6 +772,35 @@ agent-device disconnect --remote-config ./agent-device.remote.json - `metro prepare --remote-config ...` remains an advanced inspection/debug path and can still write a `--runtime-file ` artifact when needed. - The local Metro companion runs on the same machine as the React Native project and Metro. `disconnect` stops the companion owned by the connection, but it does not stop the user’s Metro server. +### Cloud profile response migration + +Cloud/control-plane clients serving `/api/control-plane/connection-profile` must return the remote profile as an object: + +```json +{ + "connection": { + "remoteConfigProfile": { + "daemonBaseUrl": "https://bridge.example.com/agent-device", + "daemonTransport": "http", + "tenant": "acme", + "runId": "run-123" + } + } +} +``` + +Do not return the old JSON-string wrapper: + +```json +{ + "connection": { + "remoteConfig": "{\"daemonBaseUrl\":\"https://bridge.example.com/agent-device\"}" + } +} +``` + +The CLI now rejects that legacy shape before writing a generated remote config. + ## Session inspection ```bash From e7770da9cdf6756a21ae13be7f4e0c8d3af522eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 30 Apr 2026 16:12:55 -0400 Subject: [PATCH 7/7] refactor: trim cloud connect compatibility code --- src/__tests__/cloud-connect-profile.test.ts | 7 ++--- src/cli/auth-session.ts | 5 ---- src/cli/cloud-connection-profile.ts | 33 +++++---------------- src/cli/commands/connection.ts | 19 ++++-------- website/docs/docs/commands.md | 27 +---------------- 5 files changed, 17 insertions(+), 74 deletions(-) diff --git a/src/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts index e6ae7269d..fc4662452 100644 --- a/src/__tests__/cloud-connect-profile.test.ts +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -84,7 +84,6 @@ test('connect without remote config reports cloud profile authorization failures mockedResolveCloudAccessForConnect.mockResolvedValue({ accessToken: 'adc_agent_cloud', cloudBaseUrl: 'https://cloud.example', - source: 'login', }); vi.stubGlobal( 'fetch', @@ -111,7 +110,7 @@ test('connect without remote config reports cloud profile authorization failures } }); -test('connect without remote config rejects unsupported cloud profile keys before writing config', async () => { +test('connect without remote config reports unsupported cloud profile keys', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-connect-cloud-invalid-')); const stateDir = path.join(tempRoot, '.state'); mockCloudConnectionProfile({ @@ -126,10 +125,9 @@ test('connect without remote config rejects unsupported cloud profile keys befor try { await assert.rejects(connectWithGeneratedCloudProfile(stateDir), (error: unknown) => { assert.equal((error as { code?: string }).code, 'COMMAND_FAILED'); - assert.match((error as Error).message, /unsupported remote config key/); + assert.match((error as Error).message, /invalid remote config/); return true; }); - assert.equal(fs.existsSync(path.join(stateDir, 'remote-connections', 'generated')), false); } finally { fs.rmSync(tempRoot, { recursive: true, force: true }); } @@ -139,7 +137,6 @@ function mockCloudConnectionProfile(connection: Record): Return mockedResolveCloudAccessForConnect.mockResolvedValue({ accessToken: 'adc_agent_cloud', cloudBaseUrl: 'https://cloud.example', - source: 'login', }); const fetchMock = vi.fn( async () => diff --git a/src/cli/auth-session.ts b/src/cli/auth-session.ts index 4842abbb0..54fbbf3a7 100644 --- a/src/cli/auth-session.ts +++ b/src/cli/auth-session.ts @@ -150,21 +150,18 @@ export async function resolveCloudAccessForConnect(options: { }): Promise<{ accessToken: string; cloudBaseUrl: string; - source: 'flag' | 'env' | 'cli-session' | 'login'; }> { const env = options.env ?? options.io?.env ?? process.env; if (hasToken(options.flags.daemonAuthToken)) { return { accessToken: options.flags.daemonAuthToken, cloudBaseUrl: resolveCloudBaseUrl(env), - source: 'flag', }; } if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { return { accessToken: env.AGENT_DEVICE_DAEMON_AUTH_TOKEN, cloudBaseUrl: resolveCloudBaseUrl(env), - source: 'env', }; } const sessionAccess = await resolveCliSessionAccess({ @@ -177,7 +174,6 @@ export async function resolveCloudAccessForConnect(options: { return { accessToken: sessionAccess.accessToken, cloudBaseUrl: sessionAccess.cloudBaseUrl, - source: 'cli-session', }; } if (options.flags.noLogin) { @@ -195,7 +191,6 @@ export async function resolveCloudAccessForConnect(options: { return { accessToken: login.accessToken, cloudBaseUrl: login.session.cloudBaseUrl, - source: 'login', }; } diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts index 3d7b40de0..68cfaf150 100644 --- a/src/cli/cloud-connection-profile.ts +++ b/src/cli/cloud-connection-profile.ts @@ -2,11 +2,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import path from 'node:path'; import { resolveRemoteConfigProfile } from '../remote-config.ts'; -import { - REMOTE_CONFIG_FIELD_SPECS, - type RemoteConfigProfile, - type ResolvedRemoteConfigProfile, -} from '../remote-config-schema.ts'; +import type { RemoteConfigProfile, ResolvedRemoteConfigProfile } from '../remote-config-schema.ts'; import { profileToCliFlags } from '../utils/remote-config.ts'; import { AppError, asAppError } from '../utils/errors.ts'; import type { CliFlags } from '../utils/command-schema.ts'; @@ -22,7 +18,6 @@ type CloudConnectionProfileResponse = { }; type EnvMap = Record; -const REMOTE_CONFIG_KEYS = new Set(REMOTE_CONFIG_FIELD_SPECS.map((spec) => spec.key)); export async function resolveCloudConnectProfile(options: { flags: CliFlags; @@ -108,7 +103,7 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { throw new AppError('COMMAND_FAILED', 'Cloud connection profile response is missing profile.'); } if (connection.remoteConfigProfile !== undefined) { - return validateRemoteConfigProfile(connection.remoteConfigProfile, 'remoteConfigProfile'); + return parseRemoteConfigProfile(connection.remoteConfigProfile); } throw new AppError( 'COMMAND_FAILED', @@ -116,29 +111,17 @@ function parseConnectionProfile(value: unknown): RemoteConfigProfile { ); } -function validateRemoteConfigProfile(value: unknown, source: string): RemoteConfigProfile { +function parseRemoteConfigProfile(value: unknown): RemoteConfigProfile { if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AppError('COMMAND_FAILED', `Cloud connection profile ${source} is invalid.`); - } - const profile = value as Record; - const keys = Object.keys(profile); - if (keys.length === 0) { - throw new AppError('COMMAND_FAILED', `Cloud connection profile ${source} is empty.`); - } - const unsupportedKey = keys.find( - (key) => !REMOTE_CONFIG_KEYS.has(key as keyof RemoteConfigProfile), - ); - if (unsupportedKey) { throw new AppError( 'COMMAND_FAILED', - 'Cloud connection profile returned unsupported remote config key.', - { - key: unsupportedKey, - source, - }, + 'Cloud connection profile remoteConfigProfile is invalid.', ); } - return profile as RemoteConfigProfile; + if (Object.keys(value).length === 0) { + throw new AppError('COMMAND_FAILED', 'Cloud connection profile remoteConfigProfile is empty.'); + } + return value as RemoteConfigProfile; } function resolveGeneratedRemoteConfigProfile(options: { diff --git a/src/cli/commands/connection.ts b/src/cli/commands/connection.ts index b64246743..75c014037 100644 --- a/src/cli/commands/connection.ts +++ b/src/cli/commands/connection.ts @@ -29,7 +29,12 @@ export const connectCommand: ClientCommandHandler = async ({ flags, client }) => const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; const resolved = flags.remoteConfig ? resolveRemoteConnectFlags(flags) - : await resolveGeneratedCloudConnectFlags(flags, stateDir); + : await resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); const connectFlags = resolved.flags; const tenant = connectFlags.tenant; const runId = connectFlags.runId; @@ -143,18 +148,6 @@ function resolveRemoteConnectFlags(flags: CliFlags): { }; } -async function resolveGeneratedCloudConnectFlags( - flags: CliFlags, - stateDir: string, -): Promise<{ flags: CliFlags; remoteConfigPath: string }> { - return resolveCloudConnectProfile({ - flags, - stateDir, - cwd: process.cwd(), - env: process.env, - }); -} - export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index fc1bb6859..27586591f 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -774,32 +774,7 @@ agent-device disconnect --remote-config ./agent-device.remote.json ### Cloud profile response migration -Cloud/control-plane clients serving `/api/control-plane/connection-profile` must return the remote profile as an object: - -```json -{ - "connection": { - "remoteConfigProfile": { - "daemonBaseUrl": "https://bridge.example.com/agent-device", - "daemonTransport": "http", - "tenant": "acme", - "runId": "run-123" - } - } -} -``` - -Do not return the old JSON-string wrapper: - -```json -{ - "connection": { - "remoteConfig": "{\"daemonBaseUrl\":\"https://bridge.example.com/agent-device\"}" - } -} -``` - -The CLI now rejects that legacy shape before writing a generated remote config. +`/api/control-plane/connection-profile` must return an object at `connection.remoteConfigProfile`, for example `{"connection":{"remoteConfigProfile":{"daemonBaseUrl":"https://bridge.example.com/agent-device","daemonTransport":"http","tenant":"acme","runId":"run-123"}}}`. The old `connection.remoteConfig` JSON-string wrapper is rejected. ## Session inspection