From 82a9d853e39af69cdbc1379f3e9e6dfac37047a0 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:08:05 +0000 Subject: [PATCH 1/4] fix(unit-only): auto-migrate pre-v0.4.0 agentcore.json legacy keys (#719) Renames legacy keys at parse time via a Zod preprocess on AgentCoreProjectSpecSchema: top-level `agents` -> `runtimes` (PR #706) and credential discriminator `type` -> `authorizerType` (PR #709), so pre-v0.4.0 projects keep validating, deploying, and running without manual edits. Mirrors the legacy-aware preprocess in primitives/harness. Refs aws/agentcore-cli#719 --- .../__tests__/agentcore-project.test.ts | 24 ++++++++++++++ src/schema/schemas/agentcore-project.ts | 32 ++++++++++++++++++- 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index 3796f85cf..c11a41a6d 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -637,6 +637,30 @@ describe('AgentCoreProjectSpecSchema', () => { } }); + it('auto-migrates pre-v0.4.0 keys (agents -> runtimes, credential type -> authorizerType)', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agents: [ + { + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/my-agent', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + credentials: [{ type: 'ApiKeyCredentialProvider', name: 'MyCred' }], + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.runtimes).toHaveLength(1); + expect(result.data.runtimes[0]!.name).toBe('MyAgent'); + expect('agents' in result.data).toBe(false); + expect(result.data.credentials[0]!.authorizerType).toBe('ApiKeyCredentialProvider'); + } + }); + it('rejects httpRuntime target on MCP gateway (no protocolType None)', () => { const result = AgentCoreProjectSpecSchema.safeParse({ ...minimalProject, diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index b9762179c..25a4b3c7a 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -387,7 +387,7 @@ export type HarnessRegistryEntry = z.infer; const BUILTIN_EVALUATOR_PREFIX = 'Builtin.'; const ARN_PREFIX = 'arn:'; -export const AgentCoreProjectSpecSchema = z +const AgentCoreProjectSpecBaseSchema = z .object({ $schema: z.string().optional(), name: ProjectNameSchema, @@ -712,4 +712,34 @@ export const AgentCoreProjectSpecSchema = z } }); +/** + * Renames pre-v0.4.0 keys in place so old agentcore.json files keep parsing without manual edits: + * the top-level `agents` array became `runtimes` (PR #706) and the credential discriminator `type` + * became `authorizerType` (PR #709). Without this the `.strict()` top-level schema rejects `agents` + * with a cryptic `unknown keys (remove): "agents"` and the credential union fails on the missing + * `authorizerType` — see GitHub issue #719. Mirrors the legacy-aware preprocess in primitives/harness. + */ +function migrateLegacyProjectSpec(val: unknown): unknown { + if (val == null || typeof val !== 'object' || Array.isArray(val)) return val; + const spec = { ...(val as Record) }; + if ('agents' in spec && !('runtimes' in spec)) { + spec.runtimes = spec.agents; + delete spec.agents; + } + if (Array.isArray(spec.credentials)) { + spec.credentials = spec.credentials.map(cred => { + if (cred == null || typeof cred !== 'object' || Array.isArray(cred)) return cred; + const entry = cred as Record; + if ('type' in entry && !('authorizerType' in entry)) { + const { type, ...rest } = entry; + return { authorizerType: type, ...rest }; + } + return entry; + }); + } + return spec; +} + +export const AgentCoreProjectSpecSchema = z.preprocess(migrateLegacyProjectSpec, AgentCoreProjectSpecBaseSchema); + export type AgentCoreProjectSpec = z.infer; From 7c26e209c7f4c44d3c23c85f3d3fd16692cb9b26 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 19:27:24 +0000 Subject: [PATCH 2/4] fix(schema): auto-migrate pre-v0.4.0 agentcore.json legacy keys (#719) From c07913fee59a1208c8e7e611625e278fae71a9c7 Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 19:50:00 +0000 Subject: [PATCH 3/4] test(schema): cover issue #719 canonical shape and add migration telemetry - strip the legacy per-runtime `type: "AgentCoreRuntime"` discriminator explicitly in migrateLegacyProjectSpec (robust even if AgentEnvSpecSchema is later tightened to .strict()) and add a test mirroring the issue #719 "Before" example verbatim (legacy runtime type + OAuth/Payment credentials) - export AgentCoreProjectSpecBaseSchema so consumers needing ZodObject methods keep access after the z.preprocess wrap; regenerated schema confirmed byte-identical - emit `cli.legacy_project_migrated` telemetry plus a one-time deprecation notice when a pre-v0.4.0 agentcore.json is auto-migrated, wired in the loader via a lib-clean reporter hook so src/lib stays free of CLI/telemetry imports Refs aws/agentcore-cli#719 --- src/cli/cli.ts | 6 +- src/cli/telemetry/index.ts | 4 + src/cli/telemetry/legacy-project-migration.ts | 51 ++++++++++ src/cli/telemetry/schemas/common-shapes.ts | 3 + src/cli/telemetry/schemas/registry.ts | 15 ++- .../schemas/io/__tests__/config-io.test.ts | 96 ++++++++++++++++++- src/lib/schemas/io/config-io.ts | 61 +++++++++++- src/lib/schemas/io/index.ts | 9 +- .../__tests__/agentcore-project.test.ts | 89 +++++++++++++++++ src/schema/schemas/agentcore-project.ts | 63 +++++++++++- 10 files changed, 384 insertions(+), 13 deletions(-) create mode 100644 src/cli/telemetry/legacy-project-migration.ts diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 30ec862d7..d56fe3ad7 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -37,7 +37,7 @@ import { registerView } from './commands/view'; import { COMMAND_DESCRIPTIONS, PACKAGE_VERSION } from './constants'; import { printPostCommandNotices, printTelemetryNotice } from './notices'; import { ALL_PRIMITIVES } from './primitives'; -import { TelemetryClientAccessor } from './telemetry'; +import { TelemetryClientAccessor, registerLegacyProjectMigrationReporter } from './telemetry'; import { renderTUI, setupAltScreenCleanup } from './tui'; import { LayoutProvider } from './tui/context'; import { clearExitMessage, getExitMessage } from './tui/exit-message'; @@ -140,6 +140,10 @@ export const main = async (argv: string[]) => { // Register global cleanup handlers once at startup setupAltScreenCleanup(); + // Observe (telemetry + one-time deprecation notice) when a pre-v0.4.0 agentcore.json + // is auto-migrated on read. Registered once before any command/TUI loads a project. + registerLegacyProjectMigrationReporter(); + // 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(); diff --git a/src/cli/telemetry/index.ts b/src/cli/telemetry/index.ts index 16395562f..25bcef3de 100644 --- a/src/cli/telemetry/index.ts +++ b/src/cli/telemetry/index.ts @@ -5,3 +5,7 @@ export { TelemetryClient } from './client.js'; export { type MetricSink } from './sinks/metric-sink.js'; export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-sink.js'; export { FileSystemSink, type FileSystemSinkConfig } from './sinks/filesystem-sink.js'; +export { + registerLegacyProjectMigrationReporter, + resetLegacyProjectMigrationNotice, +} from './legacy-project-migration.js'; diff --git a/src/cli/telemetry/legacy-project-migration.ts b/src/cli/telemetry/legacy-project-migration.ts new file mode 100644 index 000000000..52935bce7 --- /dev/null +++ b/src/cli/telemetry/legacy-project-migration.ts @@ -0,0 +1,51 @@ +import type { LegacyProjectMigrationInfo } from '../../lib/schemas/io/config-io.js'; +import { setLegacyProjectMigrationReporter } from '../../lib/schemas/io/config-io.js'; +import { ANSI } from '../constants.js'; +import { TelemetryClientAccessor } from './client-accessor.js'; + +let noticePrinted = false; + +/** Reset the one-time-notice latch. Test-only. */ +export function resetLegacyProjectMigrationNotice(): void { + noticePrinted = false; +} + +function printDeprecationNotice(): void { + if (noticePrinted) return; + noticePrinted = true; + const { yellow, reset } = ANSI; + process.stderr.write( + [ + '', + `${yellow}Your agentcore.json uses pre-v0.4.0 keys (\`agents\`, and/or \`type\` on`, + 'credentials/runtimes). These are auto-migrated for now, but support will be', + 'removed in a future release. Update the file to use `runtimes` and', + `\`authorizerType\` to silence this notice.${reset}`, + '', + ].join('\n') + ); +} + +/** + * Wire the CLI's observability into the lib config loader: when a pre-v0.4.0 agentcore.json is + * auto-migrated on read, emit the `cli.legacy_project_migrated` metric (so legacy-project adoption + * is measurable and the shim can eventually be removed) and print a one-time deprecation notice. + * + * Kept here in the CLI layer so `src/lib` stays free of any telemetry/CLI import. + */ +export function registerLegacyProjectMigrationReporter(): void { + setLegacyProjectMigrationReporter((info: LegacyProjectMigrationInfo) => { + void TelemetryClientAccessor.get() + .then(client => + client.emit('cli.legacy_project_migrated', 1, { + had_agents_key: info.hadAgentsKey, + had_credential_type_key: info.hadCredentialTypeKey, + had_runtime_type_key: info.hadRuntimeTypeKey, + }) + ) + .catch(() => { + // Telemetry is best-effort and must never affect CLI behavior. + }); + printDeprecationNotice(); + }); +} diff --git a/src/cli/telemetry/schemas/common-shapes.ts b/src/cli/telemetry/schemas/common-shapes.ts index bf7679d80..23ee824f0 100644 --- a/src/cli/telemetry/schemas/common-shapes.ts +++ b/src/cli/telemetry/schemas/common-shapes.ts @@ -223,4 +223,7 @@ export const ATTRIBUTES = { skill_source_type: SkillSourceType, ui_mode: UiMode, policy_validation_mode: PolicyValidationMode, + had_agents_key: z.boolean(), + had_credential_type_key: z.boolean(), + had_runtime_type_key: z.boolean(), } as const; diff --git a/src/cli/telemetry/schemas/registry.ts b/src/cli/telemetry/schemas/registry.ts index 5f547b0e8..982e210e9 100644 --- a/src/cli/telemetry/schemas/registry.ts +++ b/src/cli/telemetry/schemas/registry.ts @@ -31,7 +31,20 @@ export const METRICS = { 'cli.command_run': { description: 'CLI/TUI Command Execution', }, + 'cli.legacy_project_migrated': { + description: 'A pre-v0.4.0 agentcore.json was auto-migrated to current schema keys on read', + }, } as const satisfies MetricRegistry; +interface LegacyProjectMigratedAttrs { + had_agents_key: z.infer; + had_credential_type_key: z.infer; + had_runtime_type_key: z.infer; +} + export type MetricName = keyof typeof METRICS; -export type MetricAttrs = M extends 'cli.command_run' ? CommandRunAttrs : never; +export type MetricAttrs = M extends 'cli.command_run' + ? CommandRunAttrs + : M extends 'cli.legacy_project_migrated' + ? LegacyProjectMigratedAttrs + : never; diff --git a/src/lib/schemas/io/__tests__/config-io.test.ts b/src/lib/schemas/io/__tests__/config-io.test.ts index a41edb28d..c94e59e50 100644 --- a/src/lib/schemas/io/__tests__/config-io.test.ts +++ b/src/lib/schemas/io/__tests__/config-io.test.ts @@ -1,6 +1,6 @@ import { NoProjectError } from '../../../errors'; import { ConfigNotFoundError, ConfigParseError, ConfigValidationError } from '../../../errors/types.js'; -import { ConfigIO } from '../config-io.js'; +import { ConfigIO, type LegacyProjectMigrationInfo, setLegacyProjectMigrationReporter } from '../config-io.js'; import { randomUUID } from 'node:crypto'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { mkdir, rm, unlink, writeFile } from 'node:fs/promises'; @@ -160,6 +160,100 @@ describe('ConfigIO', () => { }); }); + describe('readProjectSpec legacy migration reporter', () => { + afterEach(() => { + setLegacyProjectMigrationReporter(undefined); + }); + + function writeProjectFixture(spec: unknown): ConfigIO { + const projectDir = join(testDir, `legacy-migrate-${randomUUID()}`); + const agentcoreDir = join(projectDir, 'agentcore'); + mkdirSync(agentcoreDir, { recursive: true }); + writeFileSync(join(agentcoreDir, 'agentcore.json'), JSON.stringify(spec)); + changeWorkingDir(projectDir); + return new ConfigIO(); + } + + it('fires the reporter with detected legacy keys for a pre-v0.4.0 project (issue #719)', async () => { + const captured: LegacyProjectMigrationInfo[] = []; + setLegacyProjectMigrationReporter(info => captured.push(info)); + + const configIO = writeProjectFixture({ + name: 'TestProject', + version: 1, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/MyAgent/', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + credentials: [ + { + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration', + }, + ], + }); + + const spec = await configIO.readProjectSpec(); + + expect(spec.runtimes[0]!.name).toBe('MyAgent'); + expect(spec.credentials[0]!.authorizerType).toBe('OAuthCredentialProvider'); + expect(captured).toHaveLength(1); + expect(captured[0]).toEqual({ + hadAgentsKey: true, + hadCredentialTypeKey: true, + hadRuntimeTypeKey: true, + }); + }); + + it('does not fire the reporter for a current-shape project', async () => { + const captured: LegacyProjectMigrationInfo[] = []; + setLegacyProjectMigrationReporter(info => captured.push(info)); + + const configIO = writeProjectFixture({ + name: 'TestProject', + version: 1, + runtimes: [ + { + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/MyAgent/', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + credentials: [{ authorizerType: 'ApiKeyCredentialProvider', name: 'MyCred' }], + }); + + await configIO.readProjectSpec(); + expect(captured).toHaveLength(0); + }); + + it('a throwing reporter never breaks the read', async () => { + setLegacyProjectMigrationReporter(() => { + throw new Error('reporter boom'); + }); + + const configIO = writeProjectFixture({ + name: 'TestProject', + version: 1, + agents: [], + credentials: [{ type: 'ApiKeyCredentialProvider', name: 'MyCred' }], + }); + + const spec = await configIO.readProjectSpec(); + expect(spec.credentials[0]!.authorizerType).toBe('ApiKeyCredentialProvider'); + }); + }); + describe('writeProjectSpec', () => { it('throws ConfigValidationError for invalid project data', async () => { const projectDir = join(testDir, `invalid-write-${randomUUID()}`); diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index 9fd6bf8c6..b26e83e4b 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -12,6 +12,7 @@ import { AwsDeploymentTargetsSchema, HarnessSpecSchema, createValidatedDeployedStateSchema, + detectsLegacyProjectKeys, } from '../../../schema'; import { ConfigNotFoundError, @@ -36,6 +37,30 @@ export function getSchemaUrlForVersion(version: SchemaVersion): string { return `https://schema.agentcore.aws.dev/v${version}/agentcore.json`; } +/** Which pre-v0.4.0 keys were present in an agentcore.json that the loader auto-migrated. */ +export interface LegacyProjectMigrationInfo { + /** Top-level `agents` key was rewritten to `runtimes` (PR #706). */ + hadAgentsKey: boolean; + /** A credential used the legacy `type` discriminator instead of `authorizerType` (PR #709). */ + hadCredentialTypeKey: boolean; + /** A runtime entry carried the dropped `type: "AgentCoreRuntime"` discriminator (PR #706). */ + hadRuntimeTypeKey: boolean; +} + +/** + * Optional observer invoked once whenever {@link ConfigIO.readProjectSpec} auto-migrates a + * pre-v0.4.0 agentcore.json. Lives in the lib layer so it stays free of any CLI/telemetry + * import; the CLI registers a reporter at startup (telemetry + a one-time deprecation notice). + */ +export type LegacyProjectMigrationReporter = (info: LegacyProjectMigrationInfo) => void; + +let legacyProjectMigrationReporter: LegacyProjectMigrationReporter | undefined; + +/** Register (or clear) the reporter invoked when a legacy agentcore.json is auto-migrated on read. */ +export function setLegacyProjectMigrationReporter(reporter: LegacyProjectMigrationReporter | undefined): void { + legacyProjectMigrationReporter = reporter; +} + /** * Manages reading, writing, and validation of AgentCore configuration files */ @@ -107,10 +132,29 @@ export class ConfigIO { /** * Read and validate the project configuration. + * + * Detects pre-v0.4.0 agentcore.json shapes (legacy `agents`/`type` keys) before validation and + * notifies the registered {@link LegacyProjectMigrationReporter} so the CLI can record telemetry + * and surface a deprecation notice. The schema itself transparently migrates the legacy keys. */ async readProjectSpec(): Promise { const filePath = this.pathResolver.getAgentConfigPath(); - return this.readAndValidate(filePath, 'AgentCore Project Config', AgentCoreProjectSpecSchema); + const raw = await this.readJson(filePath, 'AgentCore Project Config'); + + const legacy = detectsLegacyProjectKeys(raw); + if (legacy.hadAgentsKey || legacy.hadCredentialTypeKey || legacy.hadRuntimeTypeKey) { + try { + legacyProjectMigrationReporter?.(legacy); + } catch { + // Observability hook must never break a config read. + } + } + + const result = AgentCoreProjectSpecSchema.safeParse(raw); + if (!result.success) { + throw new ConfigValidationError(filePath, 'AgentCore Project Config', result.error); + } + return result.data; } /** @@ -295,9 +339,10 @@ export class ConfigIO { } /** - * Generic read and validate method + * Read a config file from disk and parse it as JSON, with typed errors for the + * not-found / read / parse failure modes. Does not validate against a schema. */ - private async readAndValidate(filePath: string, fileType: string, schema: ZodType): Promise { + private async readJson(filePath: string, fileType: string): Promise { // Check if file exists if (!existsSync(filePath)) { throw new ConfigNotFoundError(filePath, fileType); @@ -313,13 +358,19 @@ export class ConfigIO { } // Parse JSON - let parsed: unknown; try { - parsed = JSON.parse(fileContent); + return JSON.parse(fileContent); } catch (err: unknown) { const normalizedError = err instanceof Error ? err : new Error('Invalid JSON'); throw new ConfigParseError(filePath, normalizedError); } + } + + /** + * Generic read and validate method + */ + private async readAndValidate(filePath: string, fileType: string, schema: ZodType): Promise { + const parsed = await this.readJson(filePath, fileType); // Validate with Zod schema const result = schema.safeParse(parsed); diff --git a/src/lib/schemas/io/index.ts b/src/lib/schemas/io/index.ts index e5915541e..09fb49097 100644 --- a/src/lib/schemas/io/index.ts +++ b/src/lib/schemas/io/index.ts @@ -9,4 +9,11 @@ export { requireConfigRoot, type PathConfig, } from './path-resolver'; -export { ConfigIO, createConfigIO, getSchemaUrlForVersion } from './config-io'; +export { + ConfigIO, + createConfigIO, + getSchemaUrlForVersion, + setLegacyProjectMigrationReporter, + type LegacyProjectMigrationInfo, + type LegacyProjectMigrationReporter, +} from './config-io'; diff --git a/src/schema/schemas/__tests__/agentcore-project.test.ts b/src/schema/schemas/__tests__/agentcore-project.test.ts index c11a41a6d..07340cd79 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -661,6 +661,95 @@ describe('AgentCoreProjectSpecSchema', () => { } }); + // Canonical reproducer from GitHub issue #719: the "Before" example carries a legacy + // `type: "AgentCoreRuntime"` on every agent entry AND uses an OAuthCredentialProvider with the + // legacy `type` discriminator. Both must be tolerated and rewritten by the migration. + it('auto-migrates the canonical issue #719 shape (runtime type stripped, OAuth credential)', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + agents: [ + { + type: 'AgentCoreRuntime', + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: 'app/MyAgent/', + runtimeVersion: 'PYTHON_3_12', + networkMode: 'PUBLIC', + modelProvider: 'Bedrock', + protocol: 'HTTP', + }, + ], + credentials: [ + { + type: 'OAuthCredentialProvider', + name: 'my-oauth', + discoveryUrl: 'https://idp.example.com/.well-known/openid-configuration', + vendor: 'CustomOauth2', + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect('agents' in result.data).toBe(false); + expect(result.data.runtimes).toHaveLength(1); + const runtime = result.data.runtimes[0]!; + expect(runtime.name).toBe('MyAgent'); + // The dropped per-runtime `type: "AgentCoreRuntime"` discriminator must be removed, not just + // silently ignored — guards against AgentEnvSpecSchema later being tightened to `.strict()`. + expect('type' in runtime).toBe(false); + + expect(result.data.credentials).toHaveLength(1); + const credential = result.data.credentials[0]!; + expect(credential.authorizerType).toBe('OAuthCredentialProvider'); + expect('type' in credential).toBe(false); + if (credential.authorizerType === 'OAuthCredentialProvider') { + expect(credential.discoveryUrl).toBe('https://idp.example.com/.well-known/openid-configuration'); + expect(credential.vendor).toBe('CustomOauth2'); + } + } + }); + + it('auto-migrates a legacy PaymentCredentialProvider credential type', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + credentials: [ + { + type: 'PaymentCredentialProvider', + name: 'my-payment', + provider: 'CoinbaseCDP', + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + const credential = result.data.credentials[0]!; + expect(credential.authorizerType).toBe('PaymentCredentialProvider'); + expect('type' in credential).toBe(false); + } + }); + + it('strips legacy runtime type even when the array already uses the new `runtimes` key', () => { + const result = AgentCoreProjectSpecSchema.safeParse({ + ...minimalProject, + runtimes: [ + { + type: 'AgentCoreRuntime', + name: 'MyAgent', + build: 'CodeZip', + entrypoint: 'main.py', + codeLocation: './agents/my-agent', + runtimeVersion: 'PYTHON_3_12', + protocol: 'HTTP', + }, + ], + }); + expect(result.success).toBe(true); + if (result.success) { + expect('type' in result.data.runtimes[0]!).toBe(false); + } + }); + it('rejects httpRuntime target on MCP gateway (no protocolType None)', () => { const result = AgentCoreProjectSpecSchema.safeParse({ ...minimalProject, diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index 25a4b3c7a..4d8d9470b 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -712,12 +712,46 @@ const AgentCoreProjectSpecBaseSchema = z } }); +/** Legacy per-entry runtime discriminator dropped in v0.4.0 (PR #706). */ +const LEGACY_RUNTIME_TYPE = 'AgentCoreRuntime'; + +/** + * Returns true when `spec` carries any pre-v0.4.0 key that `migrateLegacyProjectSpec` + * rewrites. Lets callers (e.g. the config loader) emit telemetry / deprecation notices + * only when a legacy file is actually being migrated. + */ +export function detectsLegacyProjectKeys(val: unknown): { + hadAgentsKey: boolean; + hadCredentialTypeKey: boolean; + hadRuntimeTypeKey: boolean; +} { + const empty = { hadAgentsKey: false, hadCredentialTypeKey: false, hadRuntimeTypeKey: false }; + if (val == null || typeof val !== 'object' || Array.isArray(val)) return empty; + const spec = val as Record; + const hadAgentsKey = 'agents' in spec && !('runtimes' in spec); + const runtimeEntries = hadAgentsKey ? spec.agents : spec.runtimes; + const hadRuntimeTypeKey = + Array.isArray(runtimeEntries) && + runtimeEntries.some(entry => entry != null && typeof entry === 'object' && 'type' in entry); + const hadCredentialTypeKey = + Array.isArray(spec.credentials) && + spec.credentials.some( + cred => cred != null && typeof cred === 'object' && 'type' in cred && !('authorizerType' in cred) + ); + return { hadAgentsKey, hadCredentialTypeKey, hadRuntimeTypeKey }; +} + /** * Renames pre-v0.4.0 keys in place so old agentcore.json files keep parsing without manual edits: - * the top-level `agents` array became `runtimes` (PR #706) and the credential discriminator `type` - * became `authorizerType` (PR #709). Without this the `.strict()` top-level schema rejects `agents` - * with a cryptic `unknown keys (remove): "agents"` and the credential union fails on the missing + * the top-level `agents` array became `runtimes` (PR #706), the per-runtime discriminator + * `type: "AgentCoreRuntime"` was dropped (PR #706), and the credential discriminator `type` became + * `authorizerType` (PR #709). Without this the `.strict()` top-level schema rejects `agents` with a + * cryptic `unknown keys (remove): "agents"` and the credential union fails on the missing * `authorizerType` — see GitHub issue #719. Mirrors the legacy-aware preprocess in primitives/harness. + * + * Pure and side-effect free: it never mutates its input and emits no telemetry/warnings. Observability + * for the migration is wired in at the loader (`ConfigIO.readProjectSpec`) via `detectsLegacyProjectKeys`, + * so this schema stays usable in any context (tests, library consumers) without logging surprises. */ function migrateLegacyProjectSpec(val: unknown): unknown { if (val == null || typeof val !== 'object' || Array.isArray(val)) return val; @@ -726,8 +760,22 @@ function migrateLegacyProjectSpec(val: unknown): unknown { spec.runtimes = spec.agents; delete spec.agents; } + // Strip the legacy per-runtime `type: "AgentCoreRuntime"` discriminator. This works today only + // because AgentEnvSpecSchema is non-strict and silently drops unknown keys; doing it explicitly + // keeps legacy projects valid even if that schema is later tightened to `.strict()`. + if (Array.isArray(spec.runtimes)) { + spec.runtimes = (spec.runtimes as unknown[]).map((runtime): unknown => { + if (runtime == null || typeof runtime !== 'object' || Array.isArray(runtime)) return runtime; + const entry = runtime as Record; + if (entry.type === LEGACY_RUNTIME_TYPE) { + const { type: _type, ...rest } = entry; + return rest; + } + return entry; + }); + } if (Array.isArray(spec.credentials)) { - spec.credentials = spec.credentials.map(cred => { + spec.credentials = (spec.credentials as unknown[]).map((cred): unknown => { if (cred == null || typeof cred !== 'object' || Array.isArray(cred)) return cred; const entry = cred as Record; if ('type' in entry && !('authorizerType' in entry)) { @@ -740,6 +788,13 @@ function migrateLegacyProjectSpec(val: unknown): unknown { return spec; } +/** + * Underlying strict object schema, before the legacy-key preprocess wrapper. Exported so consumers + * that need `ZodObject` capabilities (`.shape`, `.extend`, `.pick`, …) still have access — wrapping + * the public export in `z.preprocess` changes its Zod kind to a pipe and hides those methods. + */ +export { AgentCoreProjectSpecBaseSchema }; + export const AgentCoreProjectSpecSchema = z.preprocess(migrateLegacyProjectSpec, AgentCoreProjectSpecBaseSchema); export type AgentCoreProjectSpec = z.infer; From 20ac6dfd9a573730e8650dbeb8c1ac69873cea5f Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Fri, 26 Jun 2026 20:02:58 +0000 Subject: [PATCH 4/4] fix(telemetry): defer legacy-migration notice past TUI and unit-test the reporter - the deprecation notice was written to stderr synchronously from the reporter, which during interactive use lands inside the Ink alt-screen buffer and is repainted over (lost to TUI users). Arm a flag in the reporter instead and flush the one-time notice from printPostCommandNotices after the alt-screen is restored, mirroring the existing telemetry/update-notification deferral - add direct unit tests for the CLI reporter module: camelCase->snake_case attr mapping on cli.legacy_project_migrated, the one-time notice latch (exercising the previously-unused resetLegacyProjectMigrationNotice export), the deferred-print contract, and that a TelemetryClientAccessor.get() rejection is swallowed Refs aws/agentcore-cli#719 --- src/cli/notices.ts | 4 + .../legacy-project-migration.test.ts | 104 ++++++++++++++++++ src/cli/telemetry/index.ts | 1 + src/cli/telemetry/legacy-project-migration.ts | 25 ++++- 4 files changed, 129 insertions(+), 5 deletions(-) create mode 100644 src/cli/telemetry/__tests__/legacy-project-migration.test.ts diff --git a/src/cli/notices.ts b/src/cli/notices.ts index 598e21b30..2e747be90 100644 --- a/src/cli/notices.ts +++ b/src/cli/notices.ts @@ -1,5 +1,6 @@ import { ANSI } from './constants'; import { resolveTelemetryPreference } from './telemetry/config'; +import { printLegacyProjectMigrationNotice } from './telemetry/legacy-project-migration'; import { type UpdateCheckResult, printUpdateNotification } from './update-notifier'; export async function printTelemetryNotice(): Promise { @@ -28,6 +29,9 @@ export async function printPostCommandNotices( if (isFirstRun) { await printTelemetryNotice(); } + // Flush the pre-v0.4.0 deprecation notice (if a legacy agentcore.json was migrated this run) only + // now that any TUI alt-screen buffer has been restored, so the notice is actually visible. + printLegacyProjectMigrationNotice(); const result = await updateCheck; if (result?.updateAvailable) { printUpdateNotification(result); diff --git a/src/cli/telemetry/__tests__/legacy-project-migration.test.ts b/src/cli/telemetry/__tests__/legacy-project-migration.test.ts new file mode 100644 index 000000000..9b39f5c26 --- /dev/null +++ b/src/cli/telemetry/__tests__/legacy-project-migration.test.ts @@ -0,0 +1,104 @@ +import type { LegacyProjectMigrationInfo, LegacyProjectMigrationReporter } from '../../../lib/schemas/io/config-io'; +import * as configIo from '../../../lib/schemas/io/config-io'; +import { TelemetryClient } from '../client'; +import { TelemetryClientAccessor } from '../client-accessor'; +import { + printLegacyProjectMigrationNotice, + registerLegacyProjectMigrationReporter, + resetLegacyProjectMigrationNotice, +} from '../legacy-project-migration'; +import { InMemorySink } from '../sinks/in-memory-sink'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +let sink: InMemorySink; +let capturedReporter: LegacyProjectMigrationReporter | undefined; + +const ALL_LEGACY: LegacyProjectMigrationInfo = { + hadAgentsKey: true, + hadCredentialTypeKey: true, + hadRuntimeTypeKey: true, +}; + +/** Wait for the reporter's fire-and-forget telemetry promise chain to settle. */ +async function flushMicrotasks(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +beforeEach(() => { + sink = new InMemorySink(); + capturedReporter = undefined; + vi.spyOn(TelemetryClientAccessor, 'get').mockResolvedValue(new TelemetryClient(sink)); + // Capture the reporter the CLI installs, instead of letting it mutate lib module state. + vi.spyOn(configIo, 'setLegacyProjectMigrationReporter').mockImplementation(reporter => { + capturedReporter = reporter; + }); + resetLegacyProjectMigrationNotice(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + resetLegacyProjectMigrationNotice(); +}); + +describe('registerLegacyProjectMigrationReporter', () => { + it('emits cli.legacy_project_migrated with the camelCase->snake_case attribute mapping', async () => { + registerLegacyProjectMigrationReporter(); + expect(capturedReporter).toBeDefined(); + + capturedReporter!({ hadAgentsKey: true, hadCredentialTypeKey: false, hadRuntimeTypeKey: true }); + await flushMicrotasks(); + + expect(sink.metrics).toHaveLength(1); + expect(sink.metrics[0]!.metric).toBe('cli.legacy_project_migrated'); + expect(sink.metrics[0]!.value).toBe(1); + // Booleans are serialized to strings by TelemetryClient.emit; assert each key maps to its own field. + expect(sink.metrics[0]!.attrs).toEqual({ + had_agents_key: 'true', + had_credential_type_key: 'false', + had_runtime_type_key: 'true', + }); + }); + + it('does not let a TelemetryClientAccessor.get() rejection propagate', async () => { + vi.spyOn(TelemetryClientAccessor, 'get').mockRejectedValue(new Error('no client')); + registerLegacyProjectMigrationReporter(); + + expect(() => capturedReporter!(ALL_LEGACY)).not.toThrow(); + await flushMicrotasks(); + expect(sink.metrics).toHaveLength(0); + }); +}); + +describe('printLegacyProjectMigrationNotice', () => { + it('is a no-op when no migration was observed', () => { + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + printLegacyProjectMigrationNotice(); + expect(write).not.toHaveBeenCalled(); + }); + + it('prints once after a migration is observed, then is a no-op (one-time latch)', async () => { + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + registerLegacyProjectMigrationReporter(); + capturedReporter!(ALL_LEGACY); + await flushMicrotasks(); + + printLegacyProjectMigrationNotice(); + printLegacyProjectMigrationNotice(); + + expect(write).toHaveBeenCalledTimes(1); + expect(String(write.mock.calls[0]![0])).toContain('pre-v0.4.0'); + }); + + it('does not print synchronously from the reporter (deferred to keep it out of the TUI alt-screen)', async () => { + const write = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + + registerLegacyProjectMigrationReporter(); + capturedReporter!(ALL_LEGACY); + await flushMicrotasks(); + + // The reporter only arms the notice; nothing is written until printPostCommandNotices flushes it. + expect(write).not.toHaveBeenCalled(); + }); +}); diff --git a/src/cli/telemetry/index.ts b/src/cli/telemetry/index.ts index 25bcef3de..1d317599d 100644 --- a/src/cli/telemetry/index.ts +++ b/src/cli/telemetry/index.ts @@ -7,5 +7,6 @@ export { OtelMetricSink, type OtelMetricSinkConfig } from './sinks/otel-metric-s export { FileSystemSink, type FileSystemSinkConfig } from './sinks/filesystem-sink.js'; export { registerLegacyProjectMigrationReporter, + printLegacyProjectMigrationNotice, resetLegacyProjectMigrationNotice, } from './legacy-project-migration.js'; diff --git a/src/cli/telemetry/legacy-project-migration.ts b/src/cli/telemetry/legacy-project-migration.ts index 52935bce7..84e68c49e 100644 --- a/src/cli/telemetry/legacy-project-migration.ts +++ b/src/cli/telemetry/legacy-project-migration.ts @@ -3,15 +3,29 @@ import { setLegacyProjectMigrationReporter } from '../../lib/schemas/io/config-i import { ANSI } from '../constants.js'; import { TelemetryClientAccessor } from './client-accessor.js'; +/** + * Set once a pre-v0.4.0 agentcore.json is auto-migrated on read, so the deprecation notice can be + * printed *after* the command/TUI exits. Printing synchronously from the reporter would land the + * notice inside the Ink alt-screen buffer (most legacy-project reads happen inside a TUI flow) where + * it is immediately repainted over and lost — so we defer, mirroring `printTelemetryNotice` / + * `printUpdateNotification` which are also flushed via `printPostCommandNotices`. + */ +let migrationObserved = false; let noticePrinted = false; -/** Reset the one-time-notice latch. Test-only. */ +/** Reset the deferred-notice state. Test-only. */ export function resetLegacyProjectMigrationNotice(): void { + migrationObserved = false; noticePrinted = false; } -function printDeprecationNotice(): void { - if (noticePrinted) return; +/** + * Print the one-time pre-v0.4.0 deprecation notice if a legacy agentcore.json was migrated during + * this invocation. No-op if no migration was observed or the notice already fired. Call after the + * TUI/command finishes (the alt-screen buffer has been restored) — see `printPostCommandNotices`. + */ +export function printLegacyProjectMigrationNotice(): void { + if (!migrationObserved || noticePrinted) return; noticePrinted = true; const { yellow, reset } = ANSI; process.stderr.write( @@ -29,12 +43,14 @@ function printDeprecationNotice(): void { /** * Wire the CLI's observability into the lib config loader: when a pre-v0.4.0 agentcore.json is * auto-migrated on read, emit the `cli.legacy_project_migrated` metric (so legacy-project adoption - * is measurable and the shim can eventually be removed) and print a one-time deprecation notice. + * is measurable and the shim can eventually be removed) and arm a one-time deprecation notice that + * `printPostCommandNotices` flushes after the alt-screen buffer is restored. * * Kept here in the CLI layer so `src/lib` stays free of any telemetry/CLI import. */ export function registerLegacyProjectMigrationReporter(): void { setLegacyProjectMigrationReporter((info: LegacyProjectMigrationInfo) => { + migrationObserved = true; void TelemetryClientAccessor.get() .then(client => client.emit('cli.legacy_project_migrated', 1, { @@ -46,6 +62,5 @@ export function registerLegacyProjectMigrationReporter(): void { .catch(() => { // Telemetry is best-effort and must never affect CLI behavior. }); - printDeprecationNotice(); }); }