diff --git a/CHANGELOG.md b/CHANGELOG.md index d7cbf929b..052a1ff54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unreleased +- Security: repository `./agent-device.json` now accepts only project-safe automation defaults. It rejects daemon endpoint/auth/transport/server settings, tenant/run/lease selectors, provider/cloud and Metro connection fields, headers, executable reporter modules, local write destinations, and other operator-controlled values before local module loading or any daemon health/RPC request. Put remote endpoint and token together in protected CI environment variables, user config, an explicit `--config` file, or the existing `connect`/`--remote-config` workflow. Daemon auth tokens no longer travel in serialized command flags. - `viewport` is now rejected during capability admission on Apple targets instead of reaching the device and failing inside dispatch. No Apple backend can resize a screen — simulator and device geometry is fixed by the selected device type — so `viewport` on iOS/iPadOS/tvOS/macOS now fails with `UNSUPPORTED_OPERATION`, `viewport is not supported on this device`, and a hint pointing at `--platform web` and at picking a different simulator. `capabilities` no longer advertises `viewport` on Apple targets. Web viewport resizing (`agent-device viewport 1280 900 --platform web`) is unchanged, and Android was already denied. - `--save-script` is now accepted only by the commands that declare it — `open`, `close`, and `replay`. A hand-built daemon request (or a `batch` step) that set `saveScript` on any other command, such as `record` or `trace`, used to arm script publication and could write a `.ad` artifact; it is now rejected with `INVALID_ARGS` before the request reaches admission, the device, or any handler. CLI, Node, and MCP usage of `--save-script` on its documented commands is unchanged. - `diff screenshot` no longer runs the retired best-effort OCR and non-text analyzers. Their optional `ocr` and `nonTextDeltas` fields remain in the result type for source compatibility but are no longer emitted; use the baseline/current images and diff artifact with vision for qualitative interpretation. diff --git a/docs/agents/cli-flags.md b/docs/agents/cli-flags.md index 545790058..ccad74e09 100644 --- a/docs/agents/cli-flags.md +++ b/docs/agents/cli-flags.md @@ -9,7 +9,10 @@ mattering — threading it further is the common failure, not stopping too early flag; find the owner with `rg -n "|supportedFlags|allowedFlags" src/commands src/cli-schema src/cli/parser`. For schema-only CLI commands (`cdp`, `auth`, `connect`, `proxy`, `react-devtools`, `web`) the owner is - `SCHEMA_ONLY_CLI_COMMAND_SCHEMAS` in `src/cli-schema/command-overrides.ts`. + `SCHEMA_ONLY_CLI_COMMAND_SCHEMAS` in `src/cli-schema/command-overrides.ts`. New flags are + operator-only by default. Add a flag to `PROJECT_CONFIG_FLAG_KEYS` in + `src/cli-schema/cli-config.ts` only when repository control is safe; this positive allowlist is + the completeness gate. 2. `src/commands/cli-grammar/*`: read the CLI flag into command input. 3. `src/commands/command-projection.ts` and command-family projection helpers: write the input into the daemon request only if the flag affects daemon execution. diff --git a/packages/contracts/src/cli-flags.ts b/packages/contracts/src/cli-flags.ts index e7269e7c3..05ec054b9 100644 --- a/packages/contracts/src/cli-flags.ts +++ b/packages/contracts/src/cli-flags.ts @@ -163,4 +163,10 @@ export type CliFlags = CloudProviderProfileFields & version: boolean; }; -export type DaemonExcludedCliFlag = 'json' | 'help' | 'version' | 'batchSteps' | 'replayMaestro'; +export type DaemonExcludedCliFlag = + | 'json' + | 'help' + | 'version' + | 'batchSteps' + | 'replayMaestro' + | 'daemonAuthToken'; diff --git a/packages/contracts/src/client-connection.ts b/packages/contracts/src/client-connection.ts index 3899bc012..ccd39a6dd 100644 --- a/packages/contracts/src/client-connection.ts +++ b/packages/contracts/src/client-connection.ts @@ -15,8 +15,13 @@ import type { RemoteConnectionProfileFields, } from './remote-config-fields.ts'; +export type AgentDeviceDaemonTransportContext = { + authToken?: string; +}; + export type AgentDeviceDaemonTransport = ( req: Omit, + context?: AgentDeviceDaemonTransportContext, ) => Promise; export type AgentDeviceClientConfig = RemoteConnectionProfileFields & diff --git a/src/__tests__/agent-device-client-auth.test.ts b/src/__tests__/agent-device-client-auth.test.ts new file mode 100644 index 000000000..db3543ac3 --- /dev/null +++ b/src/__tests__/agent-device-client-auth.test.ts @@ -0,0 +1,33 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import type { + AgentDeviceDaemonTransport, + AgentDeviceDaemonTransportContext, +} from '@agent-device/contracts/client'; +import type { DaemonRequest } from '@agent-device/kernel/contracts'; +import { createAgentDeviceClient } from '../agent-device-client.ts'; + +test('client sends daemon auth only through the typed transport context', async () => { + const requests: Array> = []; + const contexts: Array = []; + const transport: AgentDeviceDaemonTransport = async (request, context) => { + requests.push(request); + contexts.push(context); + return { ok: true, data: { devices: [] } }; + }; + const client = createAgentDeviceClient( + { + cwd: '/tmp/agent-device', + daemonBaseUrl: 'https://daemon.example.test', + daemonAuthToken: 'synthetic-client-token', + }, + { transport }, + ); + + await client.devices.list(); + + assert.equal(requests.length, 1); + assert.equal(contexts[0]?.authToken, 'synthetic-client-token'); + assert.equal(Object.hasOwn(requests[0]?.flags ?? {}, 'daemonAuthToken'), false); + assert.doesNotMatch(JSON.stringify(requests[0]), /synthetic-client-token/); +}); diff --git a/src/__tests__/cli-auth-diagnostics.test.ts b/src/__tests__/cli-auth-diagnostics.test.ts new file mode 100644 index 000000000..e49c2579d --- /dev/null +++ b/src/__tests__/cli-auth-diagnostics.test.ts @@ -0,0 +1,45 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { emitDiagnostic } from '../utils/diagnostics.ts'; +import { runCliCapture } from './cli-capture.ts'; + +test('daemon auth tokens are scrubbed from CLI diagnostics and serialized requests', async () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-auth-diagnostics-')); + const authToken = 'opaque-auth-value-1348'; + try { + const result = await runCliCapture( + [ + 'devices', + '--daemon-base-url', + 'https://daemon.example.test', + '--daemon-auth-token', + authToken, + '--json', + ], + { + env: { HOME: home }, + sendToDaemon: async () => { + emitDiagnostic({ + phase: 'synthetic_transport_failure', + data: { message: `Transport echoed ${authToken}` }, + }); + return { ok: false, error: { code: 'COMMAND_FAILED', message: 'transport failed' } }; + }, + }, + ); + + assert.equal(result.code, 1); + assert.equal(result.transportOptions[0]?.authToken, authToken); + assert.doesNotMatch(JSON.stringify(result.calls), new RegExp(authToken)); + const payload = JSON.parse(result.stdout) as { error?: { logPath?: string } }; + assert.ok(payload.error?.logPath); + const diagnostics = fs.readFileSync(payload.error.logPath, 'utf8'); + assert.doesNotMatch(diagnostics, new RegExp(authToken)); + assert.match(diagnostics, /Transport echoed \[REDACTED\]/); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +}); diff --git a/src/__tests__/cli-capture.ts b/src/__tests__/cli-capture.ts index 1315548b0..a4a89f68d 100644 --- a/src/__tests__/cli-capture.ts +++ b/src/__tests__/cli-capture.ts @@ -26,6 +26,7 @@ export type CapturedCliRun = { stdout: string; stderr: string; calls: CapturedDaemonRequest[]; + transportOptions: DaemonTransportOptions[]; }; export type CliCaptureOptions = { @@ -33,6 +34,7 @@ export type CliCaptureOptions = { env?: Record; stateDirPrefix?: string; passthroughBufferWrites?: boolean; + useRealDaemonClient?: boolean; sendToDaemon?: ( req: CapturedDaemonRequest, options?: DaemonTransportOptions, @@ -58,6 +60,7 @@ export async function runCliCapture( let stderr = ''; let code: number | null = null; const calls: CapturedDaemonRequest[] = []; + const transportOptions: DaemonTransportOptions[] = []; const stateDir = options.stateDirPrefix ? fs.mkdtempSync(path.join(os.tmpdir(), options.stateDirPrefix)) : undefined; @@ -98,6 +101,7 @@ export async function runCliCapture( daemonOptions?: DaemonTransportOptions, ): Promise => { calls.push(req); + transportOptions.push(daemonOptions); if (options.sendToDaemon) { return await options.sendToDaemon(req, daemonOptions); } @@ -105,7 +109,7 @@ export async function runCliCapture( }; try { - await runCli(argv, { sendToDaemon }); + await runCli(argv, options.useRealDaemonClient ? undefined : { sendToDaemon }); } catch (error) { if (error instanceof ExitSignal) code = error.code; else throw error; @@ -118,5 +122,5 @@ export async function runCliCapture( process.chdir(originalCwd); } - return { code, stdout, stderr, calls }; + return { code, stdout, stderr, calls, transportOptions }; } diff --git a/src/__tests__/cli-config-fixtures.ts b/src/__tests__/cli-config-fixtures.ts new file mode 100644 index 000000000..d43c76534 --- /dev/null +++ b/src/__tests__/cli-config-fixtures.ts @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +export function makeTempWorkspace(): { root: string; home: string; project: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-')); + const home = path.join(root, 'home'); + const project = path.join(root, 'project'); + fs.mkdirSync(home, { recursive: true }); + fs.mkdirSync(project, { recursive: true }); + return { root, home, project }; +} diff --git a/src/__tests__/cli-config-trust.test.ts b/src/__tests__/cli-config-trust.test.ts new file mode 100644 index 000000000..779579a51 --- /dev/null +++ b/src/__tests__/cli-config-trust.test.ts @@ -0,0 +1,249 @@ +import { test } from 'vitest'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import http from 'node:http'; +import path from 'node:path'; +import { runCliCapture } from './cli-capture.ts'; +import { makeTempWorkspace } from './cli-config-fixtures.ts'; +import { + closeLoopbackServer, + listenOnLoopback, + skipWhenLoopbackUnavailable, +} from './test-utils/loopback.ts'; + +test('project config rejects remote connection fields before daemon dispatch without echoing values', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test', + daemonAuthToken: 'token-123', + daemonTransport: 'http', + tenant: 'tenant-123', + runId: 'run-123', + leaseId: 'lease-123', + platform: 'ios', + }), + 'utf8', + ); + + const result = await runCliCapture(['press', '10', '20'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /daemonBaseUrl/); + assert.match(output, /not allowed in project config file/i); + assert.doesNotMatch(output, /daemon\.example\.test|token-123|tenant-123|run-123|lease-123/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test.each([ + ['daemonAuthToken', 'synthetic-token'], + ['daemonTransport', 'http'], + ['daemonServerMode', 'http'], + ['tenant', 'tenant-controlled-by-project'], + ['sessionIsolation', 'tenant'], + ['runId', 'project-run'], + ['leaseId', 'project-lease'], + ['leaseBackend', 'android-instance'], + ['provider', 'browserstack'], + ['metroBearerToken', 'synthetic-metro-token'], + ['header', ['authorization: Bearer synthetic-header-token']], +] as const)( + 'project config rejects protected %s without dispatching or echoing its value', + async (key, value) => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ [key]: value }), + 'utf8', + ); + + const result = await runCliCapture(['devices'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, new RegExp(key)); + assert.doesNotMatch( + output, + /synthetic-token|tenant-controlled-by-project|project-run|project-lease|browserstack|synthetic-metro-token|synthetic-header-token/, + ); + + fs.rmSync(root, { recursive: true, force: true }); + }, +); + +test('project config rejects a custom reporter before importing repository code or dispatching', async () => { + const { root, home, project } = makeTempWorkspace(); + const importMarker = path.join(project, 'reporter-imported'); + fs.writeFileSync( + path.join(project, 'project-reporter.mjs'), + [ + "import fs from 'node:fs';", + `fs.writeFileSync(${JSON.stringify(importMarker)}, 'imported', 'utf8');`, + "export default { name: 'project-controlled-reporter' };", + ].join('\n'), + 'utf8', + ); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ reporter: ['./project-reporter.mjs'] }), + 'utf8', + ); + + const result = await runCliCapture(['test', './suite'], { + cwd: project, + env: { HOME: home }, + }); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.equal(fs.existsSync(importMarker), false); + assert.match(`${result.stdout}\n${result.stderr}`, /reporter/); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /project-reporter\.mjs/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test.each([ + ['reportJunit', './project.junit.xml', ['test', './suite']], + ['saveScript', './project.ad', ['open', 'Demo']], + ['launchConsole', './project.console.log', ['open', 'Demo']], +] as const)( + 'project config rejects local write sink %s before dispatch or file creation', + async (key, value, argv) => { + const { root, home, project } = makeTempWorkspace(); + const dispatchMarker = path.join(project, 'daemon-dispatched'); + const outputPath = path.join(project, value); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ [key]: value }), + 'utf8', + ); + + const result = await runCliCapture([...argv], { + cwd: project, + env: { HOME: home }, + sendToDaemon: async () => { + fs.writeFileSync(dispatchMarker, 'dispatched', 'utf8'); + return { ok: true, data: {} }; + }, + }); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + assert.equal(fs.existsSync(dispatchMarker), false); + assert.equal(fs.existsSync(outputPath), false); + assert.match(`${result.stdout}\n${result.stderr}`, new RegExp(key)); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, new RegExp(path.basename(value))); + + fs.rmSync(root, { recursive: true, force: true }); + }, +); + +test('project config cannot pair an endpoint with an environment token', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ daemonBaseUrl: 'https://example.trycloudflare.com/agent-device' }), + 'utf8', + ); + + const result = await runCliCapture(['devices'], { + cwd: project, + env: { HOME: home, AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'operator-token' }, + }); + + assert.equal(result.code, 1); + assert.equal(result.calls.length, 0); + const output = `${result.stdout}\n${result.stderr}`; + assert.match(output, /daemonBaseUrl/); + assert.doesNotMatch(output, /example\.trycloudflare\.com|operator-token/); + + fs.rmSync(root, { recursive: true, force: true }); +}); + +test('project-selected endpoint with an environment token makes no health or RPC request', async (t) => { + if (await skipWhenLoopbackUnavailable(t, 'project config transport rejection coverage')) return; + const { root, home, project } = makeTempWorkspace(); + const requests: string[] = []; + const server = http.createServer((request, response) => { + requests.push(`${request.method} ${request.url}`); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end(JSON.stringify({ ok: true, rpcProtocolVersion: 2 })); + }); + const port = await listenOnLoopback(server); + fs.writeFileSync( + path.join(project, 'agent-device.json'), + JSON.stringify({ daemonBaseUrl: `http://127.0.0.1:${port}/agent-device` }), + 'utf8', + ); + + try { + const result = await runCliCapture(['devices'], { + cwd: project, + env: { HOME: home, AGENT_DEVICE_DAEMON_AUTH_TOKEN: 'operator-token' }, + useRealDaemonClient: true, + }); + + assert.equal(result.code, 1); + assert.deepEqual(requests, []); + assert.doesNotMatch(`${result.stdout}\n${result.stderr}`, /operator-token/); + } finally { + await closeLoopbackServer(server); + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test('user and explicit config retain remote auth through the ordinary CLI adapter', async () => { + const { root, home, project } = makeTempWorkspace(); + fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); + fs.writeFileSync( + path.join(home, '.agent-device', 'config.json'), + JSON.stringify({ + daemonBaseUrl: 'https://daemon.example.test', + daemonAuthToken: 'user-token', + daemonTransport: 'http', + tenant: 'user-tenant', + }), + 'utf8', + ); + + const userResult = await runCliCapture(['devices'], { + cwd: project, + env: { HOME: home }, + }); + assert.equal(userResult.code, null); + assert.equal(userResult.calls[0]?.flags?.daemonBaseUrl, 'https://daemon.example.test'); + assert.equal(Object.hasOwn(userResult.calls[0]?.flags ?? {}, 'daemonAuthToken'), false); + assert.equal(userResult.transportOptions[0]?.authToken, 'user-token'); + + const explicitConfig = path.join(root, 'explicit.json'); + fs.writeFileSync( + explicitConfig, + JSON.stringify({ + daemonBaseUrl: 'https://explicit.example.test', + daemonAuthToken: 'explicit-token', + }), + 'utf8', + ); + const explicitResult = await runCliCapture(['devices', '--config', explicitConfig], { + cwd: project, + env: { HOME: home }, + }); + assert.equal(explicitResult.code, null); + assert.equal(explicitResult.calls[0]?.flags?.daemonBaseUrl, 'https://explicit.example.test'); + assert.equal(Object.hasOwn(explicitResult.calls[0]?.flags ?? {}, 'daemonAuthToken'), false); + assert.equal(explicitResult.transportOptions[0]?.authToken, 'explicit-token'); + + fs.rmSync(root, { recursive: true, force: true }); +}); diff --git a/src/__tests__/cli-config.test.ts b/src/__tests__/cli-config.test.ts index c6c4f2e44..b691c5a5d 100644 --- a/src/__tests__/cli-config.test.ts +++ b/src/__tests__/cli-config.test.ts @@ -1,7 +1,6 @@ import { test } from 'vitest'; import assert from 'node:assert/strict'; import fs from 'node:fs'; -import os from 'node:os'; import path from 'node:path'; import { hashRemoteConfigFile, @@ -9,15 +8,7 @@ import { readRemoteConnectionState, } from '../remote/remote-connection-state.ts'; import { runCliCapture, type CapturedDaemonRequest } from './cli-capture.ts'; - -function makeTempWorkspace(): { root: string; home: string; project: string } { - const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-config-')); - const home = path.join(root, 'home'); - const project = path.join(root, 'project'); - fs.mkdirSync(home, { recursive: true }); - fs.mkdirSync(project, { recursive: true }); - return { root, home, project }; -} +import { makeTempWorkspace } from './cli-config-fixtures.ts'; test('CLI merges config defaults with precedence user < project < env < CLI', async () => { const { root, home, project } = makeTempWorkspace(); @@ -72,11 +63,11 @@ test('config can set appsFilter through canonical enum values', async () => { fs.rmSync(root, { recursive: true, force: true }); }); -test('config can provide install-from-source GitHub Actions artifact source', async () => { +test('user config can provide install-from-source GitHub Actions artifact source', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); fs.writeFileSync( - path.join(project, 'agent-device.json'), + path.join(home, '.agent-device', 'config.json'), JSON.stringify({ platform: 'android', installSource: { @@ -134,84 +125,6 @@ test('command-specific config defaults are ignored for commands that do not supp fs.rmSync(root, { recursive: true, force: true }); }); -test('interaction commands preserve remote config defaults', async () => { - const { root, home, project } = makeTempWorkspace(); - fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); - fs.writeFileSync( - path.join(project, 'agent-device.json'), - JSON.stringify({ - daemonBaseUrl: 'https://daemon.example.test', - daemonAuthToken: 'token-123', - daemonTransport: 'http', - tenant: 'tenant-123', - runId: 'run-123', - leaseId: 'lease-123', - platform: 'ios', - }), - 'utf8', - ); - - const commands = [ - ['press', '10', '20'], - ['click', '10', '20'], - ['fill', '10', '20', 'hello'], - ['longpress', '10', '20'], - ['get', 'text', '@e1'], - ]; - - for (const command of commands) { - const result = await runCliCapture([...command, '--json'], { - cwd: project, - env: { HOME: home }, - }); - - assert.equal(result.code, null, command.join(' ')); - assert.equal(result.calls.length, 1, command.join(' ')); - assert.equal(result.calls[0]?.flags?.daemonBaseUrl, 'https://daemon.example.test'); - assert.equal(result.calls[0]?.flags?.daemonAuthToken, 'token-123'); - assert.equal(result.calls[0]?.flags?.daemonTransport, 'http'); - assert.equal(result.calls[0]?.flags?.tenant, 'tenant-123'); - assert.equal(result.calls[0]?.flags?.runId, 'run-123'); - assert.equal(result.calls[0]?.flags?.leaseId, 'lease-123'); - assert.equal(result.calls[0]?.flags?.platform, 'ios'); - } - - fs.rmSync(root, { recursive: true, force: true }); -}); - -test('normal config can point commands at a direct remote daemon proxy', async () => { - const { root, home, project } = makeTempWorkspace(); - fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); - fs.writeFileSync( - path.join(project, 'agent-device.json'), - JSON.stringify({ - daemonBaseUrl: 'https://example.trycloudflare.com/agent-device', - daemonAuthToken: 'proxy-token', - }), - 'utf8', - ); - - const result = await runCliCapture(['devices', '--json'], { - cwd: project, - env: { HOME: home }, - }); - - assert.equal(result.code, null); - assert.equal(result.calls.length, 1); - assert.equal(result.calls[0]?.command, 'devices'); - assert.equal( - result.calls[0]?.flags?.daemonBaseUrl, - 'https://example.trycloudflare.com/agent-device', - ); - assert.equal(result.calls[0]?.flags?.daemonAuthToken, 'proxy-token'); - assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'platform'), false); - assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'remoteConfig'), false); - assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'tenant'), false); - assert.equal(Object.hasOwn(result.calls[0]?.flags ?? {}, 'runId'), false); - - fs.rmSync(root, { recursive: true, force: true }); -}); - test('explicit --config path overrides default config discovery', async () => { const { root, home, project } = makeTempWorkspace(); fs.mkdirSync(path.join(home, '.agent-device'), { recursive: true }); diff --git a/src/__tests__/cli-react-devtools-session.test.ts b/src/__tests__/cli-react-devtools-session.test.ts index 38ca9b748..b6e7fafe1 100644 --- a/src/__tests__/cli-react-devtools-session.test.ts +++ b/src/__tests__/cli-react-devtools-session.test.ts @@ -15,12 +15,42 @@ import { hashRemoteConfigFile, writeRemoteConnectionState, } from '../remote/remote-connection-state.ts'; -import type { DaemonRequest, DaemonResponse } from '../daemon/client/daemon-client.ts'; +import type { + DaemonRequest, + DaemonResponse, + sendToDaemon as SendToDaemon, +} from '../daemon/client/daemon-client.ts'; afterEach(() => { vi.clearAllMocks(); }); +function createLimrunConnectionFixture(): { tempRoot: string; stateDir: string } { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-limrun-')); + const stateDir = path.join(tempRoot, 'state'); + const remoteConfigPath = path.join(tempRoot, 'limrun.json'); + fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' })); + writeRemoteConnectionState({ + stateDir, + state: { + version: 1, + session: 'limrun-android', + remoteConfigPath, + remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), + daemon: {}, + tenant: 'limrun', + runId: 'run-1', + leaseId: 'lease-1', + leaseBackend: 'android-instance', + leaseProvider: 'limrun', + platform: 'android', + connectedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }); + return { tempRoot, stateDir }; +} + test('react-devtools uses active remote connection session after defaults are merged', async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-session-')); const stateDir = path.join(tempRoot, 'state'); @@ -75,46 +105,41 @@ test('react-devtools uses active remote connection session after defaults are me }); test('react-devtools starts Limrun port reverse through the daemon', async () => { - const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-react-devtools-limrun-')); - const stateDir = path.join(tempRoot, 'state'); + const { tempRoot, stateDir } = createLimrunConnectionFixture(); const originalExit = process.exit; let exitCode: number | undefined; const restoreEnv = installIsolatedCliTestEnv(); (process as any).exit = ((code?: number) => { exitCode = code ?? 0; }) as typeof process.exit; - const remoteConfigPath = path.join(tempRoot, 'limrun.json'); - fs.writeFileSync(remoteConfigPath, JSON.stringify({ platform: 'android' })); - writeRemoteConnectionState({ - stateDir, - state: { - version: 1, - session: 'limrun-android', - remoteConfigPath, - remoteConfigHash: hashRemoteConfigFile(remoteConfigPath), - daemon: {}, - tenant: 'limrun', - runId: 'run-1', - leaseId: 'lease-1', - leaseBackend: 'android-instance', - leaseProvider: 'limrun', - platform: 'android', - connectedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }, - }); const requests: Array> = []; - const sendToDaemon = async (request: Omit): Promise => { + const transportOptions: Array[1]> = []; + const sendToDaemon = async ( + request: Omit, + options?: Parameters[1], + ): Promise => { requests.push(request); + transportOptions.push(options); return { ok: true, data: {} }; }; vi.mocked(runReactDevtoolsCommand).mockImplementationOnce(async (_args, options) => { - await options?.configureDirectPortReverse?.(); + assert.ok(options?.configureDirectPortReverse); + await options.configureDirectPortReverse(); return 0; }); try { - await runCli(['react-devtools', 'start', '--state-dir', stateDir], { sendToDaemon }); + await runCli( + [ + 'react-devtools', + 'start', + '--state-dir', + stateDir, + '--daemon-auth-token', + 'synthetic-direct-token', + ], + { sendToDaemon }, + ); } finally { restoreEnv(); process.exit = originalExit; @@ -124,11 +149,16 @@ test('react-devtools starts Limrun port reverse through the daemon', async () => assert.equal(exitCode, 0); assert.equal(requests.length, 1); const request = requests[0]; - assert.equal(request?.command, 'runtime'); - assert.deepEqual(request?.positionals, ['port-reverse']); - assert.equal(request?.session, 'limrun-android'); - assert.equal(request?.flags?.leaseProvider, 'limrun'); - assert.equal(request?.flags?.devicePort, 8097); - assert.equal(request?.flags?.hostPort, 8097); - assert.equal(request?.flags?.portReverseName, 'react-devtools'); + assert.ok(request); + assert.equal(request.command, 'runtime'); + assert.deepEqual(request.positionals, ['port-reverse']); + assert.equal(request.session, 'limrun-android'); + assert.equal(request.flags?.leaseProvider, 'limrun'); + assert.equal(request.flags?.devicePort, 8097); + assert.equal(request.flags?.hostPort, 8097); + assert.equal(request.flags?.portReverseName, 'react-devtools'); + assert.equal(Object.hasOwn(request.flags ?? {}, 'daemonAuthToken'), false); + assert.deepEqual(transportOptions, [{ authToken: 'synthetic-direct-token' }]); + const commandOptions = vi.mocked(runReactDevtoolsCommand).mock.calls[0]?.[1]; + assert.equal(Object.hasOwn(commandOptions?.flags ?? {}, 'daemonAuthToken'), false); }); diff --git a/src/agent-device-client.ts b/src/agent-device-client.ts index f95eeec28..261cec24a 100644 --- a/src/agent-device-client.ts +++ b/src/agent-device-client.ts @@ -103,7 +103,7 @@ export function createAgentDeviceClient( input?: Record, ): Promise> => { const merged = mergeClientOptions(config, options); - const response = await transport({ + const request = { session: resolveSessionName(merged.session), command, positionals, @@ -111,7 +111,8 @@ export function createAgentDeviceClient( flags: buildRequestFlags(merged, metadataFlags), runtime: merged.runtime, meta: buildMeta(merged), - }); + }; + const response = await transport(request, { authToken: merged.daemonAuthToken }); if (!response.ok) { throwDaemonError(response.error); } diff --git a/src/cli-schema/cli-config.ts b/src/cli-schema/cli-config.ts index e61fede63..7b5522d04 100644 --- a/src/cli-schema/cli-config.ts +++ b/src/cli-schema/cli-config.ts @@ -26,18 +26,113 @@ export function resolveConfigBackedFlagDefaults(options: { return mergeDefinedFlags(defaults, readEnvFlagDefaults(env, options.command)); } +type ConfigFileSource = 'user' | 'project' | 'explicit'; + +type ConfigPath = { path: string; required: boolean; source: ConfigFileSource }; + +// Project config is repository-controlled, so new flags are operator-only by default. +// Adding a key here is the only way to make it available to ./agent-device.json. +const PROJECT_CONFIG_FLAG_KEYS = new Set([ + 'json', + 'platform', + 'target', + 'device', + 'udid', + 'serial', + 'session', + 'sessionLock', + 'activity', + 'launchArgs', + 'launchUrl', + 'remote', + 'deviceHub', + 'testIme', + 'appsFilter', + 'clean', + 'force', + 'stale', + 'relaunch', + 'shutdown', + 'surface', + 'headless', + 'restart', + 'noRecord', + 'record', + 'recordAs', + 'snapshotInteractiveOnly', + 'snapshotDiff', + 'snapshotDepth', + 'snapshotScope', + 'snapshotRaw', + 'snapshotForceFull', + 'screenshotPixelDensity', + 'screenshotFullscreen', + 'screenshotMaxSize', + 'screenshotNoStabilize', + 'screenshotNormalizeStatusBar', + 'overlayRefs', + 'networkInclude', + 'baseline', + 'threshold', + 'count', + 'pointerCount', + 'fps', + 'quality', + 'hideTouches', + 'recordingScope', + 'intervalMs', + 'delayMs', + 'durationMs', + 'holdMs', + 'jitterPx', + 'pixels', + 'doubleTap', + 'verify', + 'settle', + 'settleQuietMs', + 'clickButton', + 'backMode', + 'pauseMs', + 'pattern', + 'kind', + 'perfTemplate', + 'responseLevel', + 'verbose', + 'cost', + 'timeoutMs', + 'retries', + 'failFast', + 'recordVideo', + 'replayUpdate', + 'replayMaestro', + 'replayFrom', + 'replayPlanDigest', + 'replayKeepSession', + 'findFirst', + 'findLast', + 'batchOnError', + 'batchMaxSteps', + 'retainPaths', + 'retentionMs', + 'shardAll', + 'shardSplit', + 'noLogin', +]); + function resolveConfigPaths( cwd: string, explicitCliConfigPath: string | undefined, env: EnvMap, -): Array<{ path: string; required: boolean }> { +): ConfigPath[] { const explicitConfig = explicitCliConfigPath ?? env.AGENT_DEVICE_CONFIG; if (explicitConfig) { - return [{ path: resolveInputPath(explicitConfig, cwd, env), required: true }]; + return [ + { path: resolveInputPath(explicitConfig, cwd, env), required: true, source: 'explicit' }, + ]; } return [ - { path: resolveUserConfigPath(env), required: false }, - { path: path.resolve(cwd, 'agent-device.json'), required: false }, + { path: resolveUserConfigPath(env), required: false, source: 'user' }, + { path: path.resolve(cwd, 'agent-device.json'), required: false, source: 'project' }, ]; } @@ -49,18 +144,17 @@ function resolveInputPath(inputPath: string, cwd: string, env: EnvMap): string { return resolveUserPath(inputPath, { cwd, env }); } -function loadConfigFileDefaults( - pathsToCheck: Array<{ path: string; required: boolean }>, -): Partial { +function loadConfigFileDefaults(pathsToCheck: ConfigPath[]): Partial { const merged: Partial = {}; for (const entry of pathsToCheck) { - const parsed = loadSingleConfigFile(entry.path, entry.required); + const parsed = loadSingleConfigFile(entry); mergeDefinedFlags(merged, parsed); } return merged; } -function loadSingleConfigFile(filePath: string, required: boolean): Partial { +function loadSingleConfigFile(entry: ConfigPath): Partial { + const { path: filePath, required } = entry; if (!fs.existsSync(filePath)) { if (required) { throw new AppError('INVALID_ARGS', `Config file not found: ${filePath}`); @@ -90,31 +184,43 @@ function loadSingleConfigFile(filePath: string, required: boolean): Partial, `config file ${filePath}`); + return parseConfigObject(parsed as Record, { + source: entry.source, + label: `${entry.source === 'project' ? 'project ' : ''}config file ${filePath}`, + }); } function parseConfigObject( source: Record, - sourceLabel: string, + origin: { source: ConfigFileSource; label: string }, ): Partial { const flags: Partial = {}; for (const [rawKey, rawValue] of Object.entries(source)) { - if (rawKey === 'installSource') { - flags.installSource = parseInstallSourceConfig(rawValue, sourceLabel); - continue; - } const key = rawKey as FlagKey; const spec = getOptionSpec(key); if (!spec) { - throw new AppError('INVALID_ARGS', `Unknown config key "${rawKey}" in ${sourceLabel}.`); + throw new AppError('INVALID_ARGS', `Unknown config key "${rawKey}" in ${origin.label}.`); } - if (!spec.config.enabled) { - throw new AppError('INVALID_ARGS', `Unsupported config key "${rawKey}" in ${sourceLabel}.`); + if (!spec.configurable) { + throw new AppError( + 'INVALID_ARGS', + `Config key "${rawKey}" is not allowed in ${origin.label}. This key is not supported in config files.`, + ); + } + if (origin.source === 'project' && !PROJECT_CONFIG_FLAG_KEYS.has(key)) { + throw new AppError( + 'INVALID_ARGS', + `Config key "${rawKey}" is not allowed in ${origin.label}. Move it to ~/.agent-device/config.json, pass it with --config or AGENT_DEVICE_CONFIG, or provide it through CLI flags/environment variables.`, + ); + } + if (key === 'installSource') { + flags.installSource = parseInstallSourceConfig(rawValue, origin.label); + continue; } (flags as Record)[key] = parseOptionValueFromSource( spec, rawValue, - sourceLabel, + origin.label, rawKey, ); } diff --git a/src/cli-schema/option-schema.test.ts b/src/cli-schema/option-schema.test.ts index 6835278b2..7237ca571 100644 --- a/src/cli-schema/option-schema.test.ts +++ b/src/cli-schema/option-schema.test.ts @@ -16,8 +16,7 @@ import { test('option schema exposes config/env metadata for global options', () => { const spec = getOptionSpec('platform'); assert.ok(spec); - assert.equal(spec.config.enabled, true); - assert.equal(spec.config.key, 'platform'); + assert.equal(spec.configurable, true); assert.deepEqual(spec.env.names, ['AGENT_DEVICE_PLATFORM']); assert.equal(spec.supportsCommand('open'), true); assert.equal(spec.supportsCommand('snapshot'), true); diff --git a/src/cli-schema/option-schema.ts b/src/cli-schema/option-schema.ts index b39c8b0c4..13127c035 100644 --- a/src/cli-schema/option-schema.ts +++ b/src/cli-schema/option-schema.ts @@ -11,10 +11,7 @@ import { export type OptionSpec = { key: FlagKey; flagDefinitions: readonly FlagDefinition[]; - config: { - enabled: boolean; - key: string; - }; + configurable: boolean; env: { names: readonly string[]; }; @@ -40,7 +37,7 @@ export function getOptionSpec(key: FlagKey): OptionSpec | undefined { } export function getConfigurableOptionSpecs(command: string | null): OptionSpec[] { - return optionSpecs.filter((spec) => spec.config.enabled && spec.supportsCommand(command)); + return optionSpecs.filter((spec) => spec.configurable && spec.supportsCommand(command)); } export function isFlagSupportedForCommand(key: FlagKey, command: string | null): boolean { @@ -82,10 +79,7 @@ function buildOptionSpecs(): OptionSpec[] { .map(([key, flagDefinitions]) => ({ key, flagDefinitions, - config: { - enabled: !CONFIG_EXCLUDED_FLAG_KEYS.has(key), - key, - }, + configurable: !CONFIG_EXCLUDED_FLAG_KEYS.has(key), env: { names: ENV_EXCLUDED_FLAG_KEYS.has(key) ? [] : [buildPrimaryEnvVarName(key)], }, diff --git a/src/cli.ts b/src/cli.ts index e7d51b5ed..6f5ae2617 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { emitDiagnostic, flushDiagnosticsToSessionFile, getDiagnosticsMeta, + registerDiagnosticSensitiveValue, withDiagnosticsScope, } from './utils/diagnostics.ts'; import { resolveDaemonPaths } from './daemon/config.ts'; @@ -117,6 +118,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): requestId, debugOutputEnabled, }); + registerDaemonAuthDiagnosticValue(ctx.effectiveFlags); let logTailStopper: (() => void) | null = null; try { if (command === 'react-devtools') { @@ -139,6 +141,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): flags: ctx.effectiveFlags, }); await resolveRemoteContext(ctx, deps); + registerDaemonAuthDiagnosticValue(ctx.effectiveFlags); if (command === 'cdp') { process.exit( await runAgentCdpCommand(positionals, { @@ -170,6 +173,10 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS): ); } +function registerDaemonAuthDiagnosticValue(flags: CliFlags): void { + if (flags.daemonAuthToken) registerDiagnosticSensitiveValue(flags.daemonAuthToken); +} + type ParsedCliInput = { parsed: ReturnType; command: string; @@ -323,9 +330,10 @@ function resolveRunContextOrExit( } async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise { + const { daemonAuthToken, ...directRequestFlags } = ctx.effectiveFlags; return await runReactDevtoolsCommand(ctx.positionals, { flags: { - ...ctx.effectiveFlags, + ...directRequestFlags, leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider, }, stateDir: ctx.daemonPaths.baseDir, @@ -333,18 +341,21 @@ async function runReactDevtoolsCli(ctx: CliRunContext, deps: CliDeps): Promise { - const response = await deps.sendToDaemon({ - command: INTERNAL_COMMANDS.runtime, - positionals: ['port-reverse'], - flags: { - ...ctx.effectiveFlags, - leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider, - devicePort: 8097, - hostPort: 8097, - portReverseName: 'react-devtools', + const response = await deps.sendToDaemon( + { + command: INTERNAL_COMMANDS.runtime, + positionals: ['port-reverse'], + flags: { + ...directRequestFlags, + leaseProvider: ctx.connectionDefaults?.connection?.leaseProvider, + devicePort: 8097, + hostPort: 8097, + portReverseName: 'react-devtools', + }, + session: ctx.effectiveFlags.session ?? ctx.sessionName, }, - session: ctx.effectiveFlags.session ?? ctx.sessionName, - }); + { authToken: daemonAuthToken }, + ); if (!response.ok) throwDaemonError(response.error); }, }); @@ -741,8 +752,12 @@ function createCliDaemonTransport(options: { }): AgentDeviceDaemonTransport { const { command, flags, replayTestReporterRuntime, transport } = options; if (flags.json) return createClientDaemonTransport(transport); - return async (req) => - await sendClientRequestToCliTransport( + return async (req, context) => { + const transportOptions = + command === 'test' && replayTestReporterRuntime + ? { ...context, onProgress: replayTestReporterRuntime.onProgress } + : context; + return await sendClientRequestToCliTransport( transport, { ...req, @@ -751,14 +766,13 @@ function createCliDaemonTransport(options: { requestProgress: command === 'test' ? 'replay-test' : 'command', }, }, - command === 'test' && replayTestReporterRuntime - ? { onProgress: replayTestReporterRuntime.onProgress } - : undefined, + transportOptions, ); + }; } function createClientDaemonTransport(transport: CliDaemonTransport): AgentDeviceDaemonTransport { - return async (req) => await sendClientRequestToCliTransport(transport, req); + return async (req, context) => await sendClientRequestToCliTransport(transport, req, context); } async function sendClientRequestToCliTransport( diff --git a/src/cli/parser/__tests__/cli-help-topics.test.ts b/src/cli/parser/__tests__/cli-help-topics.test.ts index 576ddf97d..6ffc7392d 100644 --- a/src/cli/parser/__tests__/cli-help-topics.test.ts +++ b/src/cli/parser/__tests__/cli-help-topics.test.ts @@ -198,7 +198,7 @@ test('usage includes agent workflows, config, environment, and examples footers' ); assert.match( usageText, - /Use --config or AGENT_DEVICE_CONFIG to load one explicit config file\./, + /Use --config or AGENT_DEVICE_CONFIG for explicit connection\/provider defaults; project config cannot select endpoints or credentials\./, ); assert.match(usageText, /Environment:/); assert.match(usageText, /AGENT_DEVICE_SESSION\s+Explicit session name/); diff --git a/src/cli/parser/cli-help.ts b/src/cli/parser/cli-help.ts index 979e6c685..072e49398 100644 --- a/src/cli/parser/cli-help.ts +++ b/src/cli/parser/cli-help.ts @@ -111,8 +111,8 @@ const AGENT_QUICKSTART_LINES = [ ] as const; const CONFIGURATION_LINES = [ - 'Default config files: ~/.agent-device/config.json, ./agent-device.json', - 'Use --config or AGENT_DEVICE_CONFIG to load one explicit config file.', + 'Default config files: ~/.agent-device/config.json, ./agent-device.json (project-safe defaults only).', + 'Use --config or AGENT_DEVICE_CONFIG for explicit connection/provider defaults; project config cannot select endpoints or credentials.', ] as const; const ENVIRONMENT_LINES = [ diff --git a/src/commands/cli-grammar/flag-definitions-connection.ts b/src/commands/cli-grammar/flag-definitions-connection.ts index ca8b3671b..f78fd809c 100644 --- a/src/commands/cli-grammar/flag-definitions-connection.ts +++ b/src/commands/cli-grammar/flag-definitions-connection.ts @@ -7,7 +7,8 @@ export const CONNECTION_FLAG_DEFINITIONS: readonly FlagDefinition[] = [ names: ['--config'], type: 'string', usageLabel: '--config ', - usageDescription: 'Load CLI defaults from a specific config file', + usageDescription: + 'Load CLI defaults from a specific config file (required for connection/provider defaults outside user config)', }, { key: 'remoteConfig', diff --git a/src/commands/command-flags.ts b/src/commands/command-flags.ts index 9ac733d71..14b9d69ae 100644 --- a/src/commands/command-flags.ts +++ b/src/commands/command-flags.ts @@ -15,7 +15,6 @@ function buildFlags(options: InternalRequestOptions): CommandFlags { return stripUndefined({ stateDir: options.stateDir, daemonBaseUrl: options.daemonBaseUrl, - daemonAuthToken: options.daemonAuthToken, daemonTransport: options.daemonTransport, daemonServerMode: options.daemonServerMode, ...leaseScopeToCommandFlags(leaseScope), diff --git a/src/daemon/client/daemon-client-lifecycle.ts b/src/daemon/client/daemon-client-lifecycle.ts index 3555cc885..f54f87f31 100644 --- a/src/daemon/client/daemon-client-lifecycle.ts +++ b/src/daemon/client/daemon-client-lifecycle.ts @@ -73,9 +73,12 @@ LOOPBACK_BLOCK_LIST.addSubnet('127.0.0.0', 8, 'ipv4'); LOOPBACK_BLOCK_LIST.addAddress('::1', 'ipv6'); LOOPBACK_BLOCK_LIST.addSubnet('::ffff:127.0.0.0', 104, 'ipv6'); -export function resolveClientSettings(req: Omit): DaemonClientSettings { +export function resolveClientSettings( + req: Omit, + suppliedAuthToken?: string, +): DaemonClientSettings { const explicitStateDir = resolveExplicitStateDir(req); - const remote = resolveRemoteClientSettings(req); + const remote = resolveRemoteClientSettings(req, suppliedAuthToken); const transport = resolveTransportClientSettings(req, remote.remoteBaseUrl); const ownedStateDir = shouldUseOwnedReplayStateDir(req, explicitStateDir, remote.rawBaseUrl); const stateDir = ownedStateDir ? createOwnedReplayStateDir() : explicitStateDir; @@ -93,14 +96,17 @@ function resolveExplicitStateDir(req: Omit): string | un return req.flags?.stateDir ?? process.env.AGENT_DEVICE_STATE_DIR; } -function resolveRemoteClientSettings(req: Omit): { +function resolveRemoteClientSettings( + req: Omit, + suppliedAuthToken: string | undefined, +): { rawBaseUrl: string | undefined; remoteBaseUrl?: string; authToken?: string; } { const rawBaseUrl = req.flags?.daemonBaseUrl ?? process.env.AGENT_DEVICE_DAEMON_BASE_URL; const remoteBaseUrl = resolveRemoteDaemonBaseUrl(rawBaseUrl); - const authToken = req.flags?.daemonAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; + const authToken = suppliedAuthToken ?? process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; validateRemoteDaemonTrust(remoteBaseUrl, authToken); return { rawBaseUrl, remoteBaseUrl, authToken }; } diff --git a/src/daemon/client/daemon-client.ts b/src/daemon/client/daemon-client.ts index 6ca84684d..06f25ae87 100644 --- a/src/daemon/client/daemon-client.ts +++ b/src/daemon/client/daemon-client.ts @@ -2,6 +2,7 @@ import type { DaemonRequest as SharedDaemonRequest, DaemonResponse as SharedDaemonResponse, } from '../types.ts'; +import type { AgentDeviceDaemonTransportContext } from '@agent-device/contracts/client'; import type { RequestProgressSink } from '../../request/progress.ts'; import { AppError } from '@agent-device/kernel/errors'; import { createRequestId, emitDiagnostic, withDiagnosticTimer } from '../../utils/diagnostics.ts'; @@ -23,7 +24,7 @@ import { resolveDaemonRequestTimeoutMs } from './daemon-client-timeout.ts'; export type DaemonRequest = SharedDaemonRequest; export type DaemonResponse = SharedDaemonResponse; -type DaemonTransportOptions = { +type DaemonTransportOptions = AgentDeviceDaemonTransportContext & { onProgress?: RequestProgressSink; }; @@ -33,75 +34,148 @@ export async function sendToDaemon( ): Promise { const requestId = req.meta?.requestId ?? createRequestId(); const debug = Boolean(req.meta?.debug || req.flags?.verbose); - const settings = resolveClientSettings(req); - const requestTimeoutMs = resolveDaemonRequestTimeoutMs(req); + // A few internal callers build DaemonRequest directly instead of using the + // public client flag builder. Defend this transport boundary too: credentials + // belong in the auth channel, never in serializable request flags. + const rawFlags = req.flags as + | (NonNullable & { daemonAuthToken?: string }) + | undefined; + const { daemonAuthToken: flagAuthToken, ...flags } = rawFlags ?? {}; + const requestWithoutAuthFlag = rawFlags ? { ...req, flags } : req; + const settings = resolveClientSettings( + requestWithoutAuthFlag, + options.authToken ?? flagAuthToken, + ); + const requestTimeoutMs = resolveDaemonRequestTimeoutMs(requestWithoutAuthFlag); const daemon = await withDiagnosticTimer( 'daemon_startup', async () => await ensureDaemon(settings), { requestId, session: req.session }, ); const info = daemon.info; - const preparedRemoteRequest = await prepareRemoteRequestArtifacts(req, info); - writeInstallInProgressNotice(req.command); + const preparedRemoteRequest = await prepareRemoteRequestArtifacts(requestWithoutAuthFlag, info); + writeInstallInProgressNotice(requestWithoutAuthFlag.command); - const request: DaemonRequest = { - ...req, - positionals: preparedRemoteRequest.positionals, - flags: preparedRemoteRequest.flags, - token: info.token, - meta: { - ...(req.meta ?? {}), - requestId, - debug, - includeCost: req.meta?.includeCost, - cwd: req.meta?.cwd, - sessionExplicit: req.meta?.sessionExplicit, - tenantId: req.meta?.tenantId ?? req.flags?.tenant, - runId: req.meta?.runId ?? req.flags?.runId, - leaseId: req.meta?.leaseId ?? req.flags?.leaseId, - sessionIsolation: req.meta?.sessionIsolation ?? req.flags?.sessionIsolation, - lockPolicy: req.meta?.lockPolicy, - lockPlatform: req.meta?.lockPlatform, - ...(preparedRemoteRequest.uploadedArtifactId - ? { uploadedArtifactId: preparedRemoteRequest.uploadedArtifactId } - : {}), - ...(preparedRemoteRequest.clientArtifactPaths - ? { clientArtifactPaths: preparedRemoteRequest.clientArtifactPaths } - : {}), - ...(preparedRemoteRequest.installSource - ? { installSource: preparedRemoteRequest.installSource } - : {}), - }, - }; + const request = buildTransportRequest( + requestWithoutAuthFlag, + preparedRemoteRequest, + info.token, + requestId, + debug, + ); emitDiagnostic({ level: 'info', phase: 'daemon_request_prepare', data: { requestId, - command: req.command, - session: req.session, + command: requestWithoutAuthFlag.command, + session: requestWithoutAuthFlag.session, }, }); - return await performDaemonRequestWithCleanup(req, daemon, settings, async () => { - const response = await withDiagnosticTimer( - 'daemon_request', - async () => - await sendRequest( - info, - request, - settings.transportPreference, - settings.paths, - requestTimeoutMs, - options, - ), - { requestId, command: req.command }, - ); - return withActiveSessionAddressHint( - withRepairSessionAddressHintIfOwned(response, settings), - req, - settings, - ); - }); + return await performDaemonRequestWithCleanup( + requestWithoutAuthFlag, + daemon, + settings, + async () => { + const response = await withDiagnosticTimer( + 'daemon_request', + async () => + await sendRequest( + info, + request, + settings.transportPreference, + settings.paths, + requestTimeoutMs, + options.onProgress ? { onProgress: options.onProgress } : undefined, + ), + { requestId, command: req.command }, + ); + return withActiveSessionAddressHint( + withRepairSessionAddressHintIfOwned(response, settings), + requestWithoutAuthFlag, + settings, + ); + }, + ); +} + +function buildTransportRequest( + request: Omit, + preparedRemoteRequest: Awaited>, + token: string, + requestId: string, + debug: boolean, +): DaemonRequest { + return { + ...request, + positionals: preparedRemoteRequest.positionals, + flags: preparedRemoteRequest.flags, + token, + meta: buildTransportRequestMeta(request, preparedRemoteRequest, requestId, debug), + }; +} + +function buildTransportRequestMeta( + request: Omit, + preparedRemoteRequest: Awaited>, + requestId: string, + debug: boolean, +): NonNullable { + const meta = request.meta ?? {}; + return { + ...meta, + requestId, + debug, + ...buildRequestScopeMeta(meta, request.flags), + ...buildRemoteArtifactMeta(preparedRemoteRequest), + }; +} + +function buildRequestScopeMeta( + meta: NonNullable, + flags: DaemonRequest['flags'], +): Pick< + NonNullable, + | 'includeCost' + | 'cwd' + | 'sessionExplicit' + | 'tenantId' + | 'runId' + | 'leaseId' + | 'sessionIsolation' + | 'lockPolicy' + | 'lockPlatform' +> { + return { + includeCost: meta.includeCost, + cwd: meta.cwd, + sessionExplicit: meta.sessionExplicit, + tenantId: meta.tenantId ?? flags?.tenant, + runId: meta.runId ?? flags?.runId, + leaseId: meta.leaseId ?? flags?.leaseId, + sessionIsolation: meta.sessionIsolation ?? flags?.sessionIsolation, + lockPolicy: meta.lockPolicy, + lockPlatform: meta.lockPlatform, + }; +} + +function buildRemoteArtifactMeta( + preparedRemoteRequest: Awaited>, +): Pick< + NonNullable, + 'uploadedArtifactId' | 'clientArtifactPaths' | 'installSource' +> { + return { + ...(preparedRemoteRequest.uploadedArtifactId + ? { uploadedArtifactId: preparedRemoteRequest.uploadedArtifactId } + : {}), + ...(preparedRemoteRequest.clientArtifactPaths + ? { clientArtifactPaths: preparedRemoteRequest.clientArtifactPaths } + : {}), + ...(preparedRemoteRequest.installSource + ? { installSource: preparedRemoteRequest.installSource } + : {}), + }; } /** diff --git a/src/utils/__tests__/daemon-client.test.ts b/src/utils/__tests__/daemon-client.test.ts index 3b67f90af..67d62d2b3 100644 --- a/src/utils/__tests__/daemon-client.test.ts +++ b/src/utils/__tests__/daemon-client.test.ts @@ -1002,16 +1002,18 @@ test('sendToDaemon uses explicit remote daemon base URL and auth token', async ( const previousBaseUrl = process.env.AGENT_DEVICE_DAEMON_BASE_URL; const previousAuthToken = process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN; process.env.AGENT_DEVICE_DAEMON_BASE_URL = 'http://remote-mac.example.test:7777/agent-device'; - process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'remote-secret'; + process.env.AGENT_DEVICE_DAEMON_AUTH_TOKEN = 'ambient-token'; try { - const response = await sendToDaemon({ - session: 'default', - command: 'remote-smoke', - positionals: ['ping'], - flags: {}, - meta: { requestId: 'req-remote' }, - }); + const response = await sendToDaemon( + { + session: 'default', + command: 'remote-smoke', + positionals: ['ping'], + meta: { requestId: 'req-remote' }, + }, + { authToken: 'remote-secret' }, + ); assert.equal(response.ok, true); assert.deepEqual(response.data, { source: 'remote-daemon' }); @@ -1023,6 +1025,7 @@ test('sendToDaemon uses explicit remote daemon base URL and auth token', async ( assert.equal((rpcRequest as any)?.params?.command, 'remote-smoke'); assert.deepEqual((rpcRequest as any)?.params?.positionals, ['ping']); assert.equal((rpcRequest as any)?.params?.token, 'remote-secret'); + assert.equal((rpcRequest as any)?.params?.flags?.daemonAuthToken, undefined); } finally { (http as unknown as { request: typeof http.request }).request = originalHttpRequest; if (previousBaseUrl === undefined) delete process.env.AGENT_DEVICE_DAEMON_BASE_URL; @@ -1032,6 +1035,36 @@ test('sendToDaemon uses explicit remote daemon base URL and auth token', async ( } }); +test('sendToDaemon moves a raw direct-dispatch token into auth instead of RPC flags', async () => { + let rpcRequest: Record | undefined; + let authHeader = ''; + const restoreHttpRequest = mockEventHttpRequest(({ options, body, res }) => { + if (respondToHealthcheck(options, res)) return; + authHeader = String(options.headers.authorization ?? ''); + rpcRequest = JSON.parse(body) as Record; + emitJsonRpcResult(res, 'req-direct-dispatch', { ok: true, data: {} }); + }); + + try { + await withRemoteDaemonEnv(async () => { + const response = await sendToDaemon({ + session: 'default', + command: 'remote-smoke', + positionals: ['ping'], + flags: { daemonAuthToken: 'direct-dispatch-token' } as never, + meta: { requestId: 'req-direct-dispatch' }, + }); + assert.equal(response.ok, true); + }); + + assert.equal(authHeader, 'Bearer direct-dispatch-token'); + assert.equal((rpcRequest as any)?.params?.token, 'direct-dispatch-token'); + assert.equal((rpcRequest as any)?.params?.flags?.daemonAuthToken, undefined); + } finally { + restoreHttpRequest(); + } +}); + test.each([ ['older', DAEMON_RPC_PROTOCOL_VERSION - 1], ['newer', DAEMON_RPC_PROTOCOL_VERSION + 1], diff --git a/test/integration/provider-scenarios/doctor.test.ts b/test/integration/provider-scenarios/doctor.test.ts index 9b283eacb..6b5b43f55 100644 --- a/test/integration/provider-scenarios/doctor.test.ts +++ b/test/integration/provider-scenarios/doctor.test.ts @@ -170,7 +170,6 @@ test('Provider-backed integration doctor --remote skips local device inventory', const response = await daemon.callCommand('doctor', [], { remote: true, daemonBaseUrl: 'https://example.invalid/agent-device', - daemonAuthToken: 'secret', }); assertRpcOk(response); const data = response.json.result.data; diff --git a/website/docs/docs/configuration.md b/website/docs/docs/configuration.md index 3b8b9b04b..98883da39 100644 --- a/website/docs/docs/configuration.md +++ b/website/docs/docs/configuration.md @@ -4,7 +4,8 @@ title: Configuration # Configuration -Create an `agent-device.json` file to set persistent CLI defaults instead of repeating flags on every command. +Use configuration for persistent CLI defaults instead of repeating flags on every command. Repository +configuration and operator-controlled configuration have different trust scopes. ## Config file locations @@ -12,14 +13,19 @@ agent-device checks these sources in priority order: | Priority | Location | Scope | | --- | --- | --- | -| 1 (lowest) | `~/.agent-device/config.json` | User-level defaults | -| 2 | `./agent-device.json` | Project-level overrides | +| 1 (lowest) | `~/.agent-device/config.json` | User-level defaults, including connection/provider settings | +| 2 | `./agent-device.json` | Repository-controlled project-safe automation defaults | | 3 | `AGENT_DEVICE_*` env vars | Override config values | | 4 (highest) | CLI flags | Override everything | -Project-level values override user-level values. Environment variables override both. CLI flags always win. +Project-level values override user-level values where they are permitted. Environment variables override +both. CLI flags always win. `--config ` or `AGENT_DEVICE_CONFIG` loads one explicit, +operator-controlled file instead of the default locations. -Use `--config ` or `AGENT_DEVICE_CONFIG` to load one specific config file instead of the default locations. +`./agent-device.json` cannot contain endpoint, credential, daemon transport/server, tenant/run/lease, +provider/cloud, Metro connection, or other operator-controlled fields. The CLI rejects those keys during +parse, before it creates a daemon transport or sends a health request. This prevents a repository from +pairing its chosen endpoint with a token from the user environment or user config. ## Config format @@ -41,37 +47,49 @@ Example: "platform": "ios", "device": "iPhone 16", "session": "qa-ios", - "snapshotDepth": 3, - "daemonBaseUrl": "http://127.0.0.1:4310/agent-device" + "snapshotDepth": 3 } ``` -For non-loopback remote daemon URLs, also set `daemonAuthToken` or `AGENT_DEVICE_DAEMON_AUTH_TOKEN`. The client rejects non-loopback remote daemon URLs without auth. +Use user config, an explicit config, CLI flags, environment variables, or `connect`/`--remote-config` +for remote connections. For example, a user-owned config may contain: + +```json +{ + "daemonBaseUrl": "https://bridge.example.com/agent-device", + "daemonAuthToken": "", + "daemonTransport": "http", + "tenant": "ci" +} +``` + +For CI, provide both `AGENT_DEVICE_DAEMON_BASE_URL` and `AGENT_DEVICE_DAEMON_AUTH_TOKEN` from +protected, operator-controlled configuration. Do not put either value in `./agent-device.json`. +For non-loopback remote daemon URLs, the client still requires authentication. Saved `connect` profiles +and explicit `--remote-config` workflows remain supported; generated profiles do not persist tokens. + +Project-safe keys include command defaults such as `platform`, `target`, `device`, `session`, +`snapshotDepth`, recording/capture options, and action timing. Connection and provider keys below are +user- or explicit-config only: -Common keys include: - `stateDir` - `daemonBaseUrl` - `daemonAuthToken` +- `daemonTransport` +- `daemonServerMode` - `tenant` - `sessionIsolation` - `runId` - `leaseId` - `leaseBackend` -- `sessionLock` -- `platform` -- `target` -- `device` -- `udid` -- `serial` -- `iosSimulatorDeviceSet` -- `androidDeviceAllowlist` -- `session` -- `verbose` -- `json` +- provider/cloud fields (`provider*`, `aws*`) +- Metro endpoint/token fields (`metro*`, `bundleUrl`) +- request headers and structured install sources +- local code and write destinations (`reporter`, `reportJunit`, `saveScript`, `launchConsole`) -Command-specific defaults are supported too, for example `snapshotDepth`, `snapshotScope`, `activity`, `relaunch`, `shutdown`, `fps`, `quality`, `stepsFile`, or `saveScript`. +Project config can use project-safe command defaults such as `snapshotDepth`, `snapshotScope`, `activity`, `relaunch`, `shutdown`, `fps`, and `quality`. Local path and executable-module selectors such as `stepsFile` and `reporter` are user- or explicit-config only. -`install-from-source` can also read a structured GitHub Actions artifact source from config when a compatible remote daemon resolves CI artifacts server-side: +`install-from-source` can read a structured GitHub Actions artifact source from user or explicit config when a compatible remote daemon resolves CI artifacts server-side. Repository config rejects this operator-controlled source: ```json { @@ -86,7 +104,7 @@ Command-specific defaults are supported too, for example `snapshotDepth`, `snaps Use a numeric `artifact` value for an artifact ID. Use a string `artifact` value for an artifact name. -Explicit named-session lock defaults use the same config and env mapping too: +Explicit named-session lock defaults use project-safe config and the same env mapping: - `sessionLock` -> `AGENT_DEVICE_SESSION_LOCK` Most local automation can omit this because implicit `default` sessions are workspace-scoped; use `sessionLock`, `--session-lock`, or `AGENT_DEVICE_SESSION_LOCK` when intentionally running an explicitly named session. @@ -118,4 +136,4 @@ This keeps one shared config file usable across different command families. ## Failure behavior - If `--config` or `AGENT_DEVICE_CONFIG` points to a missing file, agent-device fails during CLI parse before contacting the daemon. -- Invalid JSON, unknown keys, or invalid values in config files also fail during CLI parse with `INVALID_ARGS`. +- Invalid JSON, unknown keys, invalid values, or an operator-controlled key in project config also fail during CLI parse with `INVALID_ARGS`. Rejections name the key and never echo its value. diff --git a/website/docs/docs/remote-proxy.md b/website/docs/docs/remote-proxy.md index cdc222ed0..869acfa15 100644 --- a/website/docs/docs/remote-proxy.md +++ b/website/docs/docs/remote-proxy.md @@ -48,7 +48,9 @@ agent-device disconnect Multiple agents can share one proxy when each uses the normal `connect proxy`, `open`, commands, `close`, and `disconnect` flow. A busy device error means another agent owns the device until it closes or its inactivity lease expires. -Do not commit a config file that contains a live `daemonAuthToken`. +Do not put proxy endpoint, token, tenant, or provider fields in `./agent-device.json`: repository +configuration is intentionally limited to project-safe automation defaults. Use `connect proxy`, user +config, an explicit `--config` file, or protected CI environment variables for the endpoint and token. ## What Is Exposed