From 9aa667023d2b1426f57f9f3888101fd2fa392ab7 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 18:34:12 -0400 Subject: [PATCH 01/21] feat: instrument config command --- integ-tests/config.test.ts | 101 +++++++++++++++++++++++++ src/cli/cli.ts | 2 + src/cli/commands/config/actions.ts | 71 +++++++++++++++++ src/cli/commands/config/command.ts | 31 ++++++++ src/cli/commands/config/index.ts | 1 + src/cli/commands/config/types.ts | 3 + src/cli/tui/copy.ts | 1 + src/lib/schemas/io/global-config.ts | 33 ++++---- src/lib/utils/__tests__/object.test.ts | 46 +++++++++++ src/lib/utils/__tests__/zod.test.ts | 51 ++++++++++++- src/lib/utils/index.ts | 2 +- src/lib/utils/object.ts | 15 ++++ src/lib/utils/zod.ts | 20 +++++ 13 files changed, 362 insertions(+), 15 deletions(-) create mode 100644 integ-tests/config.test.ts create mode 100644 src/cli/commands/config/actions.ts create mode 100644 src/cli/commands/config/command.ts create mode 100644 src/cli/commands/config/index.ts create mode 100644 src/cli/commands/config/types.ts create mode 100644 src/lib/utils/__tests__/object.test.ts create mode 100644 src/lib/utils/object.ts diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts new file mode 100644 index 000000000..c9620f845 --- /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 6e2662844..96a7b63dd 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'; @@ -112,6 +113,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..0954a81e9 --- /dev/null +++ b/src/cli/commands/config/actions.ts @@ -0,0 +1,71 @@ +import { readGlobalConfig, updateGlobalConfig, validateGlobalConfig } from '../../../lib/schemas/io/global-config.js'; +import { deepMerge } from '../../../lib/utils/object.js'; +import type { ConfigResult } from './types.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 existing = await readGlobalConfig(); + const partial = buildNestedObject(key, value); + const merged = deepMerge(existing, partial); + + const validation = validateGlobalConfig(merged); + if (!validation.success) { + return { success: false, error: new Error(`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/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..aecced026 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -1,3 +1,4 @@ +import { withCatchAll } from '../../utils/zod.js'; import { readFileSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; @@ -8,25 +9,31 @@ 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; +const GlobalConfigSchema = withCatchAll(GlobalConfigSchemaStrict); + +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 { diff --git a/src/lib/utils/__tests__/object.test.ts b/src/lib/utils/__tests__/object.test.ts new file mode 100644 index 000000000..1bccca8e6 --- /dev/null +++ b/src/lib/utils/__tests__/object.test.ts @@ -0,0 +1,46 @@ +import { deepMerge } from '../object.js'; +import { describe, expect, it } from 'vitest'; + +describe('deepMerge', () => { + it('merges flat objects', () => { + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); + }); + + it('overwrites primitive values', () => { + expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); + }); + + it('recursively merges nested objects', () => { + const target = { nested: { a: 1, b: 2 } }; + const source = { nested: { b: 3, c: 4 } }; + expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } }); + }); + + it('overwrites non-object with object', () => { + expect(deepMerge({ a: 'string' }, { a: { nested: true } })).toEqual({ a: { nested: true } }); + }); + + it('overwrites object with non-object', () => { + expect(deepMerge({ a: { nested: true } }, { a: 'string' })).toEqual({ a: 'string' }); + }); + + it('does not mutate inputs', () => { + const target = { a: { b: 1 } }; + const source = { a: { c: 2 } }; + deepMerge(target, source); + expect(target).toEqual({ a: { b: 1 } }); + expect(source).toEqual({ a: { c: 2 } }); + }); + + it('handles empty source', () => { + expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); + }); + + it('handles empty target', () => { + expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 }); + }); + + it('does not merge arrays', () => { + expect(deepMerge({ a: [1, 2] }, { a: [3, 4] })).toEqual({ a: [3, 4] }); + }); +}); diff --git a/src/lib/utils/__tests__/zod.test.ts b/src/lib/utils/__tests__/zod.test.ts index 264e7fabe..3579dd9cd 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 { validateAgentSchema, validateProjectSchema, withCatchAll } from '../zod.js'; import { describe, expect, it } from 'vitest'; +import { z } from 'zod'; describe('validateAgentSchema', () => { const validAgent = { @@ -79,3 +80,51 @@ describe('validateProjectSchema', () => { ).toThrow('Invalid AgentCoreProjectSpec'); }); }); + +describe('withCatchAll', () => { + it('wraps top-level fields with catch', () => { + const strict = z.object({ name: z.string(), age: z.number() }); + const lenient = withCatchAll(strict); + + const result = lenient.parse({ name: 'valid', age: 'not a number' }); + expect(result.name).toBe('valid'); + expect(result.age).toBeUndefined(); + }); + + it('wraps nested object fields with catch', () => { + const strict = z.object({ + settings: z.object({ + enabled: z.boolean(), + name: z.string(), + }), + }); + const lenient = withCatchAll(strict); + + const result = lenient.parse({ settings: { enabled: 'bad', name: 'good' } }); + expect(result.settings.enabled).toBeUndefined(); + expect(result.settings.name).toBe('good'); + }); + + it('handles optional fields', () => { + const strict = z.object({ value: z.string().optional() }); + const lenient = withCatchAll(strict); + + const result = lenient.parse({}); + expect(result.value).toBeUndefined(); + }); + + it('preserves unknown keys via loose', () => { + const strict = z.object({ known: z.string() }); + const lenient = withCatchAll(strict); + + const result = lenient.parse({ known: 'hello', extra: 'world' }) as Record; + expect(result.known).toBe('hello'); + expect(result.extra).toBe('world'); + }); + + it('passes through primitive schemas unchanged', () => { + const schema = z.string(); + const result = withCatchAll(schema); + expect(result.parse('hello')).toBe('hello'); + }); +}); diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1ab339321..8168b5a2b 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, withCatchAll } from './zod'; diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts new file mode 100644 index 000000000..a33079248 --- /dev/null +++ b/src/lib/utils/object.ts @@ -0,0 +1,15 @@ +export function deepMerge(target: Record, source: Record): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + if (isPlainObject(result[key]) && isPlainObject(source[key])) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + return result; +} + +function isPlainObject(val: unknown): val is Record { + return typeof val === 'object' && val !== null && !Array.isArray(val); +} diff --git a/src/lib/utils/zod.ts b/src/lib/utils/zod.ts index f2f13a4f2..0010fc135 100644 --- a/src/lib/utils/zod.ts +++ b/src/lib/utils/zod.ts @@ -1,5 +1,25 @@ import type { AgentCoreProjectSpec, AgentEnvSpec } from '../../schema'; import { AgentCoreProjectSpecSchema, AgentEnvSpecSchema } from '../../schema'; +import { z } from 'zod'; + +/** + * Recursively wraps all fields in a Zod schema with `.catch(undefined)` and + * adds `.loose()` to objects, making parsing lenient — invalid fields + * are silently dropped and unknown keys are preserved. + */ +export function withCatchAll(schema: T): T { + if (schema instanceof z.ZodObject) { + const shape: Record = schema.shape; + const newShape = Object.fromEntries( + Object.entries(shape).map(([key, field]) => [key, withCatchAll(field).catch(undefined)]) + ); + return z.object(newShape).loose() as unknown as T; + } + if (schema instanceof z.ZodOptional) { + return z.optional(withCatchAll(schema.unwrap() as z.ZodType)) as unknown as T; + } + return schema; +} /** * Pass agent spec through zod validator From a977e51a55d98fb1fcf250ebcab75480361af9e9 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 18:54:24 -0400 Subject: [PATCH 02/21] fix: simplify set path by validing the partial instead of the merged --- src/cli/commands/config/actions.ts | 8 ++--- src/lib/utils/__tests__/object.test.ts | 46 -------------------------- src/lib/utils/object.ts | 15 --------- 3 files changed, 3 insertions(+), 66 deletions(-) delete mode 100644 src/lib/utils/__tests__/object.test.ts delete mode 100644 src/lib/utils/object.ts diff --git a/src/cli/commands/config/actions.ts b/src/cli/commands/config/actions.ts index 0954a81e9..dc72e0582 100644 --- a/src/cli/commands/config/actions.ts +++ b/src/cli/commands/config/actions.ts @@ -1,6 +1,6 @@ import { readGlobalConfig, updateGlobalConfig, validateGlobalConfig } from '../../../lib/schemas/io/global-config.js'; -import { deepMerge } from '../../../lib/utils/object.js'; import type { ConfigResult } from './types.js'; +import { ValidationError } from '@/lib/index.js'; export async function handleConfigList(): Promise { const config = await readGlobalConfig(); @@ -19,13 +19,11 @@ export async function handleConfigGet(key: string): Promise { export async function handleConfigSet(key: string, raw: string): Promise { const value = parseValue(raw); - const existing = await readGlobalConfig(); const partial = buildNestedObject(key, value); - const merged = deepMerge(existing, partial); + const validation = validateGlobalConfig(partial); - const validation = validateGlobalConfig(merged); if (!validation.success) { - return { success: false, error: new Error(`Invalid value "${raw}" for key "${key}".`) }; + return { success: false, error: new ValidationError(`Invalid value "${raw}" for key "${key}".`) }; } const ok = await updateGlobalConfig(partial); diff --git a/src/lib/utils/__tests__/object.test.ts b/src/lib/utils/__tests__/object.test.ts deleted file mode 100644 index 1bccca8e6..000000000 --- a/src/lib/utils/__tests__/object.test.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { deepMerge } from '../object.js'; -import { describe, expect, it } from 'vitest'; - -describe('deepMerge', () => { - it('merges flat objects', () => { - expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); - }); - - it('overwrites primitive values', () => { - expect(deepMerge({ a: 1 }, { a: 2 })).toEqual({ a: 2 }); - }); - - it('recursively merges nested objects', () => { - const target = { nested: { a: 1, b: 2 } }; - const source = { nested: { b: 3, c: 4 } }; - expect(deepMerge(target, source)).toEqual({ nested: { a: 1, b: 3, c: 4 } }); - }); - - it('overwrites non-object with object', () => { - expect(deepMerge({ a: 'string' }, { a: { nested: true } })).toEqual({ a: { nested: true } }); - }); - - it('overwrites object with non-object', () => { - expect(deepMerge({ a: { nested: true } }, { a: 'string' })).toEqual({ a: 'string' }); - }); - - it('does not mutate inputs', () => { - const target = { a: { b: 1 } }; - const source = { a: { c: 2 } }; - deepMerge(target, source); - expect(target).toEqual({ a: { b: 1 } }); - expect(source).toEqual({ a: { c: 2 } }); - }); - - it('handles empty source', () => { - expect(deepMerge({ a: 1 }, {})).toEqual({ a: 1 }); - }); - - it('handles empty target', () => { - expect(deepMerge({}, { a: 1 })).toEqual({ a: 1 }); - }); - - it('does not merge arrays', () => { - expect(deepMerge({ a: [1, 2] }, { a: [3, 4] })).toEqual({ a: [3, 4] }); - }); -}); diff --git a/src/lib/utils/object.ts b/src/lib/utils/object.ts deleted file mode 100644 index a33079248..000000000 --- a/src/lib/utils/object.ts +++ /dev/null @@ -1,15 +0,0 @@ -export function deepMerge(target: Record, source: Record): Record { - const result = { ...target }; - for (const key of Object.keys(source)) { - if (isPlainObject(result[key]) && isPlainObject(source[key])) { - result[key] = deepMerge(result[key], source[key]); - } else { - result[key] = source[key]; - } - } - return result; -} - -function isPlainObject(val: unknown): val is Record { - return typeof val === 'object' && val !== null && !Array.isArray(val); -} From 24de6c4964e48c7dbe05db67f6739f3a71b97759 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 19:25:59 -0400 Subject: [PATCH 03/21] refactor: unify parsing logic with telemetry for common abstraction --- src/cli/telemetry/cli-command-run.ts | 9 ++- .../schemas/__tests__/command-run.test.ts | 15 ++-- src/cli/telemetry/schemas/common-shapes.ts | 18 ----- src/lib/schemas/io/global-config.ts | 8 +-- src/lib/utils/__tests__/zod.test.ts | 70 +++++++++++-------- src/lib/utils/index.ts | 2 +- src/lib/utils/zod.ts | 65 +++++++++++++---- 7 files changed, 113 insertions(+), 74 deletions(-) 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 5115ea1d8..442caa544 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'; @@ -248,6 +249,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 = { @@ -262,7 +265,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', () => { @@ -277,26 +280,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_environment).toBe('unknown'); expect(result.has_agent).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 === 'unknown' || value === undefined).toBe(true); } }); 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 475b0100b..83db75547 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -9,24 +9,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/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index aecced026..5d4c27ee0 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -1,4 +1,4 @@ -import { withCatchAll } from '../../utils/zod.js'; +import { resilientParse } from '../../utils/zod.js'; import { readFileSync } from 'fs'; import { mkdir, readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; @@ -27,8 +27,6 @@ const GlobalConfigSchemaStrict = z }) .strict(); -const GlobalConfigSchema = withCatchAll(GlobalConfigSchemaStrict); - export type GlobalConfig = z.infer; export function validateGlobalConfig(data: unknown): { success: boolean; error?: z.ZodError } { @@ -38,7 +36,7 @@ export function validateGlobalConfig(data: unknown): { success: boolean; error?: 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 {}; } @@ -47,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 3579dd9cd..43085e909 100644 --- a/src/lib/utils/__tests__/zod.test.ts +++ b/src/lib/utils/__tests__/zod.test.ts @@ -1,4 +1,4 @@ -import { validateAgentSchema, validateProjectSchema, withCatchAll } from '../zod.js'; +import { resilientParse, validateAgentSchema, validateProjectSchema } from '../zod.js'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -81,50 +81,62 @@ describe('validateProjectSchema', () => { }); }); -describe('withCatchAll', () => { - it('wraps top-level fields with catch', () => { - const strict = z.object({ name: z.string(), age: z.number() }); - const lenient = withCatchAll(strict); +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); + }); - const result = lenient.parse({ name: 'valid', age: 'not a number' }); + 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('wraps nested object fields with catch', () => { - const strict = z.object({ + it('recursively parses nested objects', () => { + const schema = z.object({ settings: z.object({ enabled: z.boolean(), name: z.string(), }), }); - const lenient = withCatchAll(strict); - - const result = lenient.parse({ settings: { enabled: 'bad', name: 'good' } }); - expect(result.settings.enabled).toBeUndefined(); - expect(result.settings.name).toBe('good'); + const result = resilientParse(schema, { settings: { enabled: 'bad', name: 'good' } }); + expect(result.settings).toEqual({ name: 'good' }); }); - it('handles optional fields', () => { - const strict = z.object({ value: z.string().optional() }); - const lenient = withCatchAll(strict); - - const result = lenient.parse({}); - expect(result.value).toBeUndefined(); + 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 via loose', () => { - const strict = z.object({ known: z.string() }); - const lenient = withCatchAll(strict); - - const result = lenient.parse({ known: 'hello', extra: 'world' }) as Record; + 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.extra).toBe('world'); + 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('passes through primitive schemas unchanged', () => { - const schema = z.string(); - const result = withCatchAll(schema); - expect(result.parse('hello')).toBe('hello'); + 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 8168b5a2b..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, withCatchAll } 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 0010fc135..a185c1db5 100644 --- a/src/lib/utils/zod.ts +++ b/src/lib/utils/zod.ts @@ -2,23 +2,62 @@ 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 wraps all fields in a Zod schema with `.catch(undefined)` and - * adds `.loose()` to objects, making parsing lenient — invalid fields - * are silently dropped and unknown keys are preserved. + * Recursively parse data against a Zod object schema, field by field. + * Invalid fields fall back to a default value rather than throwing. + * Nested ZodObjects (including those wrapped in ZodOptional/ZodNullable/ZodDefault) are parsed recursively. + * + * Note: when keepUnknown is true (default), extra keys are preserved in the result. + * If the result is later validated against a .strict() schema, those keys will cause errors. + * This is intentional for read-path leniency; use validateGlobalConfig for write-path strictness. */ -export function withCatchAll(schema: T): T { - if (schema instanceof z.ZodObject) { - const shape: Record = schema.shape; - const newShape = Object.fromEntries( - Object.entries(shape).map(([key, field]) => [key, withCatchAll(field).catch(undefined)]) - ); - return z.object(newShape).loose() as unknown as T; +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]; + } + } } - if (schema instanceof z.ZodOptional) { - return z.optional(withCatchAll(schema.unwrap() as z.ZodType)) as unknown as T; + 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 schema; + return field; } /** From 76e6890cc70a08d05ae682bb3579b14ad76a69d0 Mon Sep 17 00:00:00 2001 From: hkobew Date: Tue, 26 May 2026 20:07:52 -0400 Subject: [PATCH 04/21] fix: adjust tests to include quotes for strings --- integ-tests/config.test.ts | 4 ++-- src/lib/utils/zod.ts | 5 +---- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts index c9620f845..4b0fe3463 100644 --- a/integ-tests/config.test.ts +++ b/integ-tests/config.test.ts @@ -39,7 +39,7 @@ describe('config command', () => { it('gets a value', async () => { const result = await run(['config', 'uvIndex']); expect(result.exitCode).toBe(0); - expect(result.stdout.trim()).toBe('https://example.com'); + expect(result.stdout.trim()).toBe('"https://example.com"'); }); it('sets a nested value with dot notation', async () => { @@ -51,7 +51,7 @@ describe('config command', () => { 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'); + expect(result.stdout.trim()).toBe('"https://metrics.example.com"'); }); it('gets an object value as JSON', async () => { diff --git a/src/lib/utils/zod.ts b/src/lib/utils/zod.ts index a185c1db5..5eb87cc3c 100644 --- a/src/lib/utils/zod.ts +++ b/src/lib/utils/zod.ts @@ -14,11 +14,8 @@ export interface ResilientParseOptions { /** * Recursively parse data against a Zod object schema, field by field. * Invalid fields fall back to a default value rather than throwing. - * Nested ZodObjects (including those wrapped in ZodOptional/ZodNullable/ZodDefault) are parsed recursively. + * Nested ZodObjects are parsed recursively. * - * Note: when keepUnknown is true (default), extra keys are preserved in the result. - * If the result is later validated against a .strict() schema, those keys will cause errors. - * This is intentional for read-path leniency; use validateGlobalConfig for write-path strictness. */ export function resilientParse>( schema: T, From 0f33f91b2389e69a379005363c6ae605370a4211 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Fri, 29 May 2026 18:37:32 +0000 Subject: [PATCH 05/21] docs: update display text that telemetry is now active --- src/cli/commands/telemetry/command.ts | 15 +++++++++++++++ src/cli/notices.ts | 7 ++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/cli/commands/telemetry/command.ts b/src/cli/commands/telemetry/command.ts index bc6033cfd..eacc86c60 100644 --- a/src/cli/commands/telemetry/command.ts +++ b/src/cli/commands/telemetry/command.ts @@ -10,6 +10,21 @@ export function registerTelemetry(program: Command) { telemetry.outputHelp(); }); + telemetry.addHelpText( + 'after', + ` +Audit Mode: + Enable audit mode to also log every telemetry event locally. + Run: agentcore config telemetry.audit true + Events are written to ~/.agentcore/telemetry/. + Telemetry is sent to: [ENDPOINT] + + For more information on what exactly is captured, see the schemas, which + include all attributes and metrics captured: + https://github.com/aws/agentcore-cli/tree/main/src/cli/telemetry/schemas +` + ); + telemetry .command('disable') .description('Disable anonymous usage analytics') diff --git a/src/cli/notices.ts b/src/cli/notices.ts index 80fa782b2..c2a39546b 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -6,11 +6,12 @@ export function printTelemetryNotice(): void { process.stderr.write( [ '', - `${yellow}The AgentCore CLI will soon begin collecting aggregated, anonymous usage`, + `${yellow}The AgentCore CLI collects aggregated, anonymous usage`, 'analytics to help improve the tool.', 'To opt out: agentcore telemetry disable', - `To learn more: agentcore telemetry --help${reset}`, - '', + `To audit: agentcore config telemetry.audit true`, + `To learn more: agentcore telemetry --help`, + `${reset}`, '', ].join('\n') ); From a23b8b5a1b6181863f7aba51ae8fde2a27e40a0c Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Sat, 30 May 2026 13:21:07 +0000 Subject: [PATCH 06/21] fix(config): avoid overwriting config when not parsable --- src/cli/__tests__/global-config.test.ts | 77 +++++++++++++++---- src/cli/cli.ts | 6 +- src/cli/commands/config/actions.ts | 10 ++- .../telemetry/__tests__/telemetry.test.ts | 11 ++- src/cli/commands/telemetry/actions.ts | 3 +- .../__tests__/resource-resolver.test.ts | 32 +++++--- src/cli/telemetry/client-accessor.ts | 10 ++- src/cli/telemetry/config.ts | 25 +++--- src/lib/result.test.ts | 30 +++++++- src/lib/result.ts | 25 ++++++ src/lib/schemas/io/global-config.ts | 63 ++++++++++----- 11 files changed, 227 insertions(+), 65 deletions(-) diff --git a/src/cli/__tests__/global-config.test.ts b/src/cli/__tests__/global-config.test.ts index 6e2038973..daa7d04ac 100644 --- a/src/cli/__tests__/global-config.test.ts +++ b/src/cli/__tests__/global-config.test.ts @@ -6,6 +6,7 @@ import { } from '../../lib/schemas/io/global-config'; import { createTempConfig } from './helpers/temp-config'; import { readFile, writeFile } from 'fs/promises'; +import assert from 'node:assert'; import { afterAll, beforeEach, describe, expect, it } from 'vitest'; const tmp = createTempConfig('gc'); @@ -15,19 +16,35 @@ describe('global-config', () => { afterAll(() => tmp.cleanup()); describe('readGlobalConfig', () => { - it('returns parsed config when file exists', async () => { + it('returns success with parsed config when file exists', async () => { await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } })); - const config = await readGlobalConfig(tmp.configFile); + const result = await readGlobalConfig(tmp.configFile); - expect(config).toEqual({ telemetry: { enabled: false } }); + expect(result).toEqual({ success: true, config: { telemetry: { enabled: false } } }); }); - it('returns empty object when file is missing or invalid', async () => { - expect(await readGlobalConfig(tmp.testDir + '/nonexistent.json')).toEqual({}); + it('returns success with empty config when file is missing', async () => { + const result = await readGlobalConfig(tmp.testDir + '/nonexistent.json'); + + expect(result).toEqual({ success: true, config: {} }); + }); + it('returns failure when file is malformed JSON', async () => { await writeFile(tmp.configFile, 'not json'); - expect(await readGlobalConfig(tmp.configFile)).toEqual({}); + + const result = await readGlobalConfig(tmp.configFile); + + assert(!result.success); + expect(result.error).toBeInstanceOf(Error); + }); + + it('returns failure when JSON is valid but not an object', async () => { + await writeFile(tmp.configFile, '"a string"'); + + const result = await readGlobalConfig(tmp.configFile); + + assert(!result.success); }); it('drops invalid fields while preserving valid ones', async () => { @@ -40,9 +57,10 @@ describe('global-config', () => { }) ); - const config = await readGlobalConfig(tmp.configFile); + const result = await readGlobalConfig(tmp.configFile); - expect(config).toEqual({ + assert(result.success); + expect(result.config).toEqual({ transactionSearchIndexPercentage: undefined, uvIndex: 'https://valid.url', telemetry: { enabled: undefined, endpoint: 'https://example.com' }, @@ -57,9 +75,10 @@ describe('global-config', () => { }; await writeFile(tmp.configFile, JSON.stringify(full)); - const config = await readGlobalConfig(tmp.configFile); + const result = await readGlobalConfig(tmp.configFile); - expect(config).toEqual(full); + assert(result.success); + expect(result.config).toEqual(full); }); }); @@ -115,16 +134,29 @@ describe('global-config', () => { expect(ok).toBe(false); }); + + it('does not overwrite when existing file is malformed JSON', async () => { + const corrupt = '{ this is not valid json'; + await writeFile(tmp.configFile, corrupt); + + const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile); + + expect(ok).toBe(false); + const onDisk = await readFile(tmp.configFile, 'utf-8'); + expect(onDisk).toBe(corrupt); + }); }); describe('getOrCreateInstallationId', () => { it('generates installationId on first run and returns created: true', async () => { const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); + assert(result.success); expect(result.created).toBe(true); expect(result.id).toMatch(/^[0-9a-f-]{36}$/); - const config = await readGlobalConfig(tmp.configFile); - expect(config.installationId).toBe(result.id); + const read = await readGlobalConfig(tmp.configFile); + assert(read.success); + expect(read.config.installationId).toBe(result.id); }); it('returns existing id with created: false', async () => { @@ -132,7 +164,26 @@ describe('global-config', () => { const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); - expect(result).toEqual({ id: 'existing-id', created: false }); + expect(result).toEqual({ success: true, id: 'existing-id', created: false }); + }); + + it('returns failure when existing config is unreadable', async () => { + await writeFile(tmp.configFile, '{ malformed json'); + + const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); + + assert(!result.success); + expect(result.error).toBeInstanceOf(Error); + }); + + it('returns failure when the new id cannot be persisted', async () => { + const result = await getOrCreateInstallationId( + tmp.testDir + '/\0invalid', + tmp.testDir + '/\0invalid/config.json' + ); + + assert(!result.success); + expect(result.error).toBeInstanceOf(Error); }); }); }); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 96a7b63dd..930f46643 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -136,8 +136,10 @@ export const main = async (argv: string[]) => { // Register global cleanup handlers once at startup setupAltScreenCleanup(); - // Generate installationId on first run and show telemetry notice - const { created: isFirstRun } = await getOrCreateInstallationId(); + // Generate installationId on first run and show telemetry notice. If we + // could not persist the id, suppress the notice so it doesn't fire every run. + const installationIdResult = await getOrCreateInstallationId(); + const isFirstRun = installationIdResult.success && installationIdResult.created; const program = createProgram(); diff --git a/src/cli/commands/config/actions.ts b/src/cli/commands/config/actions.ts index dc72e0582..6f96d7efc 100644 --- a/src/cli/commands/config/actions.ts +++ b/src/cli/commands/config/actions.ts @@ -3,13 +3,15 @@ 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) }; + const read = await readGlobalConfig(); + if (!read.success) return read; + return { success: true, message: JSON.stringify(read.config, null, 2) }; } export async function handleConfigGet(key: string): Promise { - const config = await readGlobalConfig(); - const value = getByPath(config, key); + const read = await readGlobalConfig(); + if (!read.success) return read; + const value = getByPath(read.config, key); if (value === undefined) { return { success: false, error: new Error(`Key "${key}" is not set.`) }; } diff --git a/src/cli/commands/telemetry/__tests__/telemetry.test.ts b/src/cli/commands/telemetry/__tests__/telemetry.test.ts index 2c9afbfe4..b0d629316 100644 --- a/src/cli/commands/telemetry/__tests__/telemetry.test.ts +++ b/src/cli/commands/telemetry/__tests__/telemetry.test.ts @@ -2,6 +2,7 @@ import { readGlobalConfig } from '../../../../lib/schemas/io/global-config'; import { createTempConfig } from '../../../__tests__/helpers/temp-config'; import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions'; import { chmod, mkdir, rm, writeFile } from 'fs/promises'; +import assert from 'node:assert'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const tmp = createTempConfig('actions'); @@ -25,8 +26,9 @@ describe('telemetry actions', () => { const ok = await handleTelemetryDisable(tmp.configDir, tmp.configFile); expect(ok).toBe(true); - const config = await readGlobalConfig(tmp.configFile); - expect(config.telemetry?.enabled).toBe(false); + const result = await readGlobalConfig(tmp.configFile); + assert(result.success); + expect(result.config.telemetry?.enabled).toBe(false); }); it('returns false when config write fails', async () => { @@ -47,8 +49,9 @@ describe('telemetry actions', () => { const ok = await handleTelemetryEnable(tmp.configDir, tmp.configFile); expect(ok).toBe(true); - const config = await readGlobalConfig(tmp.configFile); - expect(config.telemetry?.enabled).toBe(true); + const result = await readGlobalConfig(tmp.configFile); + assert(result.success); + expect(result.config.telemetry?.enabled).toBe(true); }); }); diff --git a/src/cli/commands/telemetry/actions.ts b/src/cli/commands/telemetry/actions.ts index c83a6f425..dc32de871 100644 --- a/src/cli/commands/telemetry/actions.ts +++ b/src/cli/commands/telemetry/actions.ts @@ -1,3 +1,4 @@ +import { unwrapResult } from '../../../lib/result.js'; import { GLOBAL_CONFIG_DIR, GLOBAL_CONFIG_FILE, @@ -25,7 +26,7 @@ export async function handleTelemetryEnable( } export async function handleTelemetryStatus(configFile = GLOBAL_CONFIG_FILE): Promise { - const globalConfig = await readGlobalConfig(configFile); + const { config: globalConfig } = unwrapResult(await readGlobalConfig(configFile), { config: {} }); const pref = await resolveTelemetryPreference(globalConfig); const status = pref.enabled ? 'Enabled' : 'Disabled'; diff --git a/src/cli/telemetry/__tests__/resource-resolver.test.ts b/src/cli/telemetry/__tests__/resource-resolver.test.ts index 47c9c4749..d804214b0 100644 --- a/src/cli/telemetry/__tests__/resource-resolver.test.ts +++ b/src/cli/telemetry/__tests__/resource-resolver.test.ts @@ -1,5 +1,6 @@ import { resolveResourceAttributes } from '../config'; import { ResourceAttributesSchema } from '../schemas/common-attributes'; +import assert from 'node:assert'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; const ORIGINAL_ENV = process.env.AGENTCORE_CONFIG_DIR; @@ -18,33 +19,40 @@ describe('resolveResourceAttributes', () => { }); it('returns attributes that pass schema validation', async () => { - const attrs = await resolveResourceAttributes('cli'); - expect(() => ResourceAttributesSchema.parse(attrs)).not.toThrow(); + const result = await resolveResourceAttributes('cli'); + assert(result.success); + expect(() => ResourceAttributesSchema.parse(result.resource)).not.toThrow(); }); it('sets service.name to agentcore-cli', async () => { - const attrs = await resolveResourceAttributes('cli'); - expect(attrs['service.name']).toBe('agentcore-cli'); + const result = await resolveResourceAttributes('cli'); + assert(result.success); + expect(result.resource['service.name']).toBe('agentcore-cli'); }); it('generates unique session_id per call', async () => { const a = await resolveResourceAttributes('cli'); const b = await resolveResourceAttributes('cli'); - expect(a['agentcore-cli.session_id']).not.toBe(b['agentcore-cli.session_id']); + assert(a.success); + assert(b.success); + expect(a.resource['agentcore-cli.session_id']).not.toBe(b.resource['agentcore-cli.session_id']); }); it('reflects the mode parameter', async () => { const cli = await resolveResourceAttributes('cli'); const tui = await resolveResourceAttributes('tui'); - expect(cli['agentcore-cli.mode']).toBe('cli'); - expect(tui['agentcore-cli.mode']).toBe('tui'); + assert(cli.success); + assert(tui.success); + expect(cli.resource['agentcore-cli.mode']).toBe('cli'); + expect(tui.resource['agentcore-cli.mode']).toBe('tui'); }); it('populates os and node fields', async () => { - const attrs = await resolveResourceAttributes('cli'); - expect(attrs['os.type']).toBeTruthy(); - expect(attrs['os.version']).toBeTruthy(); - expect(attrs['host.arch']).toBeTruthy(); - expect(attrs['node.version']).toMatch(/^v\d+/); + const result = await resolveResourceAttributes('cli'); + assert(result.success); + expect(result.resource['os.type']).toBeTruthy(); + expect(result.resource['os.version']).toBeTruthy(); + expect(result.resource['host.arch']).toBeTruthy(); + expect(result.resource['node.version']).toMatch(/^v\d+/); }); }); diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 4a1959c88..741f796ad 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -1,3 +1,4 @@ +import { unwrapResult } from '../../lib/result.js'; import { GLOBAL_CONFIG_DIR, readGlobalConfig } from '../../lib/schemas/io/global-config.js'; import { TelemetryClient } from './client.js'; import { @@ -46,7 +47,14 @@ export class TelemetryClientAccessor { } async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Promise { - const [resource, config] = await Promise.all([resolveResourceAttributes(mode), readGlobalConfig()]); + const [resourceResult, configResult] = await Promise.all([resolveResourceAttributes(mode), readGlobalConfig()]); + if (!resourceResult.success) { + // Could not resolve a stable installation id — disable telemetry rather than + // emit metrics with an unstable id that breaks attribution across sessions. + return new TelemetryClient(new CompositeSink([])); + } + const { resource } = resourceResult; + const { config } = unwrapResult(configResult, { config: {} }); const [{ enabled }, endpointResult, audit] = await Promise.all([ resolveTelemetryPreference(config), diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts index d14133484..dea484d56 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -1,4 +1,4 @@ -import type { Result } from '../../lib/result.js'; +import { type Result, unwrapResult } from '../../lib/result.js'; import { type GlobalConfig, getOrCreateInstallationId, readGlobalConfig } from '../../lib/schemas/io/global-config.js'; import { PACKAGE_VERSION } from '../constants.js'; import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js'; @@ -30,7 +30,7 @@ export async function resolveTelemetryPreference(config?: GlobalConfig): Promise } } - const resolved = config ?? (await readGlobalConfig()); + const resolved = config ?? unwrapResult(await readGlobalConfig(), { config: {} }).config; if (typeof resolved.telemetry?.enabled === 'boolean') { return { enabled: resolved.telemetry.enabled, source: 'global-config' }; } @@ -45,14 +45,20 @@ export async function resolveTelemetryPreference(config?: GlobalConfig): Promise /** * Resolve and validate resource attributes for the current session. * Called once at startup — the returned object is reused for every metric in the session. - * Throws if any attribute fails validation (prevents PII leakage). + * + * Returns failure if the installation id cannot be persisted, so the caller + * can disable telemetry rather than emit metrics tagged with an unstable id. + * Throws if any attribute fails schema validation (prevents PII leakage). */ -export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise { - const { id } = await getOrCreateInstallationId(); - return ResourceAttributesSchema.parse({ +export async function resolveResourceAttributes( + mode: 'cli' | 'tui' +): Promise> { + const idResult = await getOrCreateInstallationId(); + if (!idResult.success) return idResult; + const resource = ResourceAttributesSchema.parse({ 'service.name': 'agentcore-cli', 'service.version': PACKAGE_VERSION, - 'agentcore-cli.installation_id': id, + 'agentcore-cli.installation_id': idResult.id, 'agentcore-cli.session_id': randomUUID(), 'agentcore-cli.mode': mode, 'os.type': os.type(), @@ -60,6 +66,7 @@ export async function resolveResourceAttributes(mode: 'cli' | 'tui'): Promise { if (process.env.AGENTCORE_TELEMETRY_AUDIT === '1') return true; - const resolved = config ?? (await readGlobalConfig()); + const resolved = config ?? unwrapResult(await readGlobalConfig(), { config: {} }).config; return resolved.telemetry?.audit === true; } @@ -101,7 +108,7 @@ export async function resolveTelemetryEndpoint(config?: GlobalConfig): Promise { @@ -17,3 +18,30 @@ describe('serializeResult', () => { expect(serializeResult(result)).toEqual({ success: false, error: 'fail', logPath: '/tmp/log' }); }); }); + +describe('unwrapResult', () => { + it('returns the data portion (without success) on success', () => { + const result: Result<{ name: string; count: number }> = { success: true, name: 'a', count: 3 }; + + expect(unwrapResult(result)).toEqual({ name: 'a', count: 3 }); + }); + + it('returns an empty object when the success branch has no payload', () => { + const result: Result = { success: true }; + + expect(unwrapResult(result)).toEqual({}); + }); + + it('throws the contained error on failure when no default is provided', () => { + const error = new Error('boom'); + const result: Result<{ name: string }> = { success: false, error }; + + expect(() => unwrapResult(result)).toThrow(error); + }); + + it('returns the provided default on failure', () => { + const result = { success: false, error: new Error('boom') } as Result<{ name: string }>; + + expect(unwrapResult(result, { name: 'fallback' })).toEqual({ name: 'fallback' }); + }); +}); diff --git a/src/lib/result.ts b/src/lib/result.ts index abec578c3..09773cf61 100644 --- a/src/lib/result.ts +++ b/src/lib/result.ts @@ -28,3 +28,28 @@ export function serializeResult>( } return result; } + +/** + * Extracts the data portion of a Result's success branch (everything except the + * `success` discriminant). + */ +type UnwrappedData = Omit, 'success'>; + +/** + * Unwrap a Result to its data portion. + * - On success: returns the data (Result minus the `success: true` discriminant). + * - On failure: throws the contained error, or returns `defaultValue` if provided. + */ +export function unwrapResult(result: R): UnwrappedData; +export function unwrapResult(result: R, defaultValue: UnwrappedData): UnwrappedData; +export function unwrapResult(result: R, defaultValue?: UnwrappedData): UnwrappedData { + if (result.success) { + const { success: _success, ...data } = result; + // TS treats destructured object as generic R type and does not respect type narrowing above. Known issue: https://github.com/microsoft/TypeScript/issues/46680 + return data as UnwrappedData; + } + if (defaultValue !== undefined) { + return defaultValue; + } + throw result.error; +} diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index 5d4c27ee0..c90288cf1 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -1,7 +1,10 @@ +import { toError } from '../../errors/types'; +import type { Result } from '../../result'; import { resilientParse } from '../../utils/zod.js'; import { readFileSync } from 'fs'; -import { mkdir, readFile, writeFile } from 'fs/promises'; +import { access, mkdir, readFile, writeFile } from 'fs/promises'; import { randomUUID } from 'node:crypto'; +import { constants as fsConstants } from 'node:fs'; import { homedir } from 'os'; import { join } from 'path'; import { z } from 'zod'; @@ -33,12 +36,27 @@ export function validateGlobalConfig(data: unknown): { success: boolean; error?: return GlobalConfigSchemaStrict.safeParse(data); } -export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise { +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise> { + // Distinguish "file does not exist" (a normal first-run state) from "file + // exists but cannot be read or parsed" (a real error the caller may want to + // surface). access(F_OK) is the canonical async existence check. try { - const data = await readFile(configFile, 'utf-8'); - return resilientParse(GlobalConfigSchemaStrict, JSON.parse(data) as Record); + await access(configFile, fsConstants.F_OK); } catch { - return {}; + return { success: true, config: {} }; + } + try { + const parsed: unknown = JSON.parse(await readFile(configFile, 'utf-8')); + if (!isRecord(parsed)) { + return { success: false, error: new Error(`Config at ${configFile} is not a JSON object.`) }; + } + return { success: true, config: resilientParse(GlobalConfigSchemaStrict, parsed) }; + } catch (err) { + return { success: false, error: toError(err) }; } } @@ -56,10 +74,12 @@ export async function updateGlobalConfig( configDir = GLOBAL_CONFIG_DIR, configFile = GLOBAL_CONFIG_FILE ): Promise { + const existing = await readGlobalConfig(configFile); + if (!existing.success) { + return false; + } try { - const existing = await readGlobalConfig(configFile); - const merged: GlobalConfig = mergeConfig(existing, partial); - + const merged: GlobalConfig = mergeConfig(existing.config, partial); await mkdir(configDir, { recursive: true }); await writeFile(configFile, JSON.stringify(merged, null, 2), 'utf-8'); return true; @@ -80,21 +100,28 @@ function mergeConfig(target: GlobalConfig, source: GlobalConfig): GlobalConfig { /** * Returns the installationId, generating one if it doesn't exist yet. - * `created: true` means this is the first run (ID was just generated). + * `success: true` means the id is in your hands AND persisted on disk + * (either it was already present, or we just wrote it). + * `created: true` is only set when this call wrote a freshly generated id. * - * Note: concurrent first-run invocations may each generate a different ID; - * the last write wins. This is acceptable — the ID only needs to be stable - * after the first successful write, and CLI invocations are typically sequential. + * Note: concurrent first-run invocations may each generate a different id; + * the last write wins. The id only needs to be stable after the first + * successful write, and CLI invocations are typically sequential. */ export async function getOrCreateInstallationId( configDir = GLOBAL_CONFIG_DIR, configFile = GLOBAL_CONFIG_FILE -): Promise<{ id: string; created: boolean }> { - const config = await readGlobalConfig(configFile); - if (config.installationId) { - return { id: config.installationId, created: false }; +): Promise> { + const read = await readGlobalConfig(configFile); + if (!read.success) return read; + if (read.config.installationId) { + return { success: true, id: read.config.installationId, created: false }; } const id = randomUUID(); - await updateGlobalConfig({ installationId: id }, configDir, configFile); - return { id, created: true }; + const written = await updateGlobalConfig({ installationId: id }, configDir, configFile); + if (!written) { + // TODO: swap to config validation error once error definition is generalized. + return { success: false, error: new Error(`Failed to persist installation id to ${configFile}`) }; + } + return { success: true, id, created: true }; } From 7b861f37df6fd05952ee3e1e5d8ba2939a74e1f7 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Sat, 30 May 2026 15:18:59 +0000 Subject: [PATCH 07/21] feat(telemetry): wire endpoint resolution with constant default and overrides --- integ-tests/global-config.test.ts | 49 +++++++++++++++++++++ src/cli/commands/telemetry/command.ts | 3 +- src/cli/constants.ts | 2 + src/cli/telemetry/__tests__/resolve.test.ts | 31 +++++++++---- src/cli/telemetry/client-accessor.ts | 12 +++-- src/cli/telemetry/config.ts | 18 +++++--- src/lib/schemas/io/global-config.ts | 1 - 7 files changed, 96 insertions(+), 20 deletions(-) create mode 100644 integ-tests/global-config.test.ts diff --git a/integ-tests/global-config.test.ts b/integ-tests/global-config.test.ts new file mode 100644 index 000000000..5fe8bd693 --- /dev/null +++ b/integ-tests/global-config.test.ts @@ -0,0 +1,49 @@ +import { runCLI } from '../src/test-utils/index.js'; +import { chmod, mkdir, readFile, rm, writeFile } from 'fs/promises'; +import { randomUUID } from 'node:crypto'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +interface TempConfig { + configDir: string; + configFile: string; + /** Read and parse config.json. Returns {} if the file does not exist. */ + read: () => Promise>; + /** Run the CLI with this temp dir as AGENTCORE_CONFIG_DIR. */ + runCLI: (args: string[]) => ReturnType; +} + +async function makeTempConfig(): Promise { + const configDir = join(tmpdir(), `agentcore-tel-endpoint-${randomUUID()}`); + const configFile = join(configDir, 'config.json'); + await mkdir(configDir, { recursive: true }); + return { + configDir, + configFile, + read: async () => { + try { + return JSON.parse(await readFile(configFile, 'utf-8')) as Record; + } catch { + return {}; + } + }, + runCLI: args => runCLI(args, process.cwd(), { env: { AGENTCORE_CONFIG_DIR: configDir } }), + }; +} + +describe('integration: global config', () => { + let tmp: TempConfig; + + beforeEach(async () => { + tmp = await makeTempConfig(); + }); + + afterEach(async () => { + // Restore writable permissions on the file before cleanup so rm can unlink it + // when a test left it read-only. + await chmod(tmp.configFile, 0o644).catch(() => undefined); + await rm(tmp.configDir, { recursive: true, force: true }); + }); + +}); diff --git a/src/cli/commands/telemetry/command.ts b/src/cli/commands/telemetry/command.ts index eacc86c60..c16c06d13 100644 --- a/src/cli/commands/telemetry/command.ts +++ b/src/cli/commands/telemetry/command.ts @@ -1,3 +1,4 @@ +import { TELEMETRY_ENDPOINT } from '../../constants.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js'; import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from './actions.js'; import type { Command } from '@commander-js/extra-typings'; @@ -17,7 +18,7 @@ Audit Mode: Enable audit mode to also log every telemetry event locally. Run: agentcore config telemetry.audit true Events are written to ~/.agentcore/telemetry/. - Telemetry is sent to: [ENDPOINT] + Telemetry is sent to: ${TELEMETRY_ENDPOINT} For more information on what exactly is captured, see the schemas, which include all attributes and metrics captured: diff --git a/src/cli/constants.ts b/src/cli/constants.ts index 488560aa5..b3364c9f8 100644 --- a/src/cli/constants.ts +++ b/src/cli/constants.ts @@ -69,6 +69,8 @@ export const SCHEMA_VERSION = 1; */ export const DEFAULT_ENDPOINT_NAME = 'DEFAULT'; +export const TELEMETRY_ENDPOINT = 'https://telemetry.agentcore.aws.dev'; + /** * Color gating: emit ANSI codes only when both streams are attached to a terminal. * Uses AND so that redirecting either stream (e.g. `2> log.txt`) disables colors, diff --git a/src/cli/telemetry/__tests__/resolve.test.ts b/src/cli/telemetry/__tests__/resolve.test.ts index 458b2a023..c001f14ed 100644 --- a/src/cli/telemetry/__tests__/resolve.test.ts +++ b/src/cli/telemetry/__tests__/resolve.test.ts @@ -1,3 +1,4 @@ +import { TELEMETRY_ENDPOINT } from '../../constants'; import { resolveAuditEnabled, resolveTelemetryEndpoint, @@ -107,32 +108,46 @@ describe('resolveTelemetryEndpoint', () => { process.env = originalEnv; }); - it('returns endpoint from env var', async () => { + it('returns endpoint from env var when valid', async () => { process.env.AGENTCORE_TELEMETRY_ENDPOINT = 'https://env.example.com'; const result = await resolveTelemetryEndpoint({}); - expect(result).toEqual({ success: true, url: 'https://env.example.com' }); + expect(result).toBe('https://env.example.com'); }); - it('falls back to config endpoint', async () => { + it('falls back to config endpoint when env is unset', async () => { const result = await resolveTelemetryEndpoint({ telemetry: { endpoint: 'https://config.example.com' } }); - expect(result).toEqual({ success: true, url: 'https://config.example.com' }); + expect(result).toBe('https://config.example.com'); }); - it('returns failure when no endpoint configured', async () => { + it('prefers env over config', async () => { + process.env.AGENTCORE_TELEMETRY_ENDPOINT = 'https://env.example.com'; + + const result = await resolveTelemetryEndpoint({ telemetry: { endpoint: 'https://config.example.com' } }); + + expect(result).toBe('https://env.example.com'); + }); + + it('falls back to the built-in default when nothing is configured', async () => { const result = await resolveTelemetryEndpoint({}); - expect(result.success).toBe(false); + expect(result).toBe(TELEMETRY_ENDPOINT); }); - it('returns failure for invalid env endpoint', async () => { + it('falls back to the built-in default when env override is invalid', async () => { process.env.AGENTCORE_TELEMETRY_ENDPOINT = 'not-a-url'; const result = await resolveTelemetryEndpoint({}); - expect(result.success).toBe(false); + expect(result).toBe(TELEMETRY_ENDPOINT); + }); + + it('falls back to the built-in default when config override is invalid', async () => { + const result = await resolveTelemetryEndpoint({ telemetry: { endpoint: 'not-a-url' } }); + + expect(result).toBe(TELEMETRY_ENDPOINT); }); }); diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 741f796ad..6aa9b81cd 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -56,7 +56,7 @@ async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Pr const { resource } = resourceResult; const { config } = unwrapResult(configResult, { config: {} }); - const [{ enabled }, endpointResult, audit] = await Promise.all([ + const [{ enabled }, endpoint, audit] = await Promise.all([ resolveTelemetryPreference(config), resolveTelemetryEndpoint(config), resolveAuditEnabled(config), @@ -73,8 +73,14 @@ async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Pr sinks.push(new FileSystemSink({ filePath, resource })); } - if (endpointResult.success && enabled) { - sinks.push(new OtelMetricSink({ endpoint: endpointResult.url, resource })); + if (enabled) { + try { + sinks.push(new OtelMetricSink({ endpoint, resource })); + } catch { + // Invalid endpoint URL (e.g. an unreplaced build placeholder, or a typo + // in the user's config that survived validation). Telemetry is best-effort + // — silently skip the network sink rather than crashing the CLI at startup. + } } return new TelemetryClient(new CompositeSink(sinks)); diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts index dea484d56..ade1b98b0 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -1,6 +1,6 @@ import { type Result, unwrapResult } from '../../lib/result.js'; import { type GlobalConfig, getOrCreateInstallationId, readGlobalConfig } from '../../lib/schemas/io/global-config.js'; -import { PACKAGE_VERSION } from '../constants.js'; +import { PACKAGE_VERSION, TELEMETRY_ENDPOINT } from '../constants.js'; import { type ResourceAttributes, ResourceAttributesSchema } from './schemas/common-attributes.js'; import { randomUUID } from 'crypto'; import os from 'os'; @@ -100,18 +100,22 @@ export function validateEndpointUrl(endpoint: string): Result<{ url: string }> { } /** - * Resolve the telemetry endpoint from env var or global config. - * Returns a failure Result if no endpoint is configured or the value is invalid. + * Resolve the telemetry endpoint. Always returns a usable string. + * Precedence: AGENTCORE_TELEMETRY_ENDPOINT env var > config.telemetry.endpoint > built-in default. + * Invalid overrides (env or config) are silently skipped — telemetry is best-effort, + * a typo in the user's config shouldn't disable it. */ -export async function resolveTelemetryEndpoint(config?: GlobalConfig): Promise> { +export async function resolveTelemetryEndpoint(config?: GlobalConfig): Promise { const envEndpoint = process.env.AGENTCORE_TELEMETRY_ENDPOINT; if (envEndpoint) { - return validateEndpointUrl(envEndpoint); + const validated = validateEndpointUrl(envEndpoint); + if (validated.success) return validated.url; } const resolved = config ?? unwrapResult(await readGlobalConfig(), { config: {} }).config; const configEndpoint = resolved.telemetry?.endpoint; if (configEndpoint) { - return validateEndpointUrl(configEndpoint); + const validated = validateEndpointUrl(configEndpoint); + if (validated.success) return validated.url; } - return { success: false, error: new Error('No telemetry endpoint found.') }; + return TELEMETRY_ENDPOINT; } diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index c90288cf1..a451cad66 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -120,7 +120,6 @@ export async function getOrCreateInstallationId( const id = randomUUID(); const written = await updateGlobalConfig({ installationId: id }, configDir, configFile); if (!written) { - // TODO: swap to config validation error once error definition is generalized. return { success: false, error: new Error(`Failed to persist installation id to ${configFile}`) }; } return { success: true, id, created: true }; From ef5daa17f9f32f8dacedb4a407c8d7cee6341a40 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 02:30:34 +0000 Subject: [PATCH 08/21] test(config): exit non-zero on corrupt config --- integ-tests/config.test.ts | 34 +++++++++++++++++++++++++++++++++- 1 file changed, 33 insertions(+), 1 deletion(-) diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts index 4b0fe3463..1aaf72060 100644 --- a/integ-tests/config.test.ts +++ b/integ-tests/config.test.ts @@ -1,5 +1,5 @@ import { spawnAndCollect } from '../src/test-utils/cli-runner.js'; -import { mkdtempSync, readFileSync } from 'node:fs'; +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; import { rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -98,4 +98,36 @@ describe('config command', () => { expect(parsed.uvIndex).toBe('https://example.com'); expect(parsed.telemetry.endpoint).toBe('https://metrics.example.com'); }); + + describe('corrupt config file', () => { + const corruptDir = mkdtempSync(join(tmpdir(), 'agentcore-config-corrupt-')); + const corruptFile = join(corruptDir, 'config.json'); + + afterAll(() => rm(corruptDir, { recursive: true, force: true })); + + function runCorrupt(args: string[]) { + return spawnAndCollect('node', [cliPath, ...args], tmpdir(), { + AGENTCORE_SKIP_INSTALL: '1', + AGENTCORE_CONFIG_DIR: corruptDir, + }); + } + + it('exits non-zero with a clear error when listing a corrupt config', async () => { + writeFileSync(corruptFile, '{ this is not valid json'); + + const result = await runCorrupt(['config']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toMatch(/JSON|Unexpected/); + }); + + it('exits non-zero with a clear error when getting a key from a non-object config', async () => { + writeFileSync(corruptFile, '"a string"'); + + const result = await runCorrupt(['config', 'telemetry.enabled']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('not a JSON object'); + }); + }); }); From b919c7593aa41abfd2bc5826334ea3f070ce0a23 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 02:53:16 +0000 Subject: [PATCH 09/21] fix(config): show friendly error with path on config read/write failures --- integ-tests/config.test.ts | 4 ++-- src/cli/commands/config/actions.ts | 17 +++++++++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts index 1aaf72060..337c697a6 100644 --- a/integ-tests/config.test.ts +++ b/integ-tests/config.test.ts @@ -118,7 +118,7 @@ describe('config command', () => { const result = await runCorrupt(['config']); expect(result.exitCode).toBe(1); - expect(result.stderr).toMatch(/JSON|Unexpected/); + expect(result.stderr).toContain(`Error: Unable to parse config file at ${corruptFile}`); }); it('exits non-zero with a clear error when getting a key from a non-object config', async () => { @@ -127,7 +127,7 @@ describe('config command', () => { const result = await runCorrupt(['config', 'telemetry.enabled']); expect(result.exitCode).toBe(1); - expect(result.stderr).toContain('not a JSON object'); + expect(result.stderr).toContain(`Error: Unable to parse config file at ${corruptFile}`); }); }); }); diff --git a/src/cli/commands/config/actions.ts b/src/cli/commands/config/actions.ts index 6f96d7efc..d34f113fd 100644 --- a/src/cli/commands/config/actions.ts +++ b/src/cli/commands/config/actions.ts @@ -1,16 +1,25 @@ -import { readGlobalConfig, updateGlobalConfig, validateGlobalConfig } from '../../../lib/schemas/io/global-config.js'; +import { + GLOBAL_CONFIG_FILE, + 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 read = await readGlobalConfig(); - if (!read.success) return read; + if (!read.success) { + return { success: false, error: new Error(`Error: Unable to parse config file at ${GLOBAL_CONFIG_FILE}`) }; + } return { success: true, message: JSON.stringify(read.config, null, 2) }; } export async function handleConfigGet(key: string): Promise { const read = await readGlobalConfig(); - if (!read.success) return read; + if (!read.success) { + return { success: false, error: new Error(`Error: Unable to parse config file at ${GLOBAL_CONFIG_FILE}`) }; + } const value = getByPath(read.config, key); if (value === undefined) { return { success: false, error: new Error(`Key "${key}" is not set.`) }; @@ -30,7 +39,7 @@ export async function handleConfigSet(key: string, raw: string): Promise Date: Mon, 1 Jun 2026 03:08:11 +0000 Subject: [PATCH 10/21] refactor(telemetry): remove enable/disable subcommands in favor of agentcore config --- docs/commands.md | 10 ++--- .../telemetry/__tests__/telemetry.test.ts | 40 +------------------ src/cli/commands/telemetry/actions.ts | 25 +----------- src/cli/commands/telemetry/command.ts | 23 +++-------- src/cli/notices.ts | 2 +- 5 files changed, 15 insertions(+), 85 deletions(-) diff --git a/docs/commands.md b/docs/commands.md index 562cdf467..ac4d33aca 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1339,13 +1339,13 @@ agentcore update cli --check # Same as `agentcore update --check` Manage anonymous usage analytics preferences. Telemetry is opt-in and used to improve the CLI. ```bash -agentcore telemetry status # Show current preference and where it was set -agentcore telemetry enable # Opt in -agentcore telemetry disable # Opt out +agentcore telemetry status # Show current preference and where it was set +agentcore config telemetry.enabled true # Opt in +agentcore config telemetry.enabled false # Opt out ``` -`enable`, `disable`, and `status` take no flags beyond `-h, --help`. The preference is stored in your global CLI config -and persists across projects. +`status` takes no flags beyond `-h, --help`. The preference is stored in your global CLI config and persists across +projects. ### help diff --git a/src/cli/commands/telemetry/__tests__/telemetry.test.ts b/src/cli/commands/telemetry/__tests__/telemetry.test.ts index b0d629316..fdd58d3b1 100644 --- a/src/cli/commands/telemetry/__tests__/telemetry.test.ts +++ b/src/cli/commands/telemetry/__tests__/telemetry.test.ts @@ -1,8 +1,6 @@ -import { readGlobalConfig } from '../../../../lib/schemas/io/global-config'; import { createTempConfig } from '../../../__tests__/helpers/temp-config'; -import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from '../actions'; -import { chmod, mkdir, rm, writeFile } from 'fs/promises'; -import assert from 'node:assert'; +import { handleTelemetryStatus } from '../actions'; +import { writeFile } from 'fs/promises'; import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const tmp = createTempConfig('actions'); @@ -21,40 +19,6 @@ describe('telemetry actions', () => { afterAll(() => tmp.cleanup()); - describe('handleTelemetryDisable', () => { - it('writes disabled to config and returns true', async () => { - const ok = await handleTelemetryDisable(tmp.configDir, tmp.configFile); - - expect(ok).toBe(true); - const result = await readGlobalConfig(tmp.configFile); - assert(result.success); - expect(result.config.telemetry?.enabled).toBe(false); - }); - - it('returns false when config write fails', async () => { - await rm(tmp.testDir, { recursive: true, force: true }); - await mkdir(tmp.testDir, { recursive: true }); - await chmod(tmp.testDir, 0o444); - - const ok = await handleTelemetryDisable(tmp.configDir, tmp.configFile); - - expect(ok).toBe(false); - - await chmod(tmp.testDir, 0o755); - }); - }); - - describe('handleTelemetryEnable', () => { - it('writes enabled to config and returns true', async () => { - const ok = await handleTelemetryEnable(tmp.configDir, tmp.configFile); - - expect(ok).toBe(true); - const result = await readGlobalConfig(tmp.configFile); - assert(result.success); - expect(result.config.telemetry?.enabled).toBe(true); - }); - }); - describe('handleTelemetryStatus', () => { it('reports default source when no config exists', async () => { const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/src/cli/commands/telemetry/actions.ts b/src/cli/commands/telemetry/actions.ts index dc32de871..2de604b3c 100644 --- a/src/cli/commands/telemetry/actions.ts +++ b/src/cli/commands/telemetry/actions.ts @@ -1,30 +1,7 @@ import { unwrapResult } from '../../../lib/result.js'; -import { - GLOBAL_CONFIG_DIR, - GLOBAL_CONFIG_FILE, - readGlobalConfig, - updateGlobalConfig, -} from '../../../lib/schemas/io/global-config.js'; +import { GLOBAL_CONFIG_FILE, readGlobalConfig } from '../../../lib/schemas/io/global-config.js'; import { resolveTelemetryPreference } from '../../telemetry/config.js'; -export async function handleTelemetryDisable( - configDir = GLOBAL_CONFIG_DIR, - configFile = GLOBAL_CONFIG_FILE -): Promise { - const ok = await updateGlobalConfig({ telemetry: { enabled: false } }, configDir, configFile); - console.log(ok ? 'Telemetry has been disabled.' : `Warning: could not write config to ${configFile}`); - return ok; -} - -export async function handleTelemetryEnable( - configDir = GLOBAL_CONFIG_DIR, - configFile = GLOBAL_CONFIG_FILE -): Promise { - const ok = await updateGlobalConfig({ telemetry: { enabled: true } }, configDir, configFile); - console.log(ok ? 'Telemetry has been enabled.' : `Warning: could not write config to ${configFile}`); - return ok; -} - export async function handleTelemetryStatus(configFile = GLOBAL_CONFIG_FILE): Promise { const { config: globalConfig } = unwrapResult(await readGlobalConfig(configFile), { config: {} }); const pref = await resolveTelemetryPreference(globalConfig); diff --git a/src/cli/commands/telemetry/command.ts b/src/cli/commands/telemetry/command.ts index c16c06d13..4a6f66399 100644 --- a/src/cli/commands/telemetry/command.ts +++ b/src/cli/commands/telemetry/command.ts @@ -1,6 +1,5 @@ -import { TELEMETRY_ENDPOINT } from '../../constants.js'; import { COMMAND_DESCRIPTIONS } from '../../tui/copy.js'; -import { handleTelemetryDisable, handleTelemetryEnable, handleTelemetryStatus } from './actions.js'; +import { handleTelemetryStatus } from './actions.js'; import type { Command } from '@commander-js/extra-typings'; export function registerTelemetry(program: Command) { @@ -14,11 +13,15 @@ export function registerTelemetry(program: Command) { telemetry.addHelpText( 'after', ` +Manage Telemetry Preferences: + Opt in: agentcore config telemetry.enabled true + Opt out: agentcore config telemetry.enabled false + Status: agentcore telemetry status + Audit Mode: Enable audit mode to also log every telemetry event locally. Run: agentcore config telemetry.audit true Events are written to ~/.agentcore/telemetry/. - Telemetry is sent to: ${TELEMETRY_ENDPOINT} For more information on what exactly is captured, see the schemas, which include all attributes and metrics captured: @@ -26,20 +29,6 @@ Audit Mode: ` ); - telemetry - .command('disable') - .description('Disable anonymous usage analytics') - .action(async () => { - await handleTelemetryDisable(); - }); - - telemetry - .command('enable') - .description('Enable anonymous usage analytics') - .action(async () => { - await handleTelemetryEnable(); - }); - telemetry .command('status') .description('Show current telemetry preference and source') diff --git a/src/cli/notices.ts b/src/cli/notices.ts index c2a39546b..ee1f959e2 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -8,7 +8,7 @@ export function printTelemetryNotice(): void { '', `${yellow}The AgentCore CLI collects aggregated, anonymous usage`, 'analytics to help improve the tool.', - 'To opt out: agentcore telemetry disable', + 'To opt out: agentcore config telemetry.enabled false', `To audit: agentcore config telemetry.audit true`, `To learn more: agentcore telemetry --help`, `${reset}`, From e112e68fc31c0186467e1f372c40fb5fe791eb3a Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 03:38:48 +0000 Subject: [PATCH 11/21] fix(config): regenerate installationId when persisted value is not a valid UUID --- integ-tests/config.test.ts | 9 +++++++ src/cli/__tests__/global-config.test.ts | 36 +++++++++++++++++++++---- src/lib/schemas/io/global-config.ts | 8 ++++-- 3 files changed, 46 insertions(+), 7 deletions(-) diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts index 337c697a6..3200010e9 100644 --- a/integ-tests/config.test.ts +++ b/integ-tests/config.test.ts @@ -130,4 +130,13 @@ describe('config command', () => { expect(result.stderr).toContain(`Error: Unable to parse config file at ${corruptFile}`); }); }); + + describe('installationId validation', () => { + it('rejects setting installationId to a non-UUID value', async () => { + const result = await run(['config', 'installationId', 'my-custom-id']); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain('Invalid value'); + }); + }); }); diff --git a/src/cli/__tests__/global-config.test.ts b/src/cli/__tests__/global-config.test.ts index daa7d04ac..9b283cb78 100644 --- a/src/cli/__tests__/global-config.test.ts +++ b/src/cli/__tests__/global-config.test.ts @@ -69,7 +69,7 @@ describe('global-config', () => { it('preserves unknown fields via passthrough', async () => { const full = { - installationId: 'abc-123', + installationId: 'f47ac10b-58cc-4372-a567-0e02b2c3d479', telemetry: { enabled: true, endpoint: 'https://example.com', audit: false }, futureField: 'hello', }; @@ -111,16 +111,17 @@ describe('global-config', () => { }); it('deep-merges telemetry sub-object with existing config', async () => { + const validUuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; await writeFile( tmp.configFile, - JSON.stringify({ installationId: 'keep-me', telemetry: { enabled: true, endpoint: 'https://x.com' } }) + JSON.stringify({ installationId: validUuid, telemetry: { enabled: true, endpoint: 'https://x.com' } }) ); await updateGlobalConfig({ telemetry: { enabled: false } }, tmp.configDir, tmp.configFile); const written = JSON.parse(await readFile(tmp.configFile, 'utf-8')); expect(written).toEqual({ - installationId: 'keep-me', + installationId: validUuid, telemetry: { enabled: false, endpoint: 'https://x.com' }, }); }); @@ -160,11 +161,36 @@ describe('global-config', () => { }); it('returns existing id with created: false', async () => { - await writeFile(tmp.configFile, JSON.stringify({ installationId: 'existing-id' })); + const validUuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'; + await writeFile(tmp.configFile, JSON.stringify({ installationId: validUuid })); const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); - expect(result).toEqual({ success: true, id: 'existing-id', created: false }); + expect(result).toEqual({ success: true, id: validUuid, created: false }); + }); + + it('regenerates id when existing value is not a valid UUID', async () => { + await writeFile(tmp.configFile, JSON.stringify({ installationId: 'my-custom-id' })); + + const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); + + assert(result.success); + expect(result.created).toBe(true); + expect(result.id).toMatch(/^[0-9a-f-]{36}$/); + expect(result.id).not.toBe('my-custom-id'); + const read = await readGlobalConfig(tmp.configFile); + assert(read.success); + expect(read.config.installationId).toBe(result.id); + }); + + it('regenerates id when existing value is an empty string', async () => { + await writeFile(tmp.configFile, JSON.stringify({ installationId: '' })); + + const result = await getOrCreateInstallationId(tmp.configDir, tmp.configFile); + + assert(result.success); + expect(result.created).toBe(true); + expect(result.id).toMatch(/^[0-9a-f-]{36}$/); }); it('returns failure when existing config is unreadable', async () => { diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index a451cad66..b144099ae 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -14,7 +14,7 @@ export const GLOBAL_CONFIG_FILE = join(GLOBAL_CONFIG_DIR, 'config.json'); const GlobalConfigSchemaStrict = z .object({ - installationId: z.string().optional(), + installationId: z.string().uuid().optional(), uvDefaultIndex: z.string().optional(), uvIndex: z.string().optional(), disableTransactionSearch: z.boolean().optional(), @@ -99,7 +99,11 @@ function mergeConfig(target: GlobalConfig, source: GlobalConfig): GlobalConfig { } /** - * Returns the installationId, generating one if it doesn't exist yet. + * Returns the installationId, generating one if it doesn't exist yet or if the + * persisted value is not a valid UUID. (`installationId` is declared as + * `z.string().uuid()` in the schema, so a malformed value is dropped to + * `undefined` by `resilientParse` and we fall through to regeneration here.) + * * `success: true` means the id is in your hands AND persisted on disk * (either it was already present, or we just wrote it). * `created: true` is only set when this call wrote a freshly generated id. From fd9606ee4de9d3e8d2dcc8756f8ea88da26742b0 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 03:39:06 +0000 Subject: [PATCH 12/21] fix(telemetry): suppress first-run notice when telemetry is disabled --- integ-tests/global-config.test.ts | 28 ++++++++++++++++++++++++++-- src/cli/cli.ts | 2 +- src/cli/notices.ts | 22 ++++++++++++++-------- 3 files changed, 41 insertions(+), 11 deletions(-) diff --git a/integ-tests/global-config.test.ts b/integ-tests/global-config.test.ts index 5fe8bd693..95eabc6e7 100644 --- a/integ-tests/global-config.test.ts +++ b/integ-tests/global-config.test.ts @@ -11,7 +11,7 @@ interface TempConfig { /** Read and parse config.json. Returns {} if the file does not exist. */ read: () => Promise>; /** Run the CLI with this temp dir as AGENTCORE_CONFIG_DIR. */ - runCLI: (args: string[]) => ReturnType; + runCLI: (args: string[], extraEnv?: Record) => ReturnType; } async function makeTempConfig(): Promise { @@ -28,7 +28,8 @@ async function makeTempConfig(): Promise { return {}; } }, - runCLI: args => runCLI(args, process.cwd(), { env: { AGENTCORE_CONFIG_DIR: configDir } }), + runCLI: (args, extraEnv = {}) => + runCLI(args, process.cwd(), { env: { AGENTCORE_CONFIG_DIR: configDir, ...extraEnv } }), }; } @@ -46,4 +47,27 @@ describe('integration: global config', () => { await rm(tmp.configDir, { recursive: true, force: true }); }); + describe('telemetry notice', () => { + const NOTICE_TEXT = 'The AgentCore CLI collects'; + + it('shows the notice on first run when telemetry is enabled', async () => { + const result = await tmp.runCLI(['--help']); + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(NOTICE_TEXT); + }); + + it('suppresses the notice when telemetry is disabled via env var', async () => { + const result = await tmp.runCLI(['--help'], { AGENTCORE_TELEMETRY_DISABLED: '1' }); + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain(NOTICE_TEXT); + }); + + it('suppresses the notice when telemetry.enabled is false in config', async () => { + await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } })); + + const result = await tmp.runCLI(['--help']); + expect(result.exitCode).toBe(0); + expect(result.stderr).not.toContain(NOTICE_TEXT); + }); + }); }); diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 930f46643..854e80ad9 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -157,7 +157,7 @@ export const main = async (argv: string[]) => { } if (isFirstRun) { - printTelemetryNotice(); + await printTelemetryNotice(); } await TelemetryClientAccessor.init(args[0] ?? 'unknown'); diff --git a/src/cli/notices.ts b/src/cli/notices.ts index ee1f959e2..05f2c809b 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -1,7 +1,14 @@ +import { resolveTelemetryPreference } from './telemetry/config'; import { ANSI } from './constants'; import { type UpdateCheckResult, printUpdateNotification } from './update-notifier'; -export function printTelemetryNotice(): void { +export async function printTelemetryNotice(): Promise { + // Don't claim "the CLI collects analytics" if the user has already opted out + // (via env var or global config). Showing the notice would directly contradict + // their choice and is misleading. + const pref = await resolveTelemetryPreference(); + if (!pref.enabled) return; + const { yellow, reset } = ANSI; process.stderr.write( [ @@ -17,16 +24,15 @@ export function printTelemetryNotice(): void { ); } -export function printPostCommandNotices( +export async function printPostCommandNotices( isFirstRun: boolean, updateCheck: Promise ): Promise { if (isFirstRun) { - printTelemetryNotice(); + await printTelemetryNotice(); + } + const result = await updateCheck; + if (result?.updateAvailable) { + printUpdateNotification(result); } - return updateCheck.then(result => { - if (result?.updateAvailable) { - printUpdateNotification(result); - } - }); } From 073fedcccbd376a8c0ec5129ab092e99887b2d83 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 12:53:35 +0000 Subject: [PATCH 13/21] test(integ): isolate spawned CLI from host telemetry env --- src/test-utils/cli-runner.ts | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/test-utils/cli-runner.ts b/src/test-utils/cli-runner.ts index 4526546c5..a79953397 100644 --- a/src/test-utils/cli-runner.ts +++ b/src/test-utils/cli-runner.ts @@ -15,12 +15,26 @@ export interface RunResult { /** * Build a clean env for spawned CLI processes. - * Strips INIT_CWD which npm/npx sets to the runner's directory — without this, - * the CLI resolves the working directory from INIT_CWD instead of the spawn's cwd. - * @see https://docs.npmjs.com/cli/v10/commands/npm-run-script + * + * - Strips INIT_CWD which npm/npx sets to the runner's directory — without this, + * the CLI resolves the working directory from INIT_CWD instead of the spawn's cwd. + * See https://docs.npmjs.com/cli/v10/commands/npm-run-script. + * - Strips AGENTCORE_TELEMETRY_DISABLED so a host-level opt-out (set by CI workflows + * or developer shells) cannot silently mask telemetry-behavior tests. Tests that + * want telemetry disabled set it explicitly via `extraEnv`. + * - Defaults AGENTCORE_TELEMETRY_ENDPOINT to a reserved-port URL so any export attempt + * is refused by the kernel (no DNS, no network egress) — prevents accidentally + * publishing test traffic to the production endpoint. Tests that need to override + * the endpoint pass their own value via `extraEnv`. */ export function cleanSpawnEnv(extraEnv: Record = {}): NodeJS.ProcessEnv { - return { ...process.env, INIT_CWD: undefined, ...extraEnv }; + const { AGENTCORE_TELEMETRY_DISABLED: _ignored, ...inherited } = process.env; + return { + ...inherited, + INIT_CWD: undefined, + AGENTCORE_TELEMETRY_ENDPOINT: 'http://127.0.0.1:1', + ...extraEnv, + }; } /** From a2f92f5bc00fcdf5b7f4775403bc52fe7461e7de Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 12:53:48 +0000 Subject: [PATCH 14/21] fix(telemetry): use .jsonl extension for audit files --- .../telemetry/__tests__/filesystem-sink.test.ts | 14 +++++++------- src/cli/telemetry/config.ts | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/cli/telemetry/__tests__/filesystem-sink.test.ts b/src/cli/telemetry/__tests__/filesystem-sink.test.ts index 87865063c..6844be9b8 100644 --- a/src/cli/telemetry/__tests__/filesystem-sink.test.ts +++ b/src/cli/telemetry/__tests__/filesystem-sink.test.ts @@ -9,7 +9,7 @@ const tmp = createTempConfig('fs-sink'); const outputDir = join(tmp.configDir, 'telemetry'); function createSink(opts: { dir?: string; log?: (msg: string) => void } = {}) { - const filePath = join(opts.dir ?? outputDir, 'test-session.json'); + const filePath = join(opts.dir ?? outputDir, 'test-session.jsonl'); return new FileSystemSink({ filePath, log: opts.log }); } @@ -31,7 +31,7 @@ describe('FileSystemSink', () => { sink.record('cli.command_run', 42, { command_group: 'deploy', command: 'deploy', exit_reason: 'success' }); await sink.flush(); - const entries = await readJsonl(join(outputDir, 'test-session.json')); + const entries = await readJsonl(join(outputDir, 'test-session.jsonl')); expect(entries).toHaveLength(1); expect(entries[0]).toMatchObject({ metric: 'cli.command_run', @@ -46,7 +46,7 @@ describe('FileSystemSink', () => { sink.record('cli.command_run', 20, { command_group: 'add', command: 'add.memory' }); await sink.flush(); - const entries = await readJsonl(join(outputDir, 'test-session.json')); + const entries = await readJsonl(join(outputDir, 'test-session.jsonl')); expect(entries).toHaveLength(2); expect(entries[0]).toMatchObject({ value: 10 }); expect(entries[1]).toMatchObject({ value: 20 }); @@ -54,7 +54,7 @@ describe('FileSystemSink', () => { it('creates output directory if it does not exist', async () => { const nested = join(tmp.testDir, 'deep', 'nested', 'telemetry'); - const filePath = join(nested, 'test.json'); + const filePath = join(nested, 'test.jsonl'); const sink = new FileSystemSink({ filePath }); sink.record('cli.command_run', 1, { command_group: 'status', command: 'status' }); await sink.flush(); @@ -76,7 +76,7 @@ describe('FileSystemSink', () => { expect(logged).toHaveLength(1); expect(logged[0]).toContain('[audit mode]'); - expect(logged[0]).toContain('test-session.json'); + expect(logged[0]).toContain('test-session.jsonl'); }); it('shutdown does not log when no records were written', async () => { @@ -89,8 +89,8 @@ describe('FileSystemSink', () => { }); describe('resolveAuditFilePath', () => { - it('joins outputDir, entrypoint, and sessionId into a JSON file path', () => { + it('joins outputDir, entrypoint, and sessionId into a JSONL file path', () => { const path = resolveAuditFilePath('/home/user/.agentcore/telemetry', 'deploy', 'abc-123'); - expect(path).toBe('/home/user/.agentcore/telemetry/deploy-abc-123.json'); + expect(path).toBe('/home/user/.agentcore/telemetry/deploy-abc-123.jsonl'); }); }); diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts index ade1b98b0..04f96e383 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -70,7 +70,7 @@ export async function resolveResourceAttributes( } export function resolveAuditFilePath(outputDir: string, entrypoint: string, sessionId: string): string { - return join(outputDir, `${entrypoint}-${sessionId}.json`); + return join(outputDir, `${entrypoint}-${sessionId}.jsonl`); } /** From b11dff7082ff22e7514636ba2e14d903889b686f Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 12:53:55 +0000 Subject: [PATCH 15/21] docs(telemetry): correct stale comment on OtelMetricSink try/catch --- src/cli/telemetry/client-accessor.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 6aa9b81cd..6ba7a0933 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -77,9 +77,11 @@ async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Pr try { sinks.push(new OtelMetricSink({ endpoint, resource })); } catch { - // Invalid endpoint URL (e.g. an unreplaced build placeholder, or a typo - // in the user's config that survived validation). Telemetry is best-effort - // — silently skip the network sink rather than crashing the CLI at startup. + // `endpoint` is validated by construction (env > config > TELEMETRY_ENDPOINT + // constant, with invalid env/config values falling through), so this catch + // is defensive against future regressions in the OTLP exporter or Node's URL + // parser. Telemetry is best-effort — silently skip the network sink rather + // than crashing the CLI at startup. } } From 01ea06cf8d2f842a2418ab6f2e43e48b14c832bf Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 13:04:32 +0000 Subject: [PATCH 16/21] test(config): remove redundant integ tests and fix type error --- integ-tests/global-config.test.ts | 73 ------------------- .../schemas/__tests__/command-run.test.ts | 2 +- 2 files changed, 1 insertion(+), 74 deletions(-) delete mode 100644 integ-tests/global-config.test.ts diff --git a/integ-tests/global-config.test.ts b/integ-tests/global-config.test.ts deleted file mode 100644 index 95eabc6e7..000000000 --- a/integ-tests/global-config.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { runCLI } from '../src/test-utils/index.js'; -import { chmod, mkdir, readFile, rm, writeFile } from 'fs/promises'; -import { randomUUID } from 'node:crypto'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -interface TempConfig { - configDir: string; - configFile: string; - /** Read and parse config.json. Returns {} if the file does not exist. */ - read: () => Promise>; - /** Run the CLI with this temp dir as AGENTCORE_CONFIG_DIR. */ - runCLI: (args: string[], extraEnv?: Record) => ReturnType; -} - -async function makeTempConfig(): Promise { - const configDir = join(tmpdir(), `agentcore-tel-endpoint-${randomUUID()}`); - const configFile = join(configDir, 'config.json'); - await mkdir(configDir, { recursive: true }); - return { - configDir, - configFile, - read: async () => { - try { - return JSON.parse(await readFile(configFile, 'utf-8')) as Record; - } catch { - return {}; - } - }, - runCLI: (args, extraEnv = {}) => - runCLI(args, process.cwd(), { env: { AGENTCORE_CONFIG_DIR: configDir, ...extraEnv } }), - }; -} - -describe('integration: global config', () => { - let tmp: TempConfig; - - beforeEach(async () => { - tmp = await makeTempConfig(); - }); - - afterEach(async () => { - // Restore writable permissions on the file before cleanup so rm can unlink it - // when a test left it read-only. - await chmod(tmp.configFile, 0o644).catch(() => undefined); - await rm(tmp.configDir, { recursive: true, force: true }); - }); - - describe('telemetry notice', () => { - const NOTICE_TEXT = 'The AgentCore CLI collects'; - - it('shows the notice on first run when telemetry is enabled', async () => { - const result = await tmp.runCLI(['--help']); - expect(result.exitCode).toBe(0); - expect(result.stderr).toContain(NOTICE_TEXT); - }); - - it('suppresses the notice when telemetry is disabled via env var', async () => { - const result = await tmp.runCLI(['--help'], { AGENTCORE_TELEMETRY_DISABLED: '1' }); - expect(result.exitCode).toBe(0); - expect(result.stderr).not.toContain(NOTICE_TEXT); - }); - - it('suppresses the notice when telemetry.enabled is false in config', async () => { - await writeFile(tmp.configFile, JSON.stringify({ telemetry: { enabled: false } })); - - const result = await tmp.runCLI(['--help']); - expect(result.exitCode).toBe(0); - expect(result.stderr).not.toContain(NOTICE_TEXT); - }); - }); -}); diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts index 442caa544..3f5e42f45 100644 --- a/src/cli/telemetry/schemas/__tests__/command-run.test.ts +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -295,7 +295,7 @@ describe('resilientParse', () => { it('defaults all fields to unknown when all are invalid', () => { const result = resilientParse(COMMAND_SCHEMAS.create, {}, TELEMETRY_OPTS); for (const value of Object.values(result)) { - expect(value === 'unknown' || value === undefined).toBe(true); + expect((value as string) === 'unknown' || value === undefined).toBe(true); } }); From 16042d481de3c1dd8b873785e475017badc31b40 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 13:20:58 +0000 Subject: [PATCH 17/21] docs: strip noisy comments from telemetry instrumentation --- src/cli/notices.ts | 5 +---- src/cli/telemetry/client-accessor.ts | 6 +----- src/cli/telemetry/config.ts | 6 ------ src/lib/schemas/io/global-config.ts | 11 ++--------- 4 files changed, 4 insertions(+), 24 deletions(-) diff --git a/src/cli/notices.ts b/src/cli/notices.ts index 05f2c809b..598e21b30 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -1,11 +1,8 @@ -import { resolveTelemetryPreference } from './telemetry/config'; import { ANSI } from './constants'; +import { resolveTelemetryPreference } from './telemetry/config'; import { type UpdateCheckResult, printUpdateNotification } from './update-notifier'; export async function printTelemetryNotice(): Promise { - // Don't claim "the CLI collects analytics" if the user has already opted out - // (via env var or global config). Showing the notice would directly contradict - // their choice and is misleading. const pref = await resolveTelemetryPreference(); if (!pref.enabled) return; diff --git a/src/cli/telemetry/client-accessor.ts b/src/cli/telemetry/client-accessor.ts index 6ba7a0933..37ac4ae1d 100644 --- a/src/cli/telemetry/client-accessor.ts +++ b/src/cli/telemetry/client-accessor.ts @@ -77,11 +77,7 @@ async function createClient(entrypoint: string, mode: 'cli' | 'tui' = 'cli'): Pr try { sinks.push(new OtelMetricSink({ endpoint, resource })); } catch { - // `endpoint` is validated by construction (env > config > TELEMETRY_ENDPOINT - // constant, with invalid env/config values falling through), so this catch - // is defensive against future regressions in the OTLP exporter or Node's URL - // parser. Telemetry is best-effort — silently skip the network sink rather - // than crashing the CLI at startup. + // Telemetry is best-effort — skip the network sink rather than crash. } } diff --git a/src/cli/telemetry/config.ts b/src/cli/telemetry/config.ts index 04f96e383..a7cda16d4 100644 --- a/src/cli/telemetry/config.ts +++ b/src/cli/telemetry/config.ts @@ -45,10 +45,6 @@ export async function resolveTelemetryPreference(config?: GlobalConfig): Promise /** * Resolve and validate resource attributes for the current session. * Called once at startup — the returned object is reused for every metric in the session. - * - * Returns failure if the installation id cannot be persisted, so the caller - * can disable telemetry rather than emit metrics tagged with an unstable id. - * Throws if any attribute fails schema validation (prevents PII leakage). */ export async function resolveResourceAttributes( mode: 'cli' | 'tui' @@ -102,8 +98,6 @@ export function validateEndpointUrl(endpoint: string): Result<{ url: string }> { /** * Resolve the telemetry endpoint. Always returns a usable string. * Precedence: AGENTCORE_TELEMETRY_ENDPOINT env var > config.telemetry.endpoint > built-in default. - * Invalid overrides (env or config) are silently skipped — telemetry is best-effort, - * a typo in the user's config shouldn't disable it. */ export async function resolveTelemetryEndpoint(config?: GlobalConfig): Promise { const envEndpoint = process.env.AGENTCORE_TELEMETRY_ENDPOINT; diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index b144099ae..91cde689b 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -42,8 +42,7 @@ function isRecord(value: unknown): value is Record { export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise> { // Distinguish "file does not exist" (a normal first-run state) from "file - // exists but cannot be read or parsed" (a real error the caller may want to - // surface). access(F_OK) is the canonical async existence check. + // exists but cannot be read or parsed"" try { await access(configFile, fsConstants.F_OK); } catch { @@ -100,13 +99,7 @@ function mergeConfig(target: GlobalConfig, source: GlobalConfig): GlobalConfig { /** * Returns the installationId, generating one if it doesn't exist yet or if the - * persisted value is not a valid UUID. (`installationId` is declared as - * `z.string().uuid()` in the schema, so a malformed value is dropped to - * `undefined` by `resilientParse` and we fall through to regeneration here.) - * - * `success: true` means the id is in your hands AND persisted on disk - * (either it was already present, or we just wrote it). - * `created: true` is only set when this call wrote a freshly generated id. + * persisted value is not a valid UUID. * * Note: concurrent first-run invocations may each generate a different id; * the last write wins. The id only needs to be stable after the first From 75b9a58c3d0d6cf04f742bed80c62aeafbe595f2 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 13:33:04 +0000 Subject: [PATCH 18/21] fix(test): update telemetry helper to search the correct files --- src/test-utils/telemetry-helper.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test-utils/telemetry-helper.ts b/src/test-utils/telemetry-helper.ts index e7fa58949..c3fe1a2de 100644 --- a/src/test-utils/telemetry-helper.ts +++ b/src/test-utils/telemetry-helper.ts @@ -30,7 +30,7 @@ export function createTelemetryHelper(): TelemetryHelper { dir, env: { AGENTCORE_TELEMETRY_AUDIT: '1', AGENTCORE_CONFIG_DIR: dir }, readEntries() { - return globSync(join(dir, 'telemetry', '*.json')).flatMap(f => + return globSync(join(dir, 'telemetry', '*.jsonl')).flatMap(f => readFileSync(f, 'utf-8') .trim() .split('\n') From f60789de8198b1b84ffcaedae2647e74585db572 Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 14:22:52 +0000 Subject: [PATCH 19/21] chore(telemetry): remove dead commands from schema --- src/cli/telemetry/schemas/command-run.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index eaf5a9c17..8a1fad1aa 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -214,8 +214,6 @@ export const COMMAND_SCHEMAS = { 'dataset.publish-version': NoAttrs, 'dataset.remove-version': NoAttrs, 'telemetry.disable': NoAttrs, - 'telemetry.enable': NoAttrs, - 'telemetry.status': NoAttrs, } as const satisfies Record>; // --------------------------------------------------------------------------- From 95ccc8fe7014a90136558681913c2e12ffe29b7b Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 14:24:39 +0000 Subject: [PATCH 20/21] docs: remove double quote in comment --- src/lib/schemas/io/global-config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/schemas/io/global-config.ts b/src/lib/schemas/io/global-config.ts index 91cde689b..3046db8b4 100644 --- a/src/lib/schemas/io/global-config.ts +++ b/src/lib/schemas/io/global-config.ts @@ -42,7 +42,7 @@ function isRecord(value: unknown): value is Record { export async function readGlobalConfig(configFile = GLOBAL_CONFIG_FILE): Promise> { // Distinguish "file does not exist" (a normal first-run state) from "file - // exists but cannot be read or parsed"" + // exists but cannot be read or parsed" try { await access(configFile, fsConstants.F_OK); } catch { From a59d425b2b55a5b8516e2faa44e832e58e092d2f Mon Sep 17 00:00:00 2001 From: Harrison Weinstock Date: Mon, 1 Jun 2026 16:28:58 +0000 Subject: [PATCH 21/21] fix: swap telemetry tests to existing metric --- integ-tests/config.test.ts | 2 +- src/cli/telemetry/__tests__/client.test.ts | 8 ++++---- src/cli/telemetry/schemas/__tests__/command-run.test.ts | 8 ++++---- src/cli/telemetry/schemas/command-run.ts | 2 +- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/integ-tests/config.test.ts b/integ-tests/config.test.ts index 3200010e9..546de32df 100644 --- a/integ-tests/config.test.ts +++ b/integ-tests/config.test.ts @@ -22,7 +22,7 @@ function readConfig() { describe('config command', () => { afterAll(() => rm(testConfigDir, { recursive: true, force: true })); - it('lists config with only installationId when fresh', async () => { + it('lists config with installationId when fresh', async () => { const result = await run(['config']); expect(result.exitCode).toBe(0); const parsed = JSON.parse(result.stdout); diff --git a/src/cli/telemetry/__tests__/client.test.ts b/src/cli/telemetry/__tests__/client.test.ts index 72f95f482..fe2bc9af1 100644 --- a/src/cli/telemetry/__tests__/client.test.ts +++ b/src/cli/telemetry/__tests__/client.test.ts @@ -71,7 +71,7 @@ describe('withCommandRunTelemetry', () => { }); it('records duration as a non-negative integer', async () => { - await withCommandRunTelemetry('telemetry.disable', {}, async () => { + await withCommandRunTelemetry('telemetry.status', {}, async () => { await new Promise(r => globalThis.setTimeout(r, 5)); return { success: true as const }; }); @@ -149,8 +149,8 @@ describe('withCommandRunTelemetry', () => { it('records failure and returns error result when callback throws', async () => { type R = { success: true } | { success: false; error: Error }; - const result = await withCommandRunTelemetry<'telemetry.disable', R>( - 'telemetry.disable', + const result = await withCommandRunTelemetry<'telemetry.status', R>( + 'telemetry.status', {}, async (): Promise => { throw new Error('network timeout'); @@ -163,7 +163,7 @@ describe('withCommandRunTelemetry', () => { } expect(sink.metrics).toHaveLength(1); expect(sink.metrics[0]!.attrs).toMatchObject({ - command: 'telemetry.disable', + command: 'telemetry.status', exit_reason: 'failure', error_name: 'UnknownError', }); diff --git a/src/cli/telemetry/schemas/__tests__/command-run.test.ts b/src/cli/telemetry/schemas/__tests__/command-run.test.ts index 3f5e42f45..d667dcdc2 100644 --- a/src/cli/telemetry/schemas/__tests__/command-run.test.ts +++ b/src/cli/telemetry/schemas/__tests__/command-run.test.ts @@ -124,7 +124,7 @@ describe('COMMAND_SCHEMAS', () => { }); it('no-attrs commands accept empty object', () => { - expect(COMMAND_SCHEMAS['telemetry.disable'].parse({})).toEqual({}); + expect(COMMAND_SCHEMAS['telemetry.status'].parse({})).toEqual({}); }); it('import subcommand schemas accept empty object', () => { @@ -205,7 +205,7 @@ describe('deriveCommandGroup', () => { ['add.agent', 'add'], ['logs.evals', 'logs'], ['remove.gateway-target', 'remove'], - ['telemetry.disable', 'telemetry'], + ['telemetry.status', 'telemetry'], ] as const)('%s → %s', (command, expected) => { expect(deriveCommandGroup(command)).toBe(expected); }); @@ -221,7 +221,7 @@ describe('type safety', () => { }); it('CommandAttrs is empty', () => { - expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); }); it('no command schema contains arbitrary string fields', () => { @@ -300,6 +300,6 @@ describe('resilientParse', () => { }); it('returns empty object for no-attrs schemas', () => { - expect(resilientParse(COMMAND_SCHEMAS['telemetry.disable'], {}, TELEMETRY_OPTS)).toEqual({}); + expect(resilientParse(COMMAND_SCHEMAS['telemetry.status'], {}, TELEMETRY_OPTS)).toEqual({}); }); }); diff --git a/src/cli/telemetry/schemas/command-run.ts b/src/cli/telemetry/schemas/command-run.ts index 8a1fad1aa..b513feafa 100644 --- a/src/cli/telemetry/schemas/command-run.ts +++ b/src/cli/telemetry/schemas/command-run.ts @@ -213,7 +213,7 @@ export const COMMAND_SCHEMAS = { 'dataset.download': NoAttrs, 'dataset.publish-version': NoAttrs, 'dataset.remove-version': NoAttrs, - 'telemetry.disable': NoAttrs, + 'telemetry.status': NoAttrs, } as const satisfies Record>; // ---------------------------------------------------------------------------