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/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 16395562f..1d317599d 100644 --- a/src/cli/telemetry/index.ts +++ b/src/cli/telemetry/index.ts @@ -5,3 +5,8 @@ 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, + 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 new file mode 100644 index 000000000..84e68c49e --- /dev/null +++ b/src/cli/telemetry/legacy-project-migration.ts @@ -0,0 +1,66 @@ +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'; + +/** + * 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 deferred-notice state. Test-only. */ +export function resetLegacyProjectMigrationNotice(): void { + migrationObserved = false; + noticePrinted = false; +} + +/** + * 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( + [ + '', + `${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 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, { + 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. + }); + }); +} 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 3796f85cf..07340cd79 100644 --- a/src/schema/schemas/__tests__/agentcore-project.test.ts +++ b/src/schema/schemas/__tests__/agentcore-project.test.ts @@ -637,6 +637,119 @@ 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'); + } + }); + + // 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 b9762179c..4d8d9470b 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,89 @@ export const AgentCoreProjectSpecSchema = 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), 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; + const spec = { ...(val as Record) }; + if ('agents' in spec && !('runtimes' in spec)) { + 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 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)) { + const { type, ...rest } = entry; + return { authorizerType: type, ...rest }; + } + return entry; + }); + } + 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;