diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts new file mode 100644 index 000000000..4b0fe3463 --- /dev/null +++ b/integ-tests/config.test.ts @@ -0,0 +1,101 @@ +import { spawnAndCollect } from '../src/test-utils/cli-runner.js'; +import { mkdtempSync, readFileSync } from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, describe, expect, it } from 'vitest'; + +const testConfigDir = mkdtempSync(join(tmpdir(), 'agentcore-config-integ-')); +const cliPath = join(__dirname, '..', 'dist', 'cli', 'index.mjs'); + +function run(args: string[]) { + return spawnAndCollect('node', [cliPath, ...args], tmpdir(), { + AGENTCORE_SKIP_INSTALL: '1', + AGENTCORE_CONFIG_DIR: testConfigDir, + }); +} + +function readConfig() { + return JSON.parse(readFileSync(join(testConfigDir, 'config.json'), 'utf-8')); +} + +describe('config command', () => { + afterAll(() => rm(testConfigDir, { recursive: true, force: true })); + + it('lists config with only installationId when fresh', async () => { + const result = await run(['config']); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.installationId).toBeDefined(); + }); + + it('sets a string value', async () => { + const result = await run(['config', 'uvIndex', 'https://example.com']); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain('Set uvIndex = https://example.com'); + expect(readConfig().uvIndex).toBe('https://example.com'); + }); + + it('gets a value', async () => { + const result = await run(['config', 'uvIndex']); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('"https://example.com"'); + }); + + it('sets a nested value with dot notation', async () => { + const result = await run(['config', 'telemetry.endpoint', 'https://metrics.example.com']); + expect(result.exitCode).toBe(0); + expect(readConfig().telemetry.endpoint).toBe('https://metrics.example.com'); + }); + + it('gets a nested value with dot notation', async () => { + const result = await run(['config', 'telemetry.endpoint']); + expect(result.exitCode).toBe(0); + expect(result.stdout.trim()).toBe('"https://metrics.example.com"'); + }); + + it('gets an object value as JSON', async () => { + const result = await run(['config', 'telemetry']); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.endpoint).toBe('https://metrics.example.com'); + }); + + it('sets a boolean value via JSON parsing', async () => { + const result = await run(['config', 'telemetry.enabled', 'true']); + expect(result.exitCode).toBe(0); + expect(readConfig().telemetry.enabled).toBe(true); + }); + + it('sets a numeric value via JSON parsing', async () => { + const result = await run(['config', 'transactionSearchIndexPercentage', '50']); + expect(result.exitCode).toBe(0); + expect(readConfig().transactionSearchIndexPercentage).toBe(50); + }); + + it('rejects invalid value for a typed key', async () => { + const result = await run(['config', 'telemetry.enabled', 'notabool']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid value'); + }); + + it('rejects unknown keys', async () => { + const result = await run(['config', 'foo.bar.baz', 'hello']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid value'); + }); + + it('returns error for unset key', async () => { + const result = await run(['config', 'disableTransactionSearch']); + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('is not set'); + }); + + it('lists all config after mutations', async () => { + const result = await run(['config']); + expect(result.exitCode).toBe(0); + const parsed = JSON.parse(result.stdout); + expect(parsed.uvIndex).toBe('https://example.com'); + expect(parsed.telemetry.endpoint).toBe('https://metrics.example.com'); + }); +}); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index db18ebd83..a24ef4e53 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -3,6 +3,7 @@ import { registerABTestCommand } from './commands/abtest'; import { registerAdd } from './commands/add'; import { registerAddTool } from './commands/add/tool-command'; import { registerArchive } from './commands/archive'; +import { registerConfig } from './commands/config'; import { registerConfigBundle } from './commands/config-bundle'; import { registerCreate } from './commands/create'; import { registerDataset } from './commands/dataset'; @@ -205,6 +206,7 @@ export function registerCommands(program: Command) { registerUpdate(program); registerValidate(program); registerConfigBundle(program); + registerConfig(program); registerDataset(program); registerArchive(program); diff --git a/src/cli/commands/config/actions.ts b/src/cli/commands/config/actions.ts new file mode 100644 index 000000000..dc72e0582 --- /dev/null +++ b/src/cli/commands/config/actions.ts @@ -0,0 +1,69 @@ +import { readGlobalConfig, updateGlobalConfig, validateGlobalConfig } from '../../../lib/schemas/io/global-config.js'; +import type { ConfigResult } from './types.js'; +import { ValidationError } from '@/lib/index.js'; + +export async function handleConfigList(): Promise { + const config = await readGlobalConfig(); + return { success: true, message: JSON.stringify(config, null, 2) }; +} + +export async function handleConfigGet(key: string): Promise { + const config = await readGlobalConfig(); + const value = getByPath(config, key); + if (value === undefined) { + return { success: false, error: new Error(`Key "${key}" is not set.`) }; + } + const message = JSON.stringify(value, null, 2); + return { success: true, message }; +} + +export async function handleConfigSet(key: string, raw: string): Promise { + const value = parseValue(raw); + const partial = buildNestedObject(key, value); + const validation = validateGlobalConfig(partial); + + if (!validation.success) { + return { success: false, error: new ValidationError(`Invalid value "${raw}" for key "${key}".`) }; + } + + const ok = await updateGlobalConfig(partial); + if (!ok) { + return { success: false, error: new Error(`Could not write config.`) }; + } + return { success: true, message: `Set ${key} = ${raw}` }; +} + +function parseValue(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return raw; + } +} + +function isRecord(val: unknown): val is Record { + return typeof val === 'object' && val !== null && !Array.isArray(val); +} + +function getByPath(obj: Record, path: string): unknown { + let current: unknown = obj; + for (const part of path.split('.')) { + if (!isRecord(current)) return undefined; + current = current[part]; + } + return current; +} + +function buildNestedObject(path: string, value: unknown): Record { + const parts = path.split('.'); + const leaf = parts.pop(); + if (!leaf) return {}; + const result: Record = {}; + const inner = parts.reduce>((acc, part) => { + const next: Record = {}; + acc[part] = next; + return next; + }, result); + inner[leaf] = value; + return result; +} diff --git a/src/cli/commands/config/command.ts b/src/cli/commands/config/command.ts new file mode 100644 index 000000000..c61e7fae6 --- /dev/null +++ b/src/cli/commands/config/command.ts @@ -0,0 +1,31 @@ +import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js'; +import { handleConfigGet, handleConfigList, handleConfigSet } from './actions.js'; +import type { ConfigResult } from './types.js'; +import type { Command } from '@commander-js/extra-typings'; + +function resolveAction(key?: string, value?: string): () => Promise { + if (!key) return () => handleConfigList(); + if (value === undefined) return () => handleConfigGet(key); + return () => handleConfigSet(key, value); +} + +function printResult(result: ConfigResult): void { + if (result.success) { + console.log(result.message); + } else { + console.error(result.error.message); + } +} + +export function registerConfig(program: Command) { + program + .command('config') + .description(COMMAND_DESCRIPTIONS.config) + .argument('[key]', 'Config key in dot notation (e.g. telemetry.enabled)') + .argument('[value]', 'Value to set') + .action(async (key?: string, value?: string) => { + const result = await resolveAction(key, value)(); + printResult(result); + if (!result.success) process.exit(1); + }); +} diff --git a/src/cli/commands/config/index.ts b/src/cli/commands/config/index.ts new file mode 100644 index 000000000..62e626078 --- /dev/null +++ b/src/cli/commands/config/index.ts @@ -0,0 +1 @@ +export { registerConfig } from './command.js'; diff --git a/src/cli/commands/config/types.ts b/src/cli/commands/config/types.ts new file mode 100644 index 000000000..1ff9d69e8 --- /dev/null +++ b/src/cli/commands/config/types.ts @@ -0,0 +1,3 @@ +import type { Result } from '../../../lib/result.js'; + +export type ConfigResult = Result<{ message: string }>; diff --git a/src/cli/telemetry/cli-command-run.ts b/src/cli/telemetry/cli-command-run.ts index d60c0e8fd..f0b4ee84b 100644 --- a/src/cli/telemetry/cli-command-run.ts +++ b/src/cli/telemetry/cli-command-run.ts @@ -1,11 +1,12 @@ import type { Result } from '../../lib/result'; +import { resilientParse } from '../../lib/utils/zod.js'; import { getErrorMessage } from '../errors'; import { type AttributeRecorder, createAttributeRecorder } from './attribute-recorder.js'; import { TelemetryClientAccessor } from './client-accessor.js'; import { TelemetryClient } from './client.js'; import { classifyError } from './error.js'; import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from './schemas/command-run.js'; -import { type CommandResult, CommandResultSchema, resilientParse } from './schemas/common-shapes.js'; +import { type CommandResult, CommandResultSchema } from './schemas/common-shapes.js'; import { performance } from 'perf_hooks'; export type { AttributeRecorder } from './attribute-recorder.js'; @@ -30,7 +31,11 @@ function recordCommandRun( const validatedAttrs = Object.keys(attrs as Record).length > 0 - ? resilientParse(COMMAND_SCHEMAS[command], attrs as Record) + ? resilientParse(COMMAND_SCHEMAS[command], attrs as Record, { + fallback: 'unknown', + fillMissing: true, + keepUnknown: false, + }) : attrs; client.emit('cli.command_run', durationMs, { diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts index 561899d7e..5c647b2b0 100644 --- a/src/cli/telemetry/schemas/__tests__/command-run.test.ts +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -1,6 +1,7 @@ +import { resilientParse } from '../../../../lib/utils/zod'; import { COMMAND_SCHEMAS, type Command, type CommandAttrs, deriveCommandGroup } from '../command-run'; import { ResourceAttributesSchema } from '../common-attributes'; -import { CommandResultSchema, resilientParse } from '../common-shapes'; +import { CommandResultSchema } from '../common-shapes'; import { describe, expect, expectTypeOf, it } from 'vitest'; import { z } from 'zod'; @@ -237,6 +238,8 @@ describe('type safety', () => { }); }); +const TELEMETRY_OPTS = { fallback: 'unknown', fillMissing: true, keepUnknown: false } as const; + describe('resilientParse', () => { it('passes valid attrs through unchanged', () => { const attrs = { @@ -250,7 +253,7 @@ describe('resilientParse', () => { network_mode: 'public', has_agent: true, }; - expect(resilientParse(COMMAND_SCHEMAS.create, attrs)).toEqual(attrs); + expect(resilientParse(COMMAND_SCHEMAS.create, attrs, TELEMETRY_OPTS)).toEqual(attrs); }); it('defaults a single invalid enum field to unknown', () => { @@ -265,26 +268,26 @@ describe('resilientParse', () => { network_mode: 'public', has_agent: true, }; - const result = resilientParse(COMMAND_SCHEMAS.create, attrs); + const result = resilientParse(COMMAND_SCHEMAS.create, attrs, TELEMETRY_OPTS); expect(result.agent_language).toBe('unknown'); expect(result.agent_framework).toBe('strands'); }); it('defaults missing required fields to unknown', () => { - const result = resilientParse(COMMAND_SCHEMAS.create, { agent_language: 'python' }); + const result = resilientParse(COMMAND_SCHEMAS.create, { agent_language: 'python' }, TELEMETRY_OPTS); expect(result.agent_language).toBe('python'); expect(result.agent_framework).toBe('unknown'); expect(result.model_provider).toBe('unknown'); }); it('defaults all fields to unknown when all are invalid', () => { - const result = resilientParse(COMMAND_SCHEMAS.create, {}); + const result = resilientParse(COMMAND_SCHEMAS.create, {}, TELEMETRY_OPTS); for (const value of Object.values(result)) { expect(value).toBe('unknown'); } }); it('returns empty object for no-attrs schemas', () => { - expect(resilientParse(COMMAND_SCHEMAS['telemetry.disable'], {})).toEqual({}); + expect(resilientParse(COMMAND_SCHEMAS['telemetry.disable'], {}, TELEMETRY_OPTS)).toEqual({}); }); }); diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index e3aa86bad..a0f679a61 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -8,24 +8,6 @@ export function safeSchema>(shape: T) { return z.object(shape); } -/** - * Validate each field in a schema individually, defaulting to 'unknown' on failure. - * This ensures a single invalid attribute never blocks the entire metric from being published. - * Keys in attrs not present in the schema are omitted from the result. - */ -export function resilientParse( - schema: z.ZodObject, - attrs: Record -): Record { - const result: Record = {}; - for (const key of Object.keys(schema.shape)) { - const field = schema.shape[key] as z.ZodType; - const parsed = field.safeParse(attrs[key]); - result[key] = parsed.success ? parsed.data : 'unknown'; - } - return result; -} - /** * Lowercase a CLI value and parse it through a Zod enum, returning the narrowed type. * The `as` cast on the failure branch is intentional: invalid values pass through to diff --git a/src/cli/tui/copy.ts b/src/cli/tui/copy.ts index 59b574a76..def2450b3 100644 --- a/src/cli/tui/copy.ts +++ b/src/cli/tui/copy.ts @@ -56,6 +56,7 @@ export const COMMAND_DESCRIPTIONS = { validate: 'Validate agentcore/ config files.', 'config-bundle': '[preview] Manage configuration bundle versions and diffs.', archive: '[preview] Archive (delete) a batch evaluation or recommendation on the service and clear local history.', + config: 'Adjust global configuration settings such as telemetry opt-out status', } as const; /** diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index fc64eb39b..5d4c27ee0 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -1,3 +1,4 @@ +import { resilientParse } from '../../utils/zod.js'; import { readFileSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; @@ -8,30 +9,34 @@ import { z } from 'zod'; export const GLOBAL_CONFIG_DIR = process.env.AGENTCORE_CONFIG_DIR ?? join(homedir(), '.agentcore'); export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json'); -const GlobalConfigSchema = z +const GlobalConfigSchemaStrict = z .object({ - installationId: z.string().optional().catch(undefined), - uvDefaultIndex: z.string().optional().catch(undefined), - uvIndex: z.string().optional().catch(undefined), - disableTransactionSearch: z.boolean().optional().catch(undefined), - transactionSearchIndexPercentage: z.number().int().min(0).max(100).optional().catch(undefined), + installationId: z.string().optional(), + uvDefaultIndex: z.string().optional(), + uvIndex: z.string().optional(), + disableTransactionSearch: z.boolean().optional(), + transactionSearchIndexPercentage: z.number().int().min(0).max(100).optional(), telemetry: z .object({ - enabled: z.boolean().optional().catch(undefined), - endpoint: z.string().optional().catch(undefined), - audit: z.boolean().optional().catch(undefined), + enabled: z.boolean().optional(), + endpoint: z.string().optional(), + audit: z.boolean().optional(), }) - .optional() - .catch(undefined), + .strict() + .optional(), }) - .passthrough(); + .strict(); -export type GlobalConfig = z.infer; +export type GlobalConfig = z.infer; + +export function validateGlobalConfig(data: unknown): { success: boolean; error?: z.ZodError } { + return GlobalConfigSchemaStrict.safeParse(data); +} export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise { try { const data = await readFile(configFile, 'utf-8'); - return GlobalConfigSchema.parse(JSON.parse(data)); + return resilientParse(GlobalConfigSchemaStrict, JSON.parse(data) as Record); } catch { return {}; } @@ -40,7 +45,7 @@ export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise export function readGlobalConfigSync(configFile = GLOBAL_CONFIG_FILE): GlobalConfig { try { const data = readFileSync(configFile, 'utf-8'); - return GlobalConfigSchema.parse(JSON.parse(data)); + return resilientParse(GlobalConfigSchemaStrict, JSON.parse(data) as Record); } catch { return {}; } diff --git a/src/lib/utils/__tests__/zod.test.ts b/src/lib/utils/__tests__/zod.test.ts index 264e7fabe..43085e909 100644 --- a/src/lib/utils/__tests__/zod.test.ts +++ b/src/lib/utils/__tests__/zod.test.ts @@ -1,5 +1,6 @@ -import { validateAgentSchema, validateProjectSchema } from '../zod.js'; +import { resilientParse, validateAgentSchema, validateProjectSchema } from '../zod.js'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; describe('validateAgentSchema', () => { const validAgent = { @@ -79,3 +80,63 @@ describe('validateProjectSchema', () => { ).toThrow('Invalid AgentCoreProjectSpec'); }); }); + +describe('resilientParse', () => { + it('passes valid fields through unchanged', () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = resilientParse(schema, { name: 'valid', age: 42 }); + expect(result.name).toBe('valid'); + expect(result.age).toBe(42); + }); + + it('defaults invalid fields to undefined', () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = resilientParse(schema, { name: 'valid', age: 'not a number' }); + expect(result.name).toBe('valid'); + expect(result.age).toBeUndefined(); + }); + + it('recursively parses nested objects', () => { + const schema = z.object({ + settings: z.object({ + enabled: z.boolean(), + name: z.string(), + }), + }); + const result = resilientParse(schema, { settings: { enabled: 'bad', name: 'good' } }); + expect(result.settings).toEqual({ name: 'good' }); + }); + + it('skips keys not present in data', () => { + const schema = z.object({ name: z.string(), age: z.number() }); + const result = resilientParse(schema, { name: 'valid' }); + expect(result).toEqual({ name: 'valid' }); + expect('age' in result).toBe(false); + }); + + it('preserves unknown keys', () => { + const schema = z.object({ known: z.string() }); + const result = resilientParse(schema, { known: 'hello', extra: 'world' }); + expect(result.known).toBe('hello'); + expect((result as Record).extra).toBe('world'); + }); + + it('recursively parses nested objects wrapped in ZodOptional', () => { + const schema = z.object({ + settings: z + .object({ + enabled: z.boolean(), + name: z.string(), + }) + .optional(), + }); + const result = resilientParse(schema, { settings: { enabled: 'bad', name: 'good' } }); + expect(result.settings).toEqual({ name: 'good' }); + }); + + it('supports custom fallback value', () => { + const schema = z.object({ name: z.string() }); + const result = resilientParse(schema, { name: 123 }, { fallback: 'unknown' }); + expect(result.name).toBe('unknown'); + }); +}); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1ab339321..c97b6be4d 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -12,4 +12,4 @@ export { export { parseTimeString } from './time-parser'; export { parseJsonRpcResponse } from './json-rpc'; export { poll, isThrottlingError } from './polling'; -export { validateAgentSchema, validateProjectSchema } from './zod'; +export { validateAgentSchema, validateProjectSchema, resilientParse, type ResilientParseOptions } from './zod'; diff --git a/src/lib/utils/zod.ts b/src/lib/utils/zod.ts index f2f13a4f2..5eb87cc3c 100644 --- a/src/lib/utils/zod.ts +++ b/src/lib/utils/zod.ts @@ -1,5 +1,61 @@ import type { AgentCoreProjectSpec, AgentEnvSpec } from '../../schema'; import { AgentCoreProjectSpecSchema, AgentEnvSpecSchema } from '../../schema'; +import { z } from 'zod'; + +export interface ResilientParseOptions { + /** Value to use when a field fails validation. Default: undefined */ + fallback?: string | number | boolean; + /** Include schema keys not present in data, set to fallback value. Default: false */ + fillMissing?: boolean; + /** Preserve keys in data not defined in the schema. Default: true */ + keepUnknown?: boolean; +} + +/** + * Recursively parse data against a Zod object schema, field by field. + * Invalid fields fall back to a default value rather than throwing. + * Nested ZodObjects are parsed recursively. + * + */ +export function resilientParse>( + schema: T, + data: Record, + options: ResilientParseOptions = {} +): Partial> { + if (data == null || typeof data !== 'object' || Array.isArray(data)) return {} as Partial>; + const { fallback, fillMissing = false, keepUnknown = true } = options; + const result: Record = {}; + for (const [key, field] of Object.entries(schema.shape)) { + if (!(key in data)) { + if (fillMissing) result[key] = fallback; + continue; + } + const value = data[key]; + const inner = unwrapZodType(field as z.ZodType); + if (inner instanceof z.ZodObject && value != null && typeof value === 'object' && !Array.isArray(value)) { + result[key] = resilientParse(inner, value as Record, options); + } else { + const parsed = (field as z.ZodType).safeParse(value); + result[key] = parsed.success ? parsed.data : fallback; + } + } + if (keepUnknown) { + for (const key of Object.keys(data)) { + if (!(key in schema.shape)) { + result[key] = data[key]; + } + } + } + return result as Partial>; +} + +/** Unwrap ZodOptional, ZodNullable, and ZodDefault to get the inner type. */ +function unwrapZodType(field: z.ZodType): z.ZodType { + while (field instanceof z.ZodOptional || field instanceof z.ZodNullable || field instanceof z.ZodDefault) { + field = field.unwrap() as z.ZodType; + } + return field; +} /** * Pass agent spec through zod validator