diff --git a/AGENTS.md b/AGENTS.md index 08e03316e0..5cf51e21e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,8 @@ Read the declaration rather than maintaining a prose copy: - interaction paths and guarantees: `packages/contracts/src/interaction-guarantees.ts` - canonical command names: `src/command-catalog.ts` - device capability admission: `src/core/capabilities.ts` +- common command input fields, and which surface may write an input key (model, operator, retired): + `src/commands/common-input-fields.ts` and `src/commands/input-audience.ts` Shared selector parsing and matching belongs in `@agent-device/selectors`; request cancellation and progress in `src/request`; cross-layer contracts in `src/contracts`; CLI flags in diff --git a/docs/agents/cli-flags.md b/docs/agents/cli-flags.md index b11d0075a5..c5eb027253 100644 --- a/docs/agents/cli-flags.md +++ b/docs/agents/cli-flags.md @@ -17,7 +17,11 @@ mattering — threading it further is the common failure, not stopping too early 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. 4. `src/commands/*-command-contracts.ts`: add to the command input schema only if the option should - be available through Node.js or MCP as structured input. + be available through Node.js or MCP as structured input. An input key that names a credential, + an endpoint a credential is sent to, or operator infrastructure declares `operatorField(...)` + (`src/commands/command-input.ts`), which is what keeps the MCP and AI SDK tool schemas from + offering the model a parameter to write it into. One of the shared common keys declares the same + audience in its `src/commands/common-input-fields.ts` row instead. 5. `src/client/client-types.ts`: update the public typed client option only when the Node.js interface exposes it. 6. `src/client/client-normalizers.ts`: update daemon flag normalization only when the request still diff --git a/src/__tests__/eager-closure-budgets.ts b/src/__tests__/eager-closure-budgets.ts index d7e59e6eea..3ddb0a14c8 100644 --- a/src/__tests__/eager-closure-budgets.ts +++ b/src/__tests__/eager-closure-budgets.ts @@ -332,7 +332,12 @@ export const HUB_BUDGETS: Readonly> = Object.freeze({ // (`resolveLocalDaemonCodeSignature`) rather than appearing here. // #2054 splits daemon cleanup and managed web backend into separate neutral contract entries; // the CLI already loads both command modules, so the second one-module contract is deliberate. - 'src/cli.ts': 365, + // #2027 splits the 705-line `commands/command-input.ts` into the three leaf modules the + // common-field table needs to exist without an import cycle: `input-readers.ts` (record + // readers), `input-audience.ts` (who may write a key), and `common-input-fields.ts` (the table + // itself). Every command schema already evaluated all three concerns; the growth is three more + // module records for the same code, with no new subtree behind any of them. + 'src/cli.ts': 368, 'src/platform-runtime.ts': 47, 'src/core/capabilities.ts': 73, 'src/core/command-descriptor/registry.ts': 67, diff --git a/src/ai-sdk/index.ts b/src/ai-sdk/index.ts index b31a4633e4..e25b8f6d60 100644 --- a/src/ai-sdk/index.ts +++ b/src/ai-sdk/index.ts @@ -6,6 +6,7 @@ import type { AgentDeviceClient } from '../client/client-types.ts'; import type { JsonSchema } from '../commands/command-contract.ts'; import { resolveCommandFrameworkTier } from '../core/command-descriptor/registry.ts'; import { createCommandToolExecutor, listCommandTools } from '../mcp/command-tools.ts'; +import { MCP_TOOL_CONFIG_KEYS } from '../mcp/tool-control-fields.ts'; import { formatToolErrorText } from '../mcp/tool-error.ts'; /** @@ -53,22 +54,13 @@ export type AgentDeviceTools = { // `execute` forwards to the shared executor: // - `session` is always pinned by this factory — the whole point is that a // tool call can never target a session other than the one passed in. -// - `stateDir` selects which daemon state directory (and therefore which -// daemon/session namespace) a call resolves against. Left model-visible, -// it would let a call escape the pinned session into another daemon's -// state entirely, defeating that guarantee. -// - `mcpOutputFormat`, `includeCost`, `responseLevel` are MCP tool-config -// knobs, not command arguments; irrelevant here since `execute` below -// returns structuredContent directly and never reads a tool's rendered -// text, and shaping the response is this factory's decision, not the -// model's. -const ALWAYS_HIDDEN_FIELDS = [ - 'session', - 'stateDir', - 'mcpOutputFormat', - 'includeCost', - 'responseLevel', -] as const; +// - every MCP tool-config key: `stateDir` selects which daemon state directory +// (and therefore which daemon/session namespace) a call resolves against, so +// left model-visible it would let a call escape the pinned session into +// another daemon's state entirely; the rest shape the response, which is this +// factory's decision, not the model's (`execute` below returns +// structuredContent directly and never reads a tool's rendered text). +const ALWAYS_HIDDEN_FIELDS: readonly string[] = ['session', ...MCP_TOOL_CONFIG_KEYS]; // The registry's hand-rolled JsonSchema type and `jsonSchema()`'s expected // JSONSchema7 (re-exported from @ai-sdk/provider, not from `ai` itself) are diff --git a/src/cli/batch-steps.ts b/src/cli/batch-steps.ts index f900a9d723..d1349a0c17 100644 --- a/src/cli/batch-steps.ts +++ b/src/cli/batch-steps.ts @@ -10,7 +10,7 @@ import { BATCH_AVAILABLE_COMMANDS_HINT, readStructuredBatchCommandName, } from '../core/batch-policy.ts'; -import { assertAllowedKeys } from '../commands/command-input.ts'; +import { assertAllowedKeys } from '../commands/input-readers.ts'; /** * The terminal half of the step-shape refusal. `@agent-device/contracts` states the shape for diff --git a/src/commands/__tests__/input-audience.test.ts b/src/commands/__tests__/input-audience.test.ts new file mode 100644 index 0000000000..eb4eb67562 --- /dev/null +++ b/src/commands/__tests__/input-audience.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + COMMON_INPUT_AUDIENCE, + commonProperties, + commonToClientOptions, + readCommonInput, +} from '../common-input-fields.ts'; +import { operatorInputRefusal } from '../input-audience.ts'; + +// The guidance a refused operator key answers with is rendered from the key's +// declared source, not written per key. These pin the four shapes the +// declarations use, so a change to the template or to `buildPrimaryEnvVarName` +// fails here instead of silently degrading every operator refusal at once. +test('an operator refusal names the environment variable and the config key', () => { + assert.equal( + operatorInputRefusal('daemonAuthToken', { operatorConfig: true }), + 'daemonAuthToken is not accepted as a tool argument. Set the AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable (or daemonAuthToken in ~/.agent-device/config.json) for the process serving these tools.', + ); +}); + +test('an operator refusal names every environment variable that carries the value', () => { + assert.equal( + operatorInputRefusal('bearerToken', { envFlagKeys: ['metroBearerToken', 'daemonAuthToken'] }), + 'bearerToken is not accepted as a tool argument. Set the AGENT_DEVICE_METRO_BEARER_TOKEN or AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable for the process serving these tools.', + ); +}); + +test('a key with no environment variable points at the operator config file alone', () => { + assert.equal( + operatorInputRefusal('iosSimulatorDeviceSet', { envFlagKeys: [], operatorConfig: true }), + 'iosSimulatorDeviceSet is not accepted as a tool argument. Set iosSimulatorDeviceSet in ~/.agent-device/config.json for the process serving these tools.', + ); +}); + +test('a key neither env nor config resolves states its own operator path', () => { + assert.equal( + operatorInputRefusal('cwd', { operatorPath: 'Start the process elsewhere.' }), + 'cwd is not accepted as a tool argument. Start the process elsewhere.', + ); +}); + +// `audience` narrows the MODEL-facing surfaces only. The CLI and the Node client +// still accept an operator key as ordinary input -- that is how an operator +// supplies it, and how env/config defaults reach the command route. A +// classification that also narrowed `commonProperties()` would break both +// without failing the MCP tests, which only assert absence. +// (Which keys are operator-owned is pinned at the boundary that enforces it, +// in `mcp/__tests__/command-tools-operator-inputs.test.ts`.) +test('an operator-owned common key stays in the CLI and Node input schema', () => { + const advertised = commonProperties(); + const operatorKeys = Object.keys(COMMON_INPUT_AUDIENCE); + assert.ok(operatorKeys.length > 0, 'the table declares operator-owned common keys'); + for (const key of operatorKeys) { + assert.equal(COMMON_INPUT_AUDIENCE[key]?.kind, 'operator'); + assert.ok(key in advertised, `${key} must stay readable by the CLI and Node client`); + } +}); + +// Schema, readers, and projection are three derivations of one table. A row +// that loses its reader or its projection entry drops the key silently at the +// seam nothing else covers, which is the failure `--no-record` hit twice +// (#1304/#1305). Round-tripping every advertised key catches it. +test('every advertised common key is read and projected onto the client options', () => { + const advertised = Object.keys(commonProperties()); + const record = Object.fromEntries( + advertised.map((key) => [key, commonInputValueFor(key)]), + ) as Record; + record.noRecord = true; + + const input = readCommonInput(record); + const options = commonToClientOptions(input) as Record; + + for (const key of advertised) { + // `deviceTarget` and its `target` alias are one value under two spellings; + // the projection emits the client-facing `target`. + const projected = key === 'deviceTarget' ? 'target' : key; + assert.ok(projected in options, `${key} is dropped between reader and projection`); + } + assert.equal(options.noRecord, true, 'noRecord rides the common seam with no schema of its own'); + assert.equal(options.target, 'mobile'); +}); + +function commonInputValueFor(key: string): unknown { + if (key === 'platform') return 'ios'; + if (key === 'deviceTarget' || key === 'target') return 'mobile'; + if (key === 'debug') return true; + return `value-${key}`; +} diff --git a/src/commands/batch/index.ts b/src/commands/batch/index.ts index 8b3dc71e49..3259b5bfa3 100644 --- a/src/commands/batch/index.ts +++ b/src/commands/batch/index.ts @@ -4,7 +4,7 @@ import { commonInputFromFlags } from '../cli-grammar/common.ts'; import type { CliReader } from '../cli-grammar/types.ts'; import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; -import { commonToClientOptions } from '../command-input.ts'; +import { commonToClientOptions } from '../common-input-fields.ts'; import { batchCliOutputFormatters } from './output.ts'; import { createBatchCommandMetadata, type BatchCommandStep, type BatchInput } from './metadata.ts'; import { STRUCTURED_BATCH_COMMAND_NAMES } from '../../core/batch-policy.ts'; diff --git a/src/commands/batch/metadata.ts b/src/commands/batch/metadata.ts index 4f636f4a95..b2521aa61a 100644 --- a/src/commands/batch/metadata.ts +++ b/src/commands/batch/metadata.ts @@ -13,16 +13,10 @@ import { STRUCTURED_BATCH_COMMAND_NAMES, readStructuredBatchCommandName, } from '../../core/batch-policy.ts'; +import { type CommandMetadata, type JsonSchema } from '../command-contract.ts'; import { - defineCommandMetadata, - type CommandMetadata, - type JsonSchema, -} from '../command-contract.ts'; -import { - assertAllowedKeys, customField, enumField, - fieldsInputSchema, integerField, readFieldInput, requiredField, @@ -30,6 +24,8 @@ import { type CommandFieldMap, type InferCommandInput, } from '../command-input.ts'; +import { defineFieldCommandMetadata } from '../field-command-contract.ts'; +import { assertAllowedKeys } from '../input-readers.ts'; export type BatchCommandStep = { command: string; @@ -48,12 +44,12 @@ export function createBatchCommandMetadata( nestedCommands: readonly string[] = STRUCTURED_BATCH_COMMAND_NAMES, ): CommandMetadata<'batch', BatchInput> { const fields = batchFields(nestedCommands); - return defineCommandMetadata({ - name: 'batch', - description: 'Execute multiple commands in one daemon request.', - inputSchema: fieldsInputSchema(fields), - readInput: (input) => readBatchInput(input, fields), - }); + return defineFieldCommandMetadata( + 'batch', + 'Execute multiple commands in one daemon request.', + fields, + { readInput: (input) => readBatchInput(input, fields) }, + ); } function batchFields(nestedCommands: readonly string[]) { diff --git a/src/commands/capture/alert.ts b/src/commands/capture/alert.ts index 0c229afe84..b718ff245f 100644 --- a/src/commands/capture/alert.ts +++ b/src/commands/capture/alert.ts @@ -1,7 +1,8 @@ import { ALERT_ACTIONS, type AlertAction } from '@agent-device/contracts/alert-contract'; import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; import type { AlertCommandOptions } from '@agent-device/contracts/client'; -import { compactRecord, enumField, integerField } from '../command-input.ts'; +import { enumField, integerField } from '../command-input.ts'; +import { compactRecord } from '../input-readers.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { commonInputFromFlags, diff --git a/src/commands/capture/wait.ts b/src/commands/capture/wait.ts index 97dc8aab7a..1cad2085a1 100644 --- a/src/commands/capture/wait.ts +++ b/src/commands/capture/wait.ts @@ -5,13 +5,8 @@ import { parseWaitPositionals } from '../../core/wait-positionals.ts'; import { SELECTOR_SNAPSHOT_FLAGS } from '../cli-grammar/flag-groups.ts'; import { AppError } from '@agent-device/kernel/errors'; import { isValidSelectorExpression } from '@agent-device/selectors'; -import { - booleanField, - enumField, - integerField, - optionalEnum, - stringField, -} from '../command-input.ts'; +import { booleanField, enumField, integerField, stringField } from '../command-input.ts'; +import { optionalEnum } from '../input-readers.ts'; import { defineExecutableCommand } from '../command-contract.ts'; import { direct, diff --git a/src/commands/cli-grammar/common.ts b/src/commands/cli-grammar/common.ts index 5d0df0a826..35c3a302d7 100644 --- a/src/commands/cli-grammar/common.ts +++ b/src/commands/cli-grammar/common.ts @@ -11,7 +11,8 @@ import { SELECTOR_EXPRESSION_REQUIRED_MESSAGE, splitSelectorFromArgs, } from '@agent-device/selectors'; -import { compactRecord, type SelectorSnapshotInput } from '../command-input.ts'; +import type { SelectorSnapshotInput } from '../command-input.ts'; +import { compactRecord } from '../input-readers.ts'; import type { CommandInput, DaemonCommandRequest, @@ -62,7 +63,7 @@ export function commonInputFromFlags(flags: CliFlags): Record { // the common seam every reader already spreads, so a reader cannot forget // it and a new reader inherits it for free. The three seams it must survive // are this one, `readCommonInput`, and `commonToClientOptions` - // (`commands/command-input.ts`) — a drop at any one of them silently + // (`commands/common-input-fields.ts`) — a drop at any one of them silently // disables the flag (#1304/#1305 fixed only the reader layer, so the flag // still never reached the daemon). // diff --git a/src/commands/cli-grammar/flag-groups.ts b/src/commands/cli-grammar/flag-groups.ts index e07bef94b7..d6f68ce593 100644 --- a/src/commands/cli-grammar/flag-groups.ts +++ b/src/commands/cli-grammar/flag-groups.ts @@ -45,6 +45,16 @@ export const REPEATED_TOUCH_FLAGS = flagKeys( export const SETTLE_FLAGS = flagKeys('settle', 'settleQuietMs', 'timeoutMs'); export const REPLAY_FLAGS = flagKeys('replayUpdate', 'replayEnv'); +// Which flags the CLI PARSER accepts on every command — a different axis from +// the common input keys in `commands/common-input-fields.ts`, which #2027 +// unified into one table. Neither list contains the other: 25 of the 42 keys +// below (`remoteConfig`, `stateDir`, `daemonTransport`, `sessionIsolation`, +// `leaseBackend`, `sessionLock`, every `provider*` and `aws*`) never become +// structured command input, while the table's `cwd` and `debug` are not flags +// and its `deviceTarget` row is spelled `target` here. Deriving this list from +// that table would mean 25 rows carrying no schema, reader, or projection, so +// the two stay separate; the table declares only `envFlagKeys`, where a flag key +// names the environment variable an operator-owned input comes from. export const COMMON_COMMAND_SUPPORTED_FLAG_KEYS = flagKeys( 'remoteConfig', 'stateDir', diff --git a/src/commands/command-contract.ts b/src/commands/command-contract.ts index b12157c30e..cad22ee752 100644 --- a/src/commands/command-contract.ts +++ b/src/commands/command-contract.ts @@ -1,4 +1,5 @@ import type { AgentDeviceClient } from '../client/client-types.ts'; +import type { InputAudienceMap } from './input-audience.ts'; export type JsonSchema = { type?: string | readonly string[]; @@ -34,12 +35,20 @@ export type CommandMetadata = { inputSchema: JsonSchema; readInput: (input: unknown) => Input; /** - * Released input keys the command removed. Excluded from `inputSchema` (not - * advertised), but still recognized: `readInput` throws migration guidance - * when one is supplied. The MCP admission boundary reads this so it lets a - * retired key reach that message instead of rejecting it as unknown. + * Non-model audiences this command's own input keys declare + * (`commands/input-audience.ts`). `retired` keys are excluded from + * `inputSchema` but still recognized, so the MCP admission boundary lets one + * through to `readInput`'s migration guidance instead of rejecting it as + * unknown; `operator` keys stay in `inputSchema` for the CLI and the Node + * client, and the MCP boundary hides and refuses them. + * + * Required, and empty for most commands: a field that declares an audience is + * only honored because its command carries it here, so leaving this optional + * would make "forgot to wire it up" a silent model-writable credential rather + * than a type error. `defineFieldCommandMetadata` derives it from the field + * map, which is why that is the one construction path for a field command. */ - retiredInputKeys?: readonly string[]; + inputAudience: InputAudienceMap; }; export type ExecutableCommandContract = CommandMetadata< diff --git a/src/commands/command-input.ts b/src/commands/command-input.ts index df003c8c20..ec76d2a49c 100644 --- a/src/commands/command-input.ts +++ b/src/commands/command-input.ts @@ -8,36 +8,33 @@ import { readOptionalInteger as optionalInteger, readOptionalNumber as optionalNumberValue, } from '@agent-device/contracts/command'; -import { - DEVICE_TARGETS, - PLATFORM_SELECTORS, - type DeviceTarget, - type PlatformSelector, -} from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; import type { RepeatedInput } from '@agent-device/contracts/interaction'; import type { JsonSchema } from './command-contract.ts'; +import { + commonProperties, + commonToClientOptions, + readCommonInput, + type CommonCommandInput, +} from './common-input-fields.ts'; +import type { InputAudience, InputAudienceMap, OperatorInputSource } from './input-audience.ts'; +import { + compactRecord, + optionalAnyString, + optionalBoolean, + optionalEnum, + optionalRecord, + optionalString, + optionalStringArray, + readInputRecord, + readRecordField, + requiredEnum, + requiredNumber, + requiredString, +} from './input-readers.ts'; const INTERACTION_TARGET_KINDS = ['ref', 'selector', 'point'] as const; -export type CommonCommandInput = Pick< - AgentDeviceRequestOverrides, - 'session' | 'daemonBaseUrl' | 'daemonAuthToken' | 'tenant' | 'runId' | 'leaseId' | 'cwd' | 'debug' -> & { - platform?: PlatformSelector; - deviceTarget?: DeviceTarget; - device?: string; - udid?: string; - serial?: string; - iosSimulatorDeviceSet?: string; - iosXctestrunFile?: string; - iosXctestDerivedDataPath?: string; - iosXctestEnvDir?: string; - androidDeviceAllowlist?: string; - /** `--no-record`: common to every recordable command (see `commonInputFromFlags`). */ - noRecord?: boolean; -}; - export type InteractionTargetInput = | { kind: 'ref'; ref: string; label?: string } | { kind: 'selector'; selector: string } @@ -54,7 +51,6 @@ export type SelectorSnapshotInput = { }; export type PointInput = { x: number; y: number }; -type CommonInputOptions = { readTargetAlias?: boolean }; function commandInputSchema( properties: Record, @@ -134,7 +130,8 @@ export type CommandField = { schema: JsonSchema; required: boolean; read: FieldReader; - retired?: true; + /** Who may write the key. Absent means the model — see `input-audience.ts`. */ + audience?: InputAudience; }; export type CommandFieldMap = Record>; @@ -182,7 +179,7 @@ export function retiredField(message: string): CommandField { return { schema: { type: 'null' }, required: false, - retired: true, + audience: { kind: 'retired', message }, read: (record, key) => { if (Object.hasOwn(record, key)) { throw new AppError('INVALID_ARGS', message); @@ -192,6 +189,19 @@ export function retiredField(message: string): CommandField { }; } +/** + * A key the CLI and the Node client accept but no model-facing tool schema may + * advertise or admit — a credential, an endpoint a credential is sent to, or an + * operator infrastructure path. `source` names how the operator supplies it, and + * the refusal is rendered from that. + */ +export function operatorField( + field: CommandField, + source: OperatorInputSource, +): CommandField { + return { ...field, audience: { kind: 'operator', source } }; +} + export function numberField( description?: string, options: { min?: number; max?: number } = {}, @@ -306,61 +316,6 @@ export function readFieldInput( }) as InferCommandInput; } -export function readInputRecord(input: unknown): Record { - if (input === undefined || input === null) return {}; - if (!input || typeof input !== 'object' || Array.isArray(input)) { - throw new AppError('INVALID_ARGS', 'Expected object arguments.'); - } - return input as Record; -} - -export function readCommonInput( - record: Record, - options: CommonInputOptions = {}, -): CommonCommandInput { - return { - session: optionalString(record, 'session'), - platform: optionalEnum(record, 'platform', PLATFORM_SELECTORS), - deviceTarget: readDeviceTarget(record, options), - device: optionalString(record, 'device'), - udid: optionalString(record, 'udid'), - serial: optionalString(record, 'serial'), - iosSimulatorDeviceSet: optionalString(record, 'iosSimulatorDeviceSet'), - iosXctestrunFile: optionalString(record, 'iosXctestrunFile'), - iosXctestDerivedDataPath: optionalString(record, 'iosXctestDerivedDataPath'), - iosXctestEnvDir: optionalString(record, 'iosXctestEnvDir'), - androidDeviceAllowlist: optionalString(record, 'androidDeviceAllowlist'), - // Seam 2 of 3 for `--no-record` (see `commonInputFromFlags`). `readFieldInput` - // keeps ONLY declared metadata fields plus this common input, so a flag - // absent here is filtered out of every field-based command's input before - // the client ever sees it. - noRecord: optionalBoolean(record, 'noRecord'), - daemonBaseUrl: optionalString(record, 'daemonBaseUrl'), - daemonAuthToken: optionalString(record, 'daemonAuthToken'), - tenant: optionalString(record, 'tenant'), - runId: optionalString(record, 'runId'), - leaseId: optionalString(record, 'leaseId'), - cwd: optionalString(record, 'cwd'), - debug: optionalBoolean(record, 'debug'), - }; -} - -function readDeviceTarget( - record: Record, - options: CommonInputOptions, -): DeviceTarget | undefined { - const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGETS); - if (options.readTargetAlias === false || record.target === undefined) return deviceTarget; - const targetAlias = optionalEnum(record, 'target', DEVICE_TARGETS); - if (deviceTarget !== undefined && targetAlias !== deviceTarget) { - throw new AppError( - 'INVALID_ARGS', - 'Expected target alias to match deviceTarget when both are set.', - ); - } - return deviceTarget ?? targetAlias; -} - function readInteractionTarget( record: Record, key: string, @@ -403,111 +358,6 @@ function readPoint(record: Record, key: string): PointInput { return { x: requiredNumber(point, 'x'), y: requiredNumber(point, 'y') }; } -function requiredString(record: Record, key: string): string { - const value = record[key]; - if (typeof value !== 'string' || value.length === 0) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be a non-empty string.`); - } - return value; -} - -/** - * Opt-in reader for the one field where the empty string is a VALUE, not a missing input: - * `fill ""` is the clear-field primitive (#2063). `requiredField` still refuses a - * missing key, so "" and absent stay distinguishable; every other string field keeps - * {@link optionalString}'s non-empty rule. - */ -function optionalAnyString(record: Record, key: string): string | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== 'string') { - throw new AppError('INVALID_ARGS', `Expected ${key} to be a string.`); - } - return value; -} - -function optionalString(record: Record, key: string): string | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== 'string' || value.length === 0) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be a non-empty string.`); - } - return value; -} - -function requiredNumber(record: Record, key: string): number { - const value = record[key]; - if (typeof value !== 'number' || !Number.isFinite(value)) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be a finite number.`); - } - return value; -} - -function optionalBoolean(record: Record, key: string): boolean | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== 'boolean') { - throw new AppError('INVALID_ARGS', `Expected ${key} to be a boolean.`); - } - return value; -} - -function requiredEnum( - record: Record, - key: string, - values: T, -): T[number] { - const value = record[key]; - if (typeof value !== 'string' || !values.includes(value)) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be one of: ${values.join(', ')}.`); - } - return value; -} - -export function optionalEnum( - record: Record, - key: string, - values: T, -): T[number] | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (typeof value !== 'string' || !values.includes(value)) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be one of: ${values.join(', ')}.`); - } - return value; -} - -export function commonToClientOptions( - input: CommonCommandInput, -): AgentDeviceRequestOverrides & AgentDeviceSelectionOptions { - return compactRecord({ - // Seam 3 of 3 for `--no-record` (see `commonInputFromFlags`). Every - // `to*Options` projection (`toPressOptions`, `toGetOptions`, ...) rebuilds - // the client options object from this helper plus its own named fields, so - // a flag absent here is dropped even when the reader forwarded it and - // `readCommonInput` kept it. - noRecord: input.noRecord, - session: input.session, - platform: input.platform, - target: input.deviceTarget, - device: input.device, - udid: input.udid, - serial: input.serial, - iosSimulatorDeviceSet: input.iosSimulatorDeviceSet, - iosXctestrunFile: input.iosXctestrunFile, - iosXctestDerivedDataPath: input.iosXctestDerivedDataPath, - iosXctestEnvDir: input.iosXctestEnvDir, - androidDeviceAllowlist: input.androidDeviceAllowlist, - daemonBaseUrl: input.daemonBaseUrl, - daemonAuthToken: input.daemonAuthToken, - tenant: input.tenant, - runId: input.runId, - leaseId: input.leaseId, - cwd: input.cwd, - debug: input.debug, - }) as AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; -} - export function toClientInteractionTarget(target: InteractionTargetInput): InteractionTarget { switch (target.kind) { case 'ref': @@ -546,27 +396,6 @@ export function toSelectorSnapshotOptions(input: SelectorSnapshotInput): Selecto }; } -export function assertAllowedKeys( - record: Record, - allowedKeys: readonly string[], - label: string, - hint?: string, -): void { - const allowed = new Set(allowedKeys); - const unknownKeys = Object.keys(record).filter((key) => !allowed.has(key)); - if (unknownKeys.length > 0) { - throw new AppError( - 'INVALID_ARGS', - `${label} has unknown field(s): ${unknownKeys.join(', ')}.`, - hint === undefined ? undefined : { hint }, - ); - } -} - -export function compactRecord(record: Record): Record { - return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); -} - function optionalField(schema: JsonSchema, read: FieldReader): CommandField { return { schema, required: false, read }; } @@ -585,7 +414,7 @@ function integerSchemaWithBounds( function fieldProperties(fields: CommandFieldMap): Record { return Object.fromEntries( Object.entries(fields) - .filter(([, field]) => !field.retired) + .filter(([, field]) => field.audience?.kind !== 'retired') .map(([key, field]) => [key, field.schema]), ); } @@ -594,89 +423,13 @@ function requiredFieldNames(fields: CommandFieldMap): string[] { return Object.entries(fields).flatMap(([key, field]) => (field.required ? [key] : [])); } -/** Names of the retired fields — declared for migration guidance, absent from the schema. */ -export function retiredFieldNames(fields: CommandFieldMap): string[] { - return Object.entries(fields).flatMap(([key, field]) => (field.retired ? [key] : [])); -} - -function optionalRecord( - record: Record, - key: string, -): Record | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be an object.`); - } - return value as Record; -} - -function optionalStringArray(record: Record, key: string): string[] | undefined { - const value = record[key]; - if (value === undefined) return undefined; - if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be an array of strings.`); - } - return value as string[]; -} - -function commonProperties(): Record { - return { - session: { type: 'string', description: 'Agent-device session name.' }, - platform: { - type: 'string', - enum: PLATFORM_SELECTORS, - description: 'Platform selector used to resolve a device.', - }, - deviceTarget: { - type: 'string', - enum: DEVICE_TARGETS, - description: 'Device target form. Maps to the CLI --target flag.', - }, - target: { - type: 'string', - enum: DEVICE_TARGETS, - description: - 'Alias for deviceTarget on commands without a UI target field. Interaction commands reserve target for the UI element.', - }, - device: { - type: 'string', - description: 'Device name selector (a UDID belongs in udid, a serial in serial).', - }, - udid: { - type: 'string', - description: - 'Apple device or simulator UDID; the selector that pins one device when several share a name.', - }, - serial: { type: 'string', description: 'Android, HarmonyOS, or Vega VVD serial selector.' }, - iosSimulatorDeviceSet: { - type: 'string', - description: 'iOS simulator device-set path used for device resolution.', - }, - iosXctestrunFile: { - type: 'string', - description: 'Externally built iOS XCTest runner .xctestrun artifact path.', - }, - iosXctestDerivedDataPath: { - type: 'string', - description: 'Derived data path for external iOS XCTest runner execution.', - }, - iosXctestEnvDir: { - type: 'string', - description: 'Writable directory for iOS XCTest runner env overlays.', - }, - androidDeviceAllowlist: { - type: 'string', - description: 'Android serial allowlist used for device resolution.', - }, - daemonBaseUrl: { type: 'string', description: 'Remote daemon base URL.' }, - daemonAuthToken: { type: 'string', description: 'Remote daemon auth token.' }, - tenant: { type: 'string', description: 'Remote tenant identifier.' }, - runId: { type: 'string', description: 'Lease run identifier.' }, - leaseId: { type: 'string', description: 'Existing lease identifier.' }, - cwd: { type: 'string', description: 'Working directory for command execution.' }, - debug: { type: 'boolean', description: 'Enable debug diagnostics.' }, - }; +/** Non-model audiences declared by a command's own fields, for the surface boundaries to honor. */ +export function fieldAudiences(fields: CommandFieldMap): InputAudienceMap { + return Object.fromEntries( + Object.entries(fields).flatMap(([key, field]) => + field.audience ? [[key, field.audience]] : [], + ), + ); } function interactionTargetSchema(): JsonSchema { @@ -728,11 +481,3 @@ function elementTargetSchemaVariants(): JsonSchema[] { }, ]; } - -function readRecordField(record: Record, key: string): Record { - const value = record[key]; - if (!value || typeof value !== 'object' || Array.isArray(value)) { - throw new AppError('INVALID_ARGS', `Expected ${key} to be an object.`); - } - return value as Record; -} diff --git a/src/commands/common-input-fields.ts b/src/commands/common-input-fields.ts new file mode 100644 index 0000000000..bb8dd3d9ab --- /dev/null +++ b/src/commands/common-input-fields.ts @@ -0,0 +1,254 @@ +import type { + AgentDeviceRequestOverrides, + AgentDeviceSelectionOptions, +} from '@agent-device/contracts/client'; +import { + DEVICE_TARGETS, + PLATFORM_SELECTORS, + type DeviceTarget, + type PlatformSelector, +} from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import type { JsonSchema } from './command-contract.ts'; +import { operatorAudience, type InputAudience, type InputAudienceMap } from './input-audience.ts'; +import { compactRecord, optionalBoolean, optionalEnum, optionalString } from './input-readers.ts'; + +export type CommonCommandInput = Pick< + AgentDeviceRequestOverrides, + 'session' | 'daemonBaseUrl' | 'daemonAuthToken' | 'tenant' | 'runId' | 'leaseId' | 'cwd' | 'debug' +> & { + platform?: PlatformSelector; + deviceTarget?: DeviceTarget; + device?: string; + udid?: string; + serial?: string; + iosSimulatorDeviceSet?: string; + iosXctestrunFile?: string; + iosXctestDerivedDataPath?: string; + iosXctestEnvDir?: string; + androidDeviceAllowlist?: string; + /** `--no-record`: common to every recordable command (see `commonInputFromFlags`). */ + noRecord?: boolean; +}; + +export type CommonInputReadOptions = { readTargetAlias?: boolean }; + +/** + * One row per input key every command accepts. The JSON schema, the readers, + * the client-options projection, and the model-facing audience boundary are all + * derived from this table, so adding or reclassifying a common key is a one-row + * edit rather than a matching edit in four parallel enumerations. + */ +type CommonInputFieldSpec = { + /** Advertised property. Absent for a key each command schema carries itself (`noRecord`). */ + schema?: JsonSchema; + /** Absent for a schema-only alias, whose value another row's reader picks up. */ + read?: (record: Record, options: CommonInputReadOptions) => unknown; + /** Client-options key, when the projection renames it. */ + clientKey?: string; + /** Who may write the key. Absent means the model — see `input-audience.ts`. */ + audience?: InputAudience; +}; + +/** + * The common operator shape: the value comes from the key's own environment + * variable or its `~/.agent-device/config.json` entry. It covers both kinds of + * operator-owned common key — the credential and the endpoint it is sent to + * (a model-writable `daemonBaseUrl` would redirect the env-resolved + * `daemonAuthToken` to an arbitrary server), and the build and device-set paths, + * which select operator infrastructure rather than per-call work. + */ +const ENV_OR_OPERATOR_CONFIG = operatorAudience({ operatorConfig: true }); + +const COMMON_INPUT_FIELDS = { + session: { + schema: { type: 'string', description: 'Agent-device session name.' }, + read: (record) => optionalString(record, 'session'), + }, + platform: { + schema: { + type: 'string', + enum: PLATFORM_SELECTORS, + description: 'Platform selector used to resolve a device.', + }, + read: (record) => optionalEnum(record, 'platform', PLATFORM_SELECTORS), + }, + deviceTarget: { + schema: { + type: 'string', + enum: DEVICE_TARGETS, + description: 'Device target form. Maps to the CLI --target flag.', + }, + read: readDeviceTarget, + clientKey: 'target', + }, + target: { + // Read through `deviceTarget` above, which reconciles the two spellings. + schema: { + type: 'string', + enum: DEVICE_TARGETS, + description: + 'Alias for deviceTarget on commands without a UI target field. Interaction commands reserve target for the UI element.', + }, + }, + device: { + schema: { + type: 'string', + description: 'Device name selector (a UDID belongs in udid, a serial in serial).', + }, + read: (record) => optionalString(record, 'device'), + }, + udid: { + schema: { + type: 'string', + description: + 'Apple device or simulator UDID; the selector that pins one device when several share a name.', + }, + read: (record) => optionalString(record, 'udid'), + }, + serial: { + schema: { + type: 'string', + description: 'Android, HarmonyOS, or Vega VVD serial selector.', + }, + read: (record) => optionalString(record, 'serial'), + }, + iosSimulatorDeviceSet: { + schema: { + type: 'string', + description: 'iOS simulator device-set path used for device resolution.', + }, + read: (record) => optionalString(record, 'iosSimulatorDeviceSet'), + audience: operatorAudience({ envFlagKeys: [], operatorConfig: true }), + }, + iosXctestrunFile: { + schema: { + type: 'string', + description: 'Externally built iOS XCTest runner .xctestrun artifact path.', + }, + read: (record) => optionalString(record, 'iosXctestrunFile'), + audience: ENV_OR_OPERATOR_CONFIG, + }, + iosXctestDerivedDataPath: { + schema: { + type: 'string', + description: 'Derived data path for external iOS XCTest runner execution.', + }, + read: (record) => optionalString(record, 'iosXctestDerivedDataPath'), + audience: ENV_OR_OPERATOR_CONFIG, + }, + iosXctestEnvDir: { + schema: { + type: 'string', + description: 'Writable directory for iOS XCTest runner env overlays.', + }, + read: (record) => optionalString(record, 'iosXctestEnvDir'), + audience: ENV_OR_OPERATOR_CONFIG, + }, + androidDeviceAllowlist: { + schema: { + type: 'string', + description: 'Android serial allowlist used for device resolution.', + }, + read: (record) => optionalString(record, 'androidDeviceAllowlist'), + }, + noRecord: { + // Seam 2 of 3 for `--no-record` (see `commonInputFromFlags`). `readFieldInput` + // keeps ONLY declared metadata fields plus this common input, so a flag + // absent here is filtered out of every field-based command's input before + // the client ever sees it. + read: (record) => optionalBoolean(record, 'noRecord'), + }, + daemonBaseUrl: { + schema: { type: 'string', description: 'Remote daemon base URL.' }, + read: (record) => optionalString(record, 'daemonBaseUrl'), + audience: ENV_OR_OPERATOR_CONFIG, + }, + daemonAuthToken: { + schema: { type: 'string', description: 'Remote daemon auth token.' }, + read: (record) => optionalString(record, 'daemonAuthToken'), + audience: ENV_OR_OPERATOR_CONFIG, + }, + tenant: { + schema: { type: 'string', description: 'Remote tenant identifier.' }, + read: (record) => optionalString(record, 'tenant'), + }, + runId: { + schema: { type: 'string', description: 'Lease run identifier.' }, + read: (record) => optionalString(record, 'runId'), + }, + leaseId: { + schema: { type: 'string', description: 'Existing lease identifier.' }, + read: (record) => optionalString(record, 'leaseId'), + }, + cwd: { + schema: { type: 'string', description: 'Working directory for command execution.' }, + read: (record) => optionalString(record, 'cwd'), + audience: operatorAudience({ + operatorPath: + 'Start the process serving these tools in the desired working directory, or pass absolute paths.', + }), + }, + debug: { + schema: { type: 'boolean', description: 'Enable debug diagnostics.' }, + read: (record) => optionalBoolean(record, 'debug'), + }, +} as const satisfies Record; + +const COMMON_INPUT_ROWS: ReadonlyArray = + Object.entries(COMMON_INPUT_FIELDS); + +/** Common keys no model-facing tool schema advertises or admits. */ +export const COMMON_INPUT_AUDIENCE: InputAudienceMap = Object.fromEntries( + COMMON_INPUT_ROWS.flatMap(([key, field]) => (field.audience ? [[key, field.audience]] : [])), +); + +export function commonProperties(): Record { + return Object.fromEntries( + COMMON_INPUT_ROWS.flatMap(([key, field]) => (field.schema ? [[key, field.schema]] : [])), + ); +} + +export function readCommonInput( + record: Record, + options: CommonInputReadOptions = {}, +): CommonCommandInput { + const input: Record = {}; + for (const [key, field] of COMMON_INPUT_ROWS) { + if (!field.read) continue; + input[key] = field.read(record, options); + } + return input as CommonCommandInput; +} + +export function commonToClientOptions( + input: CommonCommandInput, +): AgentDeviceRequestOverrides & AgentDeviceSelectionOptions { + const options: Record = {}; + for (const [key, field] of COMMON_INPUT_ROWS) { + if (!field.read) continue; + // Seam 3 of 3 for `--no-record` (see `commonInputFromFlags`). Every + // `to*Options` projection (`toPressOptions`, `toGetOptions`, ...) rebuilds + // the client options object from this helper plus its own named fields, so + // a key absent here is dropped even when the reader forwarded it and + // `readCommonInput` kept it. + options[field.clientKey ?? key] = input[key as keyof CommonCommandInput]; + } + return compactRecord(options) as AgentDeviceRequestOverrides & AgentDeviceSelectionOptions; +} + +function readDeviceTarget( + record: Record, + options: CommonInputReadOptions, +): DeviceTarget | undefined { + const deviceTarget = optionalEnum(record, 'deviceTarget', DEVICE_TARGETS); + if (options.readTargetAlias === false || record.target === undefined) return deviceTarget; + const targetAlias = optionalEnum(record, 'target', DEVICE_TARGETS); + if (deviceTarget !== undefined && targetAlias !== deviceTarget) { + throw new AppError( + 'INVALID_ARGS', + 'Expected target alias to match deviceTarget when both are set.', + ); + } + return deviceTarget ?? targetAlias; +} diff --git a/src/commands/field-command-contract.ts b/src/commands/field-command-contract.ts index 7a5f5135cd..83c7710f49 100644 --- a/src/commands/field-command-contract.ts +++ b/src/commands/field-command-contract.ts @@ -1,20 +1,48 @@ -import { defineCommandMetadata } from './command-contract.ts'; +import { defineCommandMetadata, type CommandMetadata } from './command-contract.ts'; import { + fieldAudiences, fieldsInputSchema, readFieldInput, - retiredFieldNames, type CommandFieldMap, + type InferCommandInput, } from './command-input.ts'; +type FieldCommandOptions = { + /** For a command that reads its own fields (`batch` validates steps, `gesture` reads a union). */ + readInput: (input: unknown) => TInput; +}; + +/** + * The one construction path for a command whose input is a field map. It is the + * only place `fieldsInputSchema` and `fieldAudiences` are called, so a field + * declaring `operatorField(...)` or `retiredField(...)` cannot reach a command + * whose metadata forgot to carry that audience to the surface boundaries. + */ export function defineFieldCommandMetadata< const TName extends string, const TFields extends CommandFieldMap, ->(name: TName, description: string, fields: TFields) { +>( + name: TName, + description: string, + fields: TFields, +): CommandMetadata>; +export function defineFieldCommandMetadata( + name: TName, + description: string, + fields: CommandFieldMap, + options: FieldCommandOptions, +): CommandMetadata; +export function defineFieldCommandMetadata( + name: string, + description: string, + fields: CommandFieldMap, + options?: FieldCommandOptions, +): CommandMetadata { return defineCommandMetadata({ name, description, inputSchema: fieldsInputSchema(fields), - readInput: (input) => readFieldInput(input, fields), - retiredInputKeys: retiredFieldNames(fields), + readInput: options?.readInput ?? ((input) => readFieldInput(input, fields)), + inputAudience: fieldAudiences(fields), }); } diff --git a/src/commands/input-audience.ts b/src/commands/input-audience.ts new file mode 100644 index 0000000000..67e9200434 --- /dev/null +++ b/src/commands/input-audience.ts @@ -0,0 +1,64 @@ +import { buildPrimaryEnvVarName } from '../utils/source-value.ts'; + +/** + * Where an operator supplies a value that no model-facing surface may accept as + * an argument. Declared beside the field it governs, so the refusal text is + * rendered from the declaration instead of hand-written again in whichever + * boundary happens to enforce it. + */ +export type OperatorInputSource = + /** + * Environment variables named after these CLI flag keys carry the value — the + * input key itself when `envFlagKeys` is omitted — plus + * `~/.agent-device/config.json` under the input key when `operatorConfig`. + */ + | { envFlagKeys?: readonly [string, ...string[]]; operatorConfig?: boolean } + /** + * No environment variable (the `ENV_EXCLUDED_FLAG_KEYS` set in + * `cli-schema/option-schema.ts`), so the config file is the only path. + */ + | { envFlagKeys: readonly []; operatorConfig: true } + /** Neither env nor config resolves it: the declaration states its own sentence. */ + | { operatorPath: string }; + +/** + * Who may write an input key. An absent audience means the model — the default, + * and the only audience a tool schema advertises. + * + * `operator` keys stay in the CLI and Node input schemas but are removed from + * every model-facing tool schema and refused there: the model both reads + * untrusted app UI text and picks tool arguments, so a screen that steers it + * into writing a token, an endpoint, or an infrastructure path must find no + * parameter to write it into. Operator-sourced values still flow in from env + * and config outside the model-writable surface. + * + * `retired` keys are released keys that were removed. They are absent from + * every schema but still recognized, so supplying one answers with migration + * guidance instead of silently dropping the value or rejecting it as unknown. + */ +export type InputAudience = + | { kind: 'operator'; source: OperatorInputSource } + | { kind: 'retired'; message: string }; + +/** Non-model audiences by input key. Keys absent from the map are model-writable. */ +export type InputAudienceMap = Readonly>; + +export function operatorAudience(source: OperatorInputSource): InputAudience { + return { kind: 'operator', source }; +} + +/** The refusal a model-facing surface answers with, rendered from the declared source. */ +export function operatorInputRefusal(key: string, source: OperatorInputSource): string { + return `${key} is not accepted as a tool argument. ${operatorPathSentence(key, source)}`; +} + +function operatorPathSentence(key: string, source: OperatorInputSource): string { + if ('operatorPath' in source) return source.operatorPath; + const forTheServer = 'for the process serving these tools.'; + // The type admits an empty `envFlagKeys` only together with `operatorConfig`, + // so a source can never render a sentence naming no path at all. + const envNames = (source.envFlagKeys ?? [key]).map(buildPrimaryEnvVarName).join(' or '); + if (envNames === '') return `Set ${key} in ~/.agent-device/config.json ${forTheServer}`; + const configPath = source.operatorConfig ? ` (or ${key} in ~/.agent-device/config.json)` : ''; + return `Set the ${envNames} environment variable${configPath} ${forTheServer}`; +} diff --git a/src/commands/input-readers.ts b/src/commands/input-readers.ts new file mode 100644 index 0000000000..f52e7c5e30 --- /dev/null +++ b/src/commands/input-readers.ts @@ -0,0 +1,144 @@ +import { AppError } from '@agent-device/kernel/errors'; + +/** Primitive readers shared by the common-field table and the per-command field constructors. */ + +export function readInputRecord(input: unknown): Record { + if (input === undefined || input === null) return {}; + if (!input || typeof input !== 'object' || Array.isArray(input)) { + throw new AppError('INVALID_ARGS', 'Expected object arguments.'); + } + return input as Record; +} + +export function readRecordField( + record: Record, + key: string, +): Record { + const value = record[key]; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be an object.`); + } + return value as Record; +} + +export function requiredString(record: Record, key: string): string { + const value = record[key]; + if (typeof value !== 'string' || value.length === 0) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be a non-empty string.`); + } + return value; +} + +/** + * Opt-in reader for the one field where the empty string is a VALUE, not a missing input: + * `fill ""` is the clear-field primitive (#2063). `requiredField` still refuses a + * missing key, so "" and absent stay distinguishable; every other string field keeps + * {@link optionalString}'s non-empty rule. + */ +export function optionalAnyString( + record: Record, + key: string, +): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string') { + throw new AppError('INVALID_ARGS', `Expected ${key} to be a string.`); + } + return value; +} + +export function optionalString(record: Record, key: string): string | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value.length === 0) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be a non-empty string.`); + } + return value; +} + +export function requiredNumber(record: Record, key: string): number { + const value = record[key]; + if (typeof value !== 'number' || !Number.isFinite(value)) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be a finite number.`); + } + return value; +} + +export function optionalBoolean(record: Record, key: string): boolean | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'boolean') { + throw new AppError('INVALID_ARGS', `Expected ${key} to be a boolean.`); + } + return value; +} + +export function requiredEnum( + record: Record, + key: string, + values: T, +): T[number] { + const value = record[key]; + if (typeof value !== 'string' || !values.includes(value)) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be one of: ${values.join(', ')}.`); + } + return value; +} + +export function optionalEnum( + record: Record, + key: string, + values: T, +): T[number] | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (typeof value !== 'string' || !values.includes(value)) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be one of: ${values.join(', ')}.`); + } + return value; +} + +export function optionalRecord( + record: Record, + key: string, +): Record | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be an object.`); + } + return value as Record; +} + +export function optionalStringArray( + record: Record, + key: string, +): string[] | undefined { + const value = record[key]; + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string')) { + throw new AppError('INVALID_ARGS', `Expected ${key} to be an array of strings.`); + } + return value as string[]; +} + +export function assertAllowedKeys( + record: Record, + allowedKeys: readonly string[], + label: string, + hint?: string, +): void { + const allowed = new Set(allowedKeys); + const unknownKeys = Object.keys(record).filter((key) => !allowed.has(key)); + if (unknownKeys.length > 0) { + throw new AppError( + 'INVALID_ARGS', + `${label} has unknown field(s): ${unknownKeys.join(', ')}.`, + hint === undefined ? undefined : { hint }, + ); + } +} + +export function compactRecord(record: Record): Record { + return Object.fromEntries(Object.entries(record).filter(([, value]) => value !== undefined)); +} diff --git a/src/commands/interaction/gesture.ts b/src/commands/interaction/gesture.ts index e515c25998..49a96efd72 100644 --- a/src/commands/interaction/gesture.ts +++ b/src/commands/interaction/gesture.ts @@ -1,5 +1,5 @@ import { PUBLIC_COMMANDS } from '../../command-catalog.ts'; -import { compactRecord } from '../command-input.ts'; +import { compactRecord } from '../input-readers.ts'; import type { CliFlags } from '@agent-device/contracts/command'; import { commonInputFromFlags, request } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; diff --git a/src/commands/interaction/index.ts b/src/commands/interaction/index.ts index 9707baa478..a3f7ca67aa 100644 --- a/src/commands/interaction/index.ts +++ b/src/commands/interaction/index.ts @@ -24,12 +24,12 @@ import { REPEATED_TOUCH_FLAGS, SELECTOR_SNAPSHOT_FLAGS } from '../cli-grammar/fl import { defineExecutableCommand } from '../command-contract.ts'; import { postActionObservationCliFlags } from '../post-action-observation-grammar.ts'; import { - commonToClientOptions, toClientElementTarget, toClientInteractionTarget, toRepeatedOptions, toSelectorSnapshotOptions, } from '../command-input.ts'; +import { commonToClientOptions } from '../common-input-fields.ts'; import { defineCommandFacet, defineCommandFamilyFromFacets } from '../family/types.ts'; import { gestureCliReaders, gestureDaemonWriters } from './gesture.ts'; import { interactionCliReaders, interactionDaemonWriters } from './interactions.ts'; diff --git a/src/commands/interaction/metadata.ts b/src/commands/interaction/metadata.ts index d010c53795..435eaea51f 100644 --- a/src/commands/interaction/metadata.ts +++ b/src/commands/interaction/metadata.ts @@ -19,27 +19,23 @@ import { SWIPE_REPETITION_MAX, } from '@agent-device/contracts/scroll-gesture'; import { FIND_LOCATORS } from '@agent-device/selectors'; -import { defineCommandMetadata } from '../command-contract.ts'; import { booleanField, elementTargetField, enumField, - fieldsInputSchema, integerField, interactionTargetField, numberField, pointField, - readCommonInput, - readFieldInput, - readInputRecord, repeatedFields, requiredField, selectorSnapshotFields, stringField, type CommandFieldMap, - type CommonCommandInput, type InferCommandInput, } from '../command-input.ts'; +import { readCommonInput, type CommonCommandInput } from '../common-input-fields.ts'; +import { readInputRecord } from '../input-readers.ts'; import { defineFieldCommandMetadata } from '../field-command-contract.ts'; import { postActionObservationFields } from '../post-action-observation-grammar.ts'; import { SCROLL_INPUT_DIRECTIONS } from './runtime/gestures.ts'; @@ -245,24 +241,9 @@ export type GestureInput = | DragInput; export const interactionCommandMetadata = [ - defineCommandMetadata({ - name: 'click', - description: interactionCommandDescriptions.click, - inputSchema: fieldsInputSchema(clickFields), - readInput: (input) => readFieldInput(input, clickFields), - }), - defineCommandMetadata({ - name: 'press', - description: interactionCommandDescriptions.press, - inputSchema: fieldsInputSchema(pressFields), - readInput: (input) => readFieldInput(input, pressFields), - }), - defineCommandMetadata({ - name: 'fill', - description: interactionCommandDescriptions.fill, - inputSchema: fieldsInputSchema(fillFields), - readInput: (input) => readFieldInput(input, fillFields), - }), + defineInteractionCommandMetadata('click', clickFields), + defineInteractionCommandMetadata('press', pressFields), + defineInteractionCommandMetadata('fill', fillFields), defineInteractionCommandMetadata('longpress', longPressFields), defineInteractionCommandMetadata('hover', hoverFields), defineInteractionCommandMetadata('swipe', swipeFields), @@ -272,10 +253,7 @@ export const interactionCommandMetadata = [ defineInteractionCommandMetadata('get', getFields), defineInteractionCommandMetadata('is', isFields), defineInteractionCommandMetadata('find', findFields), - defineCommandMetadata({ - name: 'gesture', - description: interactionCommandDescriptions.gesture, - inputSchema: fieldsInputSchema(gestureFields), + defineFieldCommandMetadata('gesture', interactionCommandDescriptions.gesture, gestureFields, { readInput: readGestureInput, }), ] as const; diff --git a/src/commands/metro/index.ts b/src/commands/metro/index.ts index b151520324..5f51cbf546 100644 --- a/src/commands/metro/index.ts +++ b/src/commands/metro/index.ts @@ -11,6 +11,7 @@ import { enumField, integerField, jsonSchemaField, + operatorField, requiredField, stringField, stringSchema, @@ -37,8 +38,15 @@ export const metroCommandMetadata = defineFieldCommandMetadata( projectRoot: stringField(), kind: jsonSchemaField(stringSchema()), publicBaseUrl: stringField(), - proxyBaseUrl: stringField(), - bearerToken: stringField(), + // Operator-owned: the endpoint a bearer token is sent to, and the token + // itself. Both stay CLI/Node inputs; no model-facing tool schema offers them. + proxyBaseUrl: operatorField(stringField(), { + operatorPath: + 'Configure the remote proxy in the remote-config profile or operator config for the process serving these tools.', + }), + bearerToken: operatorField(stringField(), { + envFlagKeys: ['metroBearerToken', 'daemonAuthToken'], + }), bridgeScope: jsonSchemaField({ type: 'object', additionalProperties: true, diff --git a/src/commands/system/index.ts b/src/commands/system/index.ts index fcb1042255..7acd8e461d 100644 --- a/src/commands/system/index.ts +++ b/src/commands/system/index.ts @@ -18,13 +18,8 @@ import { } from '../cli-grammar/common.ts'; import type { CliReader, DaemonWriter } from '../cli-grammar/types.ts'; import { defineExecutableCommand } from '../command-contract.ts'; -import { - compactRecord, - enumField, - integerField, - requiredField, - stringField, -} from '../command-input.ts'; +import { enumField, integerField, requiredField, stringField } from '../command-input.ts'; +import { compactRecord } from '../input-readers.ts'; import { defineCommandFacet, defineCommandFamilyFromFacets, diff --git a/src/mcp/__tests__/command-tools-operator-inputs.test.ts b/src/mcp/__tests__/command-tools-operator-inputs.test.ts index 6b1989a121..64b6fcbf6f 100644 --- a/src/mcp/__tests__/command-tools-operator-inputs.test.ts +++ b/src/mcp/__tests__/command-tools-operator-inputs.test.ts @@ -26,6 +26,23 @@ const OPERATOR_OWNED_KEYS = [ 'iosXctestEnvDir', ] as const; +// The rest of the shared common input: keys a model may write, because they +// name which device or session THIS call targets, not who the operator is. +const MODEL_WRITABLE_COMMON_KEYS = [ + 'session', + 'platform', + 'deviceTarget', + 'target', + 'device', + 'udid', + 'serial', + 'androidDeviceAllowlist', + 'tenant', + 'runId', + 'leaseId', + 'debug', +] as const; + test('MCP tool schemas advertise no operator-owned inputs', () => { for (const tool of listCommandTools()) { const properties = tool.inputSchema.properties ?? {}; @@ -35,6 +52,19 @@ test('MCP tool schemas advertise no operator-owned inputs', () => { } }); +// The complement, so a misclassification fails in both directions. An +// over-broad operator row narrows every tool's schema at once, and the absence +// assertions above would stay green while the model lost the ability to say +// which device a call targets. +test('MCP tool schemas keep every common input the model may write', () => { + const wait = listCommandTools().find((tool) => tool.name === 'wait'); + assert.ok(wait, 'expected an MCP tool named wait'); + const properties = wait.inputSchema.properties ?? {}; + for (const key of MODEL_WRITABLE_COMMON_KEYS) { + assert.equal(key in properties, true, `wait no longer advertises ${key}`); + } +}); + test('MCP refuses every explicit operator-owned argument with guidance', async () => { const calls: unknown[] = []; const executor = createCommandToolExecutor({ diff --git a/src/mcp/command-tools.ts b/src/mcp/command-tools.ts index 1158773a54..130640d043 100644 --- a/src/mcp/command-tools.ts +++ b/src/mcp/command-tools.ts @@ -1,12 +1,13 @@ import type { AgentDeviceClientConfig } from '@agent-device/contracts/client'; import type { AgentDeviceClient } from '../client/client-types.ts'; -import type { JsonSchema } from '../commands/command-contract.ts'; +import type { CommandMetadata, JsonSchema } from '../commands/command-contract.ts'; import type { CommandExecutionResult } from '../commands/command-surface.ts'; import { RESPONSE_LEVELS, type ResponseLevel } from '@agent-device/kernel/contracts'; import { formatCliOutput } from '../commands/cli-output.ts'; import { findCommandMetadata, isCommandName, + listCommandMetadata, listMcpCommandMetadata, type CommandName, } from '../commands/command-metadata.ts'; @@ -17,6 +18,17 @@ import { resolveCommandTimeoutPolicy, } from '../core/command-descriptor/registry.ts'; import { MCP_COMMAND_OUTPUT_SCHEMAS } from './mcp-output-schemas.ts'; +import { COMMON_INPUT_AUDIENCE } from '../commands/common-input-fields.ts'; +import { + operatorAudience, + operatorInputRefusal, + type InputAudienceMap, +} from '../commands/input-audience.ts'; +import { + MCP_TOOL_CONFIG_AUDIENCE, + MCP_TOOL_CONFIG_KEYS, + mcpToolConfigProperties, +} from './tool-control-fields.ts'; import { AppError } from '@agent-device/kernel/errors'; import { isRecord } from '../utils/parsing.ts'; import { formatToolErrorText, normalizeToolError } from './tool-error.ts'; @@ -53,44 +65,60 @@ type McpToolConfig = { }; /** - * Operator-owned inputs are never model-writable: the model both reads - * untrusted app UI text and picks tool arguments, so a screen that steers it - * into writing a token, an endpoint, or an infrastructure path must find no - * parameter to write it into. Credentials and the endpoints they are sent to - * are the hard security boundary (a model-writable daemonBaseUrl or - * proxyBaseUrl redirects the env-resolved token to an arbitrary server); - * state/build paths ride along because they select operator infrastructure, - * not per-call work. All keys are removed from every advertised tool schema - * and refused as explicit input with migration guidance (the retired-field - * posture: refuse, never silently drop). Operator-sourced values still flow — - * env/config defaults merge in `resolveMcpConfigDefaults`, and the daemon and - * Metro clients fall back to their env vars on their own. + * Config-file-loading keys. Not per-command flags but flags the MCP config + * resolver reads to load an arbitrary file; never model-writable, since that + * file can carry operator credentials and endpoints. They appear in no tool + * schema, so admission rejects them like any unadvertised key — with a message + * that points at the operator path instead of the generic unknown-key text. */ -const OPERATOR_INPUT_GUIDANCE: Readonly> = { - // Credentials. - daemonAuthToken: - 'daemonAuthToken is not accepted as a tool argument. Set the AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable (or daemonAuthToken in ~/.agent-device/config.json) for the process serving these tools.', - bearerToken: - 'bearerToken is not accepted as a tool argument. Set the AGENT_DEVICE_METRO_BEARER_TOKEN or AGENT_DEVICE_DAEMON_AUTH_TOKEN environment variable for the process serving these tools.', - // Endpoints the resolved credentials are sent to. - daemonBaseUrl: - 'daemonBaseUrl is not accepted as a tool argument. Set the AGENT_DEVICE_DAEMON_BASE_URL environment variable (or daemonBaseUrl in ~/.agent-device/config.json) for the process serving these tools.', - proxyBaseUrl: - 'proxyBaseUrl is not accepted as a tool argument. Configure the remote proxy in the remote-config profile or operator config for the process serving these tools.', - // Operator infrastructure paths. - stateDir: - 'stateDir is not accepted as a tool argument. Set the AGENT_DEVICE_STATE_DIR environment variable (or stateDir in ~/.agent-device/config.json) for the process serving these tools.', - cwd: 'cwd is not accepted as a tool argument. Start the process serving these tools in the desired working directory, or pass absolute paths.', - iosSimulatorDeviceSet: - 'iosSimulatorDeviceSet is not accepted as a tool argument. Set iosSimulatorDeviceSet in ~/.agent-device/config.json for the process serving these tools.', - iosXctestrunFile: - 'iosXctestrunFile is not accepted as a tool argument. Set the AGENT_DEVICE_IOS_XCTESTRUN_FILE environment variable (or iosXctestrunFile in ~/.agent-device/config.json) for the process serving these tools.', - iosXctestDerivedDataPath: - 'iosXctestDerivedDataPath is not accepted as a tool argument. Set the AGENT_DEVICE_IOS_XCTEST_DERIVED_DATA_PATH environment variable (or iosXctestDerivedDataPath in ~/.agent-device/config.json) for the process serving these tools.', - iosXctestEnvDir: - 'iosXctestEnvDir is not accepted as a tool argument. Set the AGENT_DEVICE_IOS_XCTEST_ENV_DIR environment variable (or iosXctestEnvDir in ~/.agent-device/config.json) for the process serving these tools.', +const CONFIG_LOADER_AUDIENCE: InputAudienceMap = { + config: operatorAudience({ + operatorPath: + 'Point the process serving these tools at a config file with the AGENT_DEVICE_CONFIG environment variable.', + }), + remoteConfig: operatorAudience({ + operatorPath: + 'Configure the remote profile on the process serving these tools, not per tool call.', + }), }; +/** + * Every `operator` key any command declares. The deny side is deliberately + * global rather than per-command: a screen that steers the model into writing a + * credential must find no tool that takes it, and must be told the operator path + * on whichever tool it tried. `retired` audiences stay per-command — a key one + * command removed is simply unknown to the rest. + */ +const DECLARED_OPERATOR_AUDIENCE: InputAudienceMap = Object.fromEntries( + listCommandMetadata().flatMap((metadata) => + Object.entries(metadata.inputAudience).filter(([, audience]) => audience.kind === 'operator'), + ), +); + +/** + * Who may write each input key of one tool, merged from every declaration that + * governs it: this command's own fields, the shared common fields, this + * surface's own tool-config keys and config loaders, and every command-declared + * operator key. `listCommandTools` and admission both read this, so a key can + * never be hidden from the model yet admitted from the wire. + * + * The command's own map is spread FIRST so an `operator` classification always + * outranks it. `retired` keys are admitted (the command's reader answers with + * migration guidance), so a command that retired a key whose name another + * declaration owns as `operator` must fail closed on the refusal, not open on + * the guidance. Every operator key a command declares is already in + * `DECLARED_OPERATOR_AUDIENCE`, so nothing is lost by letting the globals win. + */ +function toolInputAudience(metadata: AdmissionMetadata): InputAudienceMap { + return { + ...metadata.inputAudience, + ...COMMON_INPUT_AUDIENCE, + ...MCP_TOOL_CONFIG_AUDIENCE, + ...CONFIG_LOADER_AUDIENCE, + ...DECLARED_OPERATOR_AUDIENCE, + }; +} + export function listCommandTools(): Array<{ name: string; description: string; @@ -104,9 +132,7 @@ export function listCommandTools(): Array<{ definition.name in MCP_COMMAND_OUTPUT_SCHEMAS ? MCP_COMMAND_OUTPUT_SCHEMAS[definition.name as keyof typeof MCP_COMMAND_OUTPUT_SCHEMAS] : undefined; - const advertised = omitOperatorProperties( - withMcpConfigSchema(definition.name, definition.inputSchema), - ); + const advertised = advertisedInputSchema(definition.name, definition); return { name: definition.name, description: withTimeoutNote(definition.name, mcpBody(definition)), @@ -283,72 +309,52 @@ function readMcpOutputFormat(outputFormat: unknown): McpOutputFormat { } function stripMcpConfigFields(input: Record): Record { - const { - stateDir: _stateDir, - mcpOutputFormat: _mcpOutputFormat, - includeCost: _includeCost, - responseLevel: _responseLevel, - ...commandInput - } = input; - return commandInput; + return Object.fromEntries( + Object.entries(input).filter(([key]) => !MCP_TOOL_CONFIG_KEYS.has(key)), + ); } -function omitOperatorProperties( - schema: JsonSchema & { properties: Record }, +type AdmissionMetadata = Pick, 'inputSchema' | 'inputAudience'>; + +/** + * The advertised schema for a tool — exactly what `listCommandTools` exposes. + * Admission derives from this same function so a key can never be hidden from + * the model yet admitted from the wire. + */ +function advertisedInputSchema( + name: CommandName, + metadata: AdmissionMetadata, ): JsonSchema & { properties: Record } { + const audience = toolInputAudience(metadata); + const schema = withMcpConfigSchema(name, metadata.inputSchema); return { ...schema, properties: Object.fromEntries( - Object.entries(schema.properties).filter( - ([key]) => !Object.hasOwn(OPERATOR_INPUT_GUIDANCE, key), - ), + Object.entries(schema.properties).filter(([key]) => audience[key]?.kind !== 'operator'), ), }; } -// Config-file-loading keys. Not per-command flags but flags the MCP config -// resolver reads to load an arbitrary file; never model-writable, since that -// file can carry operator credentials and endpoints. Rejected by admission -// like any unadvertised key, with a message that points at the operator path. -const CONFIG_LOADER_GUIDANCE: Readonly> = { - config: - 'config is not accepted as a tool argument. Point the process serving these tools at a config file with the AGENT_DEVICE_CONFIG environment variable.', - remoteConfig: - 'remoteConfig is not accepted as a tool argument. Configure the remote profile on the process serving these tools, not per tool call.', -}; - -/** - * The advertised property set for a tool — exactly what `listCommandTools` - * exposes. Admission and the tool listing derive from this one function so a - * key can never be hidden from the model yet admitted from the wire. - */ -type AdmissionMetadata = { inputSchema: JsonSchema; retiredInputKeys?: readonly string[] }; - -function advertisedInputProperties( - name: CommandName, - metadata: AdmissionMetadata, -): Record { - return omitOperatorProperties(withMcpConfigSchema(name, metadata.inputSchema)).properties; -} - /** The first raw input key the advertised schema does not list, with guidance, or undefined. */ function findInadmissibleInput( name: CommandName, metadata: AdmissionMetadata, input: Record, ): string | undefined { - const advertised = advertisedInputProperties(name, metadata); - // Retired keys are absent from the advertised schema but still recognized: - // admit them so the command's own reader answers with migration guidance - // (e.g. maxSize -> "use --scale") instead of a bare unknown-key rejection. - const retired = new Set(metadata.retiredInputKeys ?? []); + const advertised = advertisedInputSchema(name, metadata).properties; + const audience = toolInputAudience(metadata); for (const key of Object.keys(input)) { - if (Object.hasOwn(advertised, key) || retired.has(key)) continue; - return ( - OPERATOR_INPUT_GUIDANCE[key] ?? - CONFIG_LOADER_GUIDANCE[key] ?? - `${key} is not an accepted argument for the ${name} tool.` - ); + if (Object.hasOwn(advertised, key)) continue; + // `Object.hasOwn`, not a plain lookup: `key` is a raw tool argument name, so + // `__proto__`/`constructor`/`toString` would otherwise read a value off + // `Object.prototype` and classify against it. + const keyAudience = Object.hasOwn(audience, key) ? audience[key] : undefined; + // Retired keys are absent from the advertised schema but still recognized: + // admit them so the command's own reader answers with migration guidance + // (e.g. maxSize -> "use --scale") instead of a bare unknown-key rejection. + if (keyAudience?.kind === 'retired') continue; + if (keyAudience?.kind === 'operator') return operatorInputRefusal(key, keyAudience.source); + return `${key} is not an accepted argument for the ${name} tool.`; } return undefined; } @@ -471,24 +477,7 @@ function withMcpConfigSchema( }, } : {}), - stateDir: { type: 'string', description: 'Agent-device state directory.' }, - mcpOutputFormat: { - type: 'string', - enum: ['optimized', 'json'], - description: - 'MCP text content format. Defaults to optimized agent-friendly text; use json for JSON text. Structured content is always returned separately.', - }, - includeCost: { - type: 'boolean', - description: - 'Include per-command agent-cost (cost.wallClockMs, …) in structuredContent. Defaults to off; the default response shape is unchanged.', - }, - responseLevel: { - type: 'string', - enum: ['digest', 'default', 'full'], - description: - 'Response verbosity: token-cheap digest / default (today) / full. Defaults to default; the default response shape is unchanged.', - }, + ...mcpToolConfigProperties(), }, }; } diff --git a/src/mcp/tool-control-fields.ts b/src/mcp/tool-control-fields.ts new file mode 100644 index 0000000000..f83b784e32 --- /dev/null +++ b/src/mcp/tool-control-fields.ts @@ -0,0 +1,70 @@ +import type { JsonSchema } from '../commands/command-contract.ts'; +import { + operatorAudience, + type InputAudience, + type InputAudienceMap, +} from '../commands/input-audience.ts'; + +/** + * The MCP surface's own tool arguments: transport and response-shaping controls + * it adds on top of a command's input, consumes itself, and never forwards to + * the command route. One row per key, so the advertised schema, the keys + * stripped before dispatch, and the audience boundary read one declaration. + * + * Not `tool-input-config.ts`, which is the other half of the same call: that one + * merges the operator's env and config-file defaults into a call's input. + */ +type McpToolConfigFieldSpec = { + schema: JsonSchema; + /** Who may write the key. Absent means the model — see `commands/input-audience.ts`. */ + audience?: InputAudience; +}; + +const MCP_TOOL_CONFIG_FIELDS = { + stateDir: { + schema: { type: 'string', description: 'Agent-device state directory.' }, + // Selects which daemon state directory (and therefore which daemon and + // session namespace) a call resolves against: operator infrastructure, not + // per-call work. + audience: operatorAudience({ operatorConfig: true }), + }, + mcpOutputFormat: { + schema: { + type: 'string', + enum: ['optimized', 'json'], + description: + 'MCP text content format. Defaults to optimized agent-friendly text; use json for JSON text. Structured content is always returned separately.', + }, + }, + includeCost: { + schema: { + type: 'boolean', + description: + 'Include per-command agent-cost (cost.wallClockMs, …) in structuredContent. Defaults to off; the default response shape is unchanged.', + }, + }, + responseLevel: { + schema: { + type: 'string', + enum: ['digest', 'default', 'full'], + description: + 'Response verbosity: token-cheap digest / default (today) / full. Defaults to default; the default response shape is unchanged.', + }, + }, +} as const satisfies Record; + +const MCP_TOOL_CONFIG_ROWS: ReadonlyArray = + Object.entries(MCP_TOOL_CONFIG_FIELDS); + +/** Tool arguments the MCP surface consumes itself and never forwards to the command route. */ +export const MCP_TOOL_CONFIG_KEYS: ReadonlySet = new Set( + MCP_TOOL_CONFIG_ROWS.map(([key]) => key), +); + +export const MCP_TOOL_CONFIG_AUDIENCE: InputAudienceMap = Object.fromEntries( + MCP_TOOL_CONFIG_ROWS.flatMap(([key, field]) => (field.audience ? [[key, field.audience]] : [])), +); + +export function mcpToolConfigProperties(): Record { + return Object.fromEntries(MCP_TOOL_CONFIG_ROWS.map(([key, field]) => [key, field.schema])); +}