From 0ea4daaef944be7758c3ba8266798cd8ed8ed1a4 Mon Sep 17 00:00:00 2001 From: Ken Tominaga Date: Thu, 19 Mar 2026 01:22:36 -0700 Subject: [PATCH 1/3] feat: add CommandName union type for compile-time command name verification Introduce a CommandName union type derived from a const COMMAND_NAMES array (52 entries: 46 CLI + 6 daemon-internal) and apply it to internal function signatures and lookup tables via satisfies checks. DaemonRequest.command stays string (untrusted wire boundary). --- src/cli-client-commands.ts | 3 +- src/client.ts | 3 +- src/core/batch.ts | 7 +-- src/core/capabilities.ts | 3 +- src/core/command-names.ts | 76 ++++++++++++++++++++++++++++ src/core/dispatch.ts | 5 +- src/daemon/handlers/session-close.ts | 3 +- src/daemon/handlers/session.ts | 3 +- src/daemon/http-server.ts | 3 +- src/daemon/request-router.ts | 11 ++-- src/utils/args.ts | 5 +- src/utils/command-schema.ts | 3 +- 12 files changed, 105 insertions(+), 20 deletions(-) create mode 100644 src/core/command-names.ts diff --git a/src/cli-client-commands.ts b/src/cli-client-commands.ts index f462196e..74f9d78e 100644 --- a/src/cli-client-commands.ts +++ b/src/cli-client-commands.ts @@ -2,6 +2,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import type { CliFlags } from './utils/command-schema.ts'; +import type { CommandName } from './core/command-names.ts'; import { formatScreenshotDiffText, formatSnapshotText, printJson } from './utils/output.ts'; import { AppError } from './utils/errors.ts'; import { @@ -247,7 +248,7 @@ const clientCommandHandlers: Partial> = { } return true; }, -}; +} satisfies Partial>; async function runDeployCommand( command: 'install' | 'reinstall', diff --git a/src/client.ts b/src/client.ts index 3f6d145c..58fa1b59 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,6 +1,7 @@ import { sendToDaemon } from './daemon-client.ts'; import { prepareMetroRuntime } from './client-metro.ts'; import { AppError } from './utils/errors.ts'; +import type { CommandName } from './core/command-names.ts'; import { buildFlags, buildMeta, @@ -44,7 +45,7 @@ export function createAgentDeviceClient( const transport = deps.transport ?? sendToDaemon; const execute = async ( - command: string, + command: CommandName, positionals: string[] = [], options: InternalRequestOptions = {}, ): Promise> => { diff --git a/src/core/batch.ts b/src/core/batch.ts index 830bcac8..8b3f5c7b 100644 --- a/src/core/batch.ts +++ b/src/core/batch.ts @@ -1,11 +1,12 @@ import { AppError } from '../utils/errors.ts'; import type { BatchStep, CommandFlags } from './dispatch.ts'; +import type { CommandName } from './command-names.ts'; export const DEFAULT_BATCH_MAX_STEPS = 100; const BATCH_BLOCKED_COMMANDS = new Set(['batch', 'replay']); export type NormalizedBatchStep = { - command: string; + command: CommandName; positionals: string[]; flags: Partial; runtime?: unknown; @@ -13,7 +14,7 @@ export type NormalizedBatchStep = { export type BatchStepResult = { step: number; - command: string; + command: CommandName; ok: true; data: Record; durationMs: number; @@ -82,7 +83,7 @@ export function validateAndNormalizeBatchSteps( throw new AppError('INVALID_ARGS', `Batch step ${index + 1} runtime must be an object.`); } normalized.push({ - command, + command: command as CommandName, positionals: positionals as string[], flags: (step.flags ?? {}) as Partial, runtime: step.runtime, diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 1c436bd2..d5742c84 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -1,4 +1,5 @@ import type { DeviceInfo } from '../utils/device.ts'; +import type { CommandName } from './command-names.ts'; type KindMatrix = { simulator?: boolean; @@ -144,7 +145,7 @@ const COMMAND_CAPABILITY_MATRIX: Record = { ios: { simulator: true, device: true }, android: { emulator: true, device: true, unknown: true }, }, -}; +} satisfies Partial>; export function isCommandSupportedOnDevice(command: string, device: DeviceInfo): boolean { const capability = COMMAND_CAPABILITY_MATRIX[command]; diff --git a/src/core/command-names.ts b/src/core/command-names.ts new file mode 100644 index 00000000..66470468 --- /dev/null +++ b/src/core/command-names.ts @@ -0,0 +1,76 @@ +/** + * Canonical command names used throughout agent-device. + * + * The `COMMAND_NAMES` array is the single source of truth. + * `CommandName` is the union type derived from it. + * + * `DaemonRequest.command` intentionally stays `string` because it represents + * untrusted input from wire boundaries. The value of `CommandName` comes from + * typed lookup-table keys and internal function parameters. + */ + +export const COMMAND_NAMES = [ + // CLI commands (46) + 'alert', + 'app-switcher', + 'apps', + 'appstate', + 'back', + 'batch', + 'boot', + 'click', + 'clipboard', + 'close', + 'devices', + 'diff', + 'ensure-simulator', + 'fill', + 'find', + 'focus', + 'get', + 'home', + 'install', + 'install-from-source', + 'is', + 'keyboard', + 'logs', + 'longpress', + 'metro', + 'network', + 'open', + 'perf', + 'pinch', + 'press', + 'push', + 'record', + 'reinstall', + 'replay', + 'runtime', + 'screenshot', + 'scroll', + 'scrollintoview', + 'session', + 'settings', + 'snapshot', + 'swipe', + 'trace', + 'trigger-app-event', + 'type', + 'wait', + + // Daemon-internal (6) + 'install_source', + 'lease_allocate', + 'lease_heartbeat', + 'lease_release', + 'release_materialized_paths', + 'session_list', +] as const; + +export type CommandName = (typeof COMMAND_NAMES)[number]; + +/** CLI aliases that are normalized before reaching command dispatch. */ +export const COMMAND_ALIASES: Record = { + 'long-press': 'longpress', + metrics: 'perf', +}; \ No newline at end of file diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 0453fedc..1a25d4bd 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -2,6 +2,7 @@ import { promises as fs } from 'node:fs'; import pathModule from 'node:path'; import { AppError } from '../utils/errors.ts'; import type { DeviceInfo } from '../utils/device.ts'; +import type { CommandName } from './command-names.ts'; import { dismissAndroidKeyboard, getAndroidKeyboardState, @@ -30,7 +31,7 @@ export { resolveTargetDevice } from './dispatch-resolve.ts'; export { shouldUseIosTapSeries, shouldUseIosDragSeries }; export type BatchStep = { - command: string; + command: CommandName; positionals?: string[]; flags?: Partial; runtime?: unknown; @@ -42,7 +43,7 @@ export type CommandFlags = Omit, diff --git a/src/daemon/handlers/session.ts b/src/daemon/handlers/session.ts index 7820f4db..d735f97d 100644 --- a/src/daemon/handlers/session.ts +++ b/src/daemon/handlers/session.ts @@ -5,6 +5,7 @@ import { type BatchStep, type CommandFlags, } from '../../core/dispatch.ts'; +import type { CommandName } from '../../core/command-names.ts'; import { isCommandSupportedOnDevice } from '../../core/capabilities.ts'; import { AppError, asAppError, normalizeError } from '../../utils/errors.ts'; import { normalizePlatformSelector, type DeviceInfo } from '../../utils/device.ts'; @@ -117,7 +118,7 @@ async function runSessionOrSelectorDispatch(params: { ensureReady: typeof ensureDeviceReady; resolveDevice: typeof resolveTargetDevice; dispatch: typeof dispatchCommand; - command: string; + command: CommandName; positionals: string[]; recordPositionals?: string[]; deriveNextSession?: ( diff --git a/src/daemon/http-server.ts b/src/daemon/http-server.ts index cff8d59c..ce4a6e08 100644 --- a/src/daemon/http-server.ts +++ b/src/daemon/http-server.ts @@ -2,6 +2,7 @@ import http, { type IncomingHttpHeaders } from 'node:http'; import fs from 'node:fs'; import { AppError, normalizeError } from '../utils/errors.ts'; import type { DaemonInstallSource, DaemonRequest, DaemonResponse } from './types.ts'; +import type { CommandName } from '../core/command-names.ts'; import { normalizeTenantId } from './config.ts'; import { clearRequestCanceled, @@ -78,7 +79,7 @@ const LEASE_RPC_METHOD_TO_COMMAND: Record< 'agent-device.lease.heartbeat': 'lease_heartbeat', 'agent_device.lease.release': 'lease_release', 'agent-device.lease.release': 'lease_release', -}; +} satisfies Record; const SUPPORTED_RPC_METHODS = new Set([ ...COMMAND_RPC_METHODS, ...INSTALL_FROM_SOURCE_RPC_METHODS, diff --git a/src/daemon/request-router.ts b/src/daemon/request-router.ts index 2faf453e..27754bc3 100644 --- a/src/daemon/request-router.ts +++ b/src/daemon/request-router.ts @@ -27,14 +27,15 @@ import { } from '../utils/diagnostics.ts'; import { resolveLeaseScope } from './lease-context.ts'; import type { LeaseRegistry } from './lease-registry.ts'; +import type { CommandName } from '../core/command-names.ts'; -const selectorValidationExemptCommands = new Set([ +const selectorValidationExemptCommands = new Set([ 'session_list', 'devices', 'ensure-simulator', 'release_materialized_paths', -]); -const leaseAdmissionExemptCommands = new Set([ +] as const satisfies readonly CommandName[]); +const leaseAdmissionExemptCommands = new Set([ 'session_list', 'devices', 'ensure-simulator', @@ -42,7 +43,7 @@ const leaseAdmissionExemptCommands = new Set([ 'lease_allocate', 'lease_heartbeat', 'lease_release', -]); +] as const satisfies readonly CommandName[]); function contextFromFlags( logPath: string, @@ -344,7 +345,7 @@ export function createRequestHandler( command === 'screenshot' && resolvedOut ? { ...(lockedReq.flags ?? {}), out: resolvedOut } : (lockedReq.flags ?? {}); - const data = await dispatch(session.device, command, resolvedPositionals, resolvedOut, { + const data = await dispatch(session.device, command as CommandName, resolvedPositionals, resolvedOut, { ...contextFromFlags( logPath, lockedReq.flags, diff --git a/src/utils/args.ts b/src/utils/args.ts index 5eafb090..ff30c856 100644 --- a/src/utils/args.ts +++ b/src/utils/args.ts @@ -10,6 +10,7 @@ import { type FlagKey, } from './command-schema.ts'; import { isFlagSupportedForCommand } from './cli-option-schema.ts'; +import { COMMAND_ALIASES } from '../core/command-names.ts'; type ParsedArgs = { command: string | null; @@ -304,9 +305,7 @@ export function usageForCommand(command: string): string | null { } function normalizeCommandAlias(command: string): string { - if (command === 'long-press') return 'longpress'; - if (command === 'metrics') return 'perf'; - return command; + return COMMAND_ALIASES[command] ?? command; } function mergeDefinedFlags>(target: T, source: Partial): T { diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 3cf64188..7c174d72 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,4 +1,5 @@ import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; +import type { CommandName } from '../core/command-names.ts'; export type CliFlags = { json: boolean; @@ -1049,7 +1050,7 @@ const COMMAND_SCHEMAS: Record = { allowedFlags: [], skipCapabilityCheck: true, }, -}; +} satisfies Partial>; const flagDefinitionByName = new Map(); const flagDefinitionsByKey = new Map(); From 72c3d9ffa56cbd3fcee671597bac21d8731926c3 Mon Sep 17 00:00:00 2001 From: Ken Tominaga Date: Thu, 19 Mar 2026 10:21:18 -0700 Subject: [PATCH 2/3] refactor: derive CliCommandName from COMMAND_SCHEMAS keys Instead of maintaining a separate COMMAND_NAMES array, derive CliCommandName from keyof typeof COMMAND_SCHEMAS. Only the 6 daemon-internal commands remain as a small explicit union. --- src/core/command-names.ts | 63 ++++++------------------------------- src/utils/command-schema.ts | 9 ++++-- 2 files changed, 15 insertions(+), 57 deletions(-) diff --git a/src/core/command-names.ts b/src/core/command-names.ts index 66470468..457c43db 100644 --- a/src/core/command-names.ts +++ b/src/core/command-names.ts @@ -1,64 +1,17 @@ /** - * Canonical command names used throughout agent-device. + * Canonical command name type used throughout agent-device. * - * The `COMMAND_NAMES` array is the single source of truth. - * `CommandName` is the union type derived from it. + * CLI command names are derived from `COMMAND_SCHEMAS` keys. + * Daemon-internal names are listed as a small separate union. * * `DaemonRequest.command` intentionally stays `string` because it represents * untrusted input from wire boundaries. The value of `CommandName` comes from * typed lookup-table keys and internal function parameters. */ -export const COMMAND_NAMES = [ - // CLI commands (46) - 'alert', - 'app-switcher', - 'apps', - 'appstate', - 'back', - 'batch', - 'boot', - 'click', - 'clipboard', - 'close', - 'devices', - 'diff', - 'ensure-simulator', - 'fill', - 'find', - 'focus', - 'get', - 'home', - 'install', - 'install-from-source', - 'is', - 'keyboard', - 'logs', - 'longpress', - 'metro', - 'network', - 'open', - 'perf', - 'pinch', - 'press', - 'push', - 'record', - 'reinstall', - 'replay', - 'runtime', - 'screenshot', - 'scroll', - 'scrollintoview', - 'session', - 'settings', - 'snapshot', - 'swipe', - 'trace', - 'trigger-app-event', - 'type', - 'wait', +import type { CliCommandName } from '../utils/command-schema.ts'; - // Daemon-internal (6) +const DAEMON_INTERNAL_COMMANDS = [ 'install_source', 'lease_allocate', 'lease_heartbeat', @@ -67,10 +20,12 @@ export const COMMAND_NAMES = [ 'session_list', ] as const; -export type CommandName = (typeof COMMAND_NAMES)[number]; +type DaemonInternalCommandName = (typeof DAEMON_INTERNAL_COMMANDS)[number]; + +export type CommandName = CliCommandName | DaemonInternalCommandName; /** CLI aliases that are normalized before reaching command dispatch. */ export const COMMAND_ALIASES: Record = { 'long-press': 'longpress', metrics: 'perf', -}; \ No newline at end of file +}; diff --git a/src/utils/command-schema.ts b/src/utils/command-schema.ts index 7c174d72..e3b885f9 100644 --- a/src/utils/command-schema.ts +++ b/src/utils/command-schema.ts @@ -1,5 +1,4 @@ import { SETTINGS_USAGE_OVERRIDE } from '../core/settings-contract.ts'; -import type { CommandName } from '../core/command-names.ts'; export type CliFlags = { json: boolean; @@ -744,7 +743,7 @@ export const GLOBAL_FLAG_KEYS = new Set([ 'noRecord', ]); -const COMMAND_SCHEMAS: Record = { +const commandSchemaEntries = { boot: { description: 'Ensure target device/simulator is booted and ready', positionalArgs: [], @@ -1050,7 +1049,11 @@ const COMMAND_SCHEMAS: Record = { allowedFlags: [], skipCapabilityCheck: true, }, -} satisfies Partial>; +} satisfies Record; + +export type CliCommandName = keyof typeof commandSchemaEntries; + +const COMMAND_SCHEMAS: Record = commandSchemaEntries; const flagDefinitionByName = new Map(); const flagDefinitionsByKey = new Map(); From b74a99d3d784dc7e1c35ec1c31652952162a9e23 Mon Sep 17 00:00:00 2001 From: Ken Tominaga Date: Thu, 19 Mar 2026 10:32:55 -0700 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/core/command-names.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/command-names.ts b/src/core/command-names.ts index 457c43db..e3b3a282 100644 --- a/src/core/command-names.ts +++ b/src/core/command-names.ts @@ -25,7 +25,7 @@ type DaemonInternalCommandName = (typeof DAEMON_INTERNAL_COMMANDS)[number]; export type CommandName = CliCommandName | DaemonInternalCommandName; /** CLI aliases that are normalized before reaching command dispatch. */ -export const COMMAND_ALIASES: Record = { +export const COMMAND_ALIASES: Partial> = { 'long-press': 'longpress', metrics: 'perf', };