diff --git a/src/__tests__/cloud-connect-auth.test.ts b/src/__tests__/cloud-connect-auth.test.ts new file mode 100644 index 00000000..c2fd239a --- /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/__tests__/cloud-connect-profile.test.ts b/src/__tests__/cloud-connect-profile.test.ts new file mode 100644 index 00000000..fc466245 --- /dev/null +++ b/src/__tests__/cloud-connect-profile.test.ts @@ -0,0 +1,205 @@ +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 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({ + remoteConfig: JSON.stringify({ + daemonBaseUrl: 'https://bridge.example.com/agent-device', + daemonTransport: 'http', + tenant: 'acme', + runId: 'demo-run-001', + }), + }); + + try { + 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 }); + } +}); + +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', + }); + 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 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({ + 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, /invalid remote config/); + return true; + }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } +}); + +function mockCloudConnectionProfile(connection: Record): ReturnType { + mockedResolveCloudAccessForConnect.mockResolvedValue({ + accessToken: 'adc_agent_cloud', + cloudBaseUrl: 'https://cloud.example', + }); + 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/cli/auth-session.ts b/src/cli/auth-session.ts index a592467d..54fbbf3a 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', }; } @@ -143,13 +142,69 @@ export async function resolveRemoteAuth(options: { }; } +export async function resolveCloudAccessForConnect(options: { + stateDir: string; + flags: CliFlags; + env?: EnvMap; + io?: AuthIo; +}): Promise<{ + accessToken: string; + cloudBaseUrl: string; +}> { + const env = options.env ?? options.io?.env ?? process.env; + if (hasToken(options.flags.daemonAuthToken)) { + return { + accessToken: options.flags.daemonAuthToken, + cloudBaseUrl: resolveCloudBaseUrl(env), + }; + } + if (hasToken(env.AGENT_DEVICE_DAEMON_AUTH_TOKEN)) { + return { + accessToken: env.AGENT_DEVICE_DAEMON_AUTH_TOKEN, + cloudBaseUrl: resolveCloudBaseUrl(env), + }; + } + const sessionAccess = await resolveCliSessionAccess({ + stateDir: options.stateDir, + flags: options.flags, + env, + io: options.io, + }); + if (sessionAccess) { + return { + accessToken: sessionAccess.accessToken, + cloudBaseUrl: sessionAccess.cloudBaseUrl, + }; + } + 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, + env, + io: options.io, + commandLabel: 'agent-device connect', + }); + return { + accessToken: login.accessToken, + cloudBaseUrl: login.session.cloudBaseUrl, + }; +} + 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 +266,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 { @@ -286,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; diff --git a/src/cli/cloud-connection-profile.ts b/src/cli/cloud-connection-profile.ts new file mode 100644 index 00000000..68cfaf15 --- /dev/null +++ b/src/cli/cloud-connection-profile.ts @@ -0,0 +1,183 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { resolveRemoteConfigProfile } from '../remote-config.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'; +import { resolveCloudAccessForConnect } from './auth-session.ts'; + +const CONNECTION_PROFILE_PATH = '/api/control-plane/connection-profile'; +const HTTP_TIMEOUT_MS = 15_000; + +type CloudConnectionProfileResponse = { + connection?: { + 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 }> { + 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 = resolveGeneratedRemoteConfigProfile({ + 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 !== undefined) { + return parseRemoteConfigProfile(connection.remoteConfigProfile); + } + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile did not include remoteConfigProfile.', + ); +} + +function parseRemoteConfigProfile(value: unknown): RemoteConfigProfile { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError( + 'COMMAND_FAILED', + 'Cloud connection profile remoteConfigProfile is invalid.', + ); + } + if (Object.keys(value).length === 0) { + throw new AppError('COMMAND_FAILED', 'Cloud connection profile remoteConfigProfile is empty.'); + } + return value as RemoteConfigProfile; +} + +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; +}): 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 2b0c86bd..5799f6d1 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 a7a4f671..75c01403 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,18 @@ 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 resolveCloudConnectProfile({ + flags, + stateDir, + cwd: process.cwd(), + env: process.env, + }); + const connectFlags = resolved.flags; + const tenant = connectFlags.tenant; + const runId = connectFlags.runId; if (!tenant) { throw new AppError( 'INVALID_ARGS', @@ -42,23 +50,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 +68,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 +89,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 +130,24 @@ 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, + }; +} + export const disconnectCommand: ClientCommandHandler = async ({ flags, client }) => { const session = flags.session ?? 'default'; const stateDir = resolveDaemonPaths(flags.stateDir).baseDir; diff --git a/src/utils/__tests__/args.test.ts b/src/utils/__tests__/args.test.ts index fd8f86b2..2ca51082 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 70b03d47..96006e5f 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. @@ -1437,7 +1444,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 ad10a962..8abc40b6 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]; diff --git a/test/skillgym/suites/agent-device-smoke-suite.ts b/test/skillgym/suites/agent-device-smoke-suite.ts index 6b22e9ff..70e328e2 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 8ed1a8a7..27586591 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,10 @@ 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. +- 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. - 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. @@ -761,6 +772,10 @@ 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 + +`/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 ```bash