diff --git a/integ-tests/schema-injection.test.ts b/integ-tests/schema-injection.test.ts new file mode 100644 index 000000000..7f96ee9f4 --- /dev/null +++ b/integ-tests/schema-injection.test.ts @@ -0,0 +1,78 @@ +import { createTestProject, runCLI } from '../src/test-utils/index.js'; +import type { TestProject } from '../src/test-utils/index.js'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +const SCHEMA_URL_PATTERN = /^https:\/\/schema\.agentcore\.aws\.dev\/.+\.json$/; +async function readRawConfig(projectPath: string): Promise> { + const raw = await readFile(join(projectPath, 'agentcore', 'agentcore.json'), 'utf-8'); + return JSON.parse(raw) as Record; +} + +describe('integration: $schema injection in agentcore.json', () => { + let project: TestProject; + + beforeAll(async () => { + project = await createTestProject({ + language: 'Python', + framework: 'Strands', + modelProvider: 'Bedrock', + memory: 'none', + }); + }); + + afterAll(async () => { + await project.cleanup(); + }); + + it('new project has $schema set to the official URL as the first key', async () => { + const config = await readRawConfig(project.projectPath); + expect(config.$schema).toMatch(SCHEMA_URL_PATTERN); + expect(Object.keys(config)[0]).toBe('$schema'); + }); + + it('$schema persists after adding a resource', async () => { + const memName = `SchemaMem${Date.now().toString().slice(-6)}`; + await runCLI(['add', 'memory', '--name', memName, '--json'], project.projectPath); + + const config = await readRawConfig(project.projectPath); + expect(config.$schema).toMatch(SCHEMA_URL_PATTERN); + + await runCLI(['remove', 'memory', '--name', memName, '--json'], project.projectPath); + }); + + it('does not overwrite a custom $schema value', async () => { + const configPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const config = await readRawConfig(project.projectPath); + const customUrl = 'https://example.com/custom-schema.json'; + config.$schema = customUrl; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + const memName = `CustomMem${Date.now().toString().slice(-6)}`; + await runCLI(['add', 'memory', '--name', memName, '--json'], project.projectPath); + + const updated = await readRawConfig(project.projectPath); + expect(updated.$schema).toBe(customUrl); + + await runCLI(['remove', 'memory', '--name', memName, '--json'], project.projectPath); + }); + + it('does not inject $schema into a pre-existing project that lacks one', async () => { + const configPath = join(project.projectPath, 'agentcore', 'agentcore.json'); + const config = await readRawConfig(project.projectPath); + + // Simulate an old project by stripping $schema + delete config.$schema; + await writeFile(configPath, JSON.stringify(config, null, 2)); + + // Trigger a write + const memName = `OldProj${Date.now().toString().slice(-6)}`; + await runCLI(['add', 'memory', '--name', memName, '--json'], project.projectPath); + + const updated = await readRawConfig(project.projectPath); + expect(updated.$schema).toBeUndefined(); + + await runCLI(['remove', 'memory', '--name', memName, '--json'], project.projectPath); + }); +}); diff --git a/schemas/agentcore.schema.v1.json b/schemas/agentcore.schema.v1.json index 823dbdd6a..797fcf686 100644 --- a/schemas/agentcore.schema.v1.json +++ b/schemas/agentcore.schema.v1.json @@ -2,6 +2,9 @@ "$schema": "http://json-schema.org/draft-07/schema#", "type": "object", "properties": { + "$schema": { + "type": "string" + }, "name": { "type": "string", "minLength": 1, @@ -13,6 +16,11 @@ "minimum": 1, "maximum": 9007199254740991 }, + "managedBy": { + "default": "CDK", + "type": "string", + "enum": ["CDK"] + }, "tags": { "type": "object", "propertyNames": { @@ -124,7 +132,6 @@ "type": "boolean" } }, - "required": ["enableOtel"], "additionalProperties": false }, "modelProvider": { @@ -142,6 +149,103 @@ "type": "string" } }, + "authorizerType": { + "type": "string", + "enum": ["AWS_IAM", "CUSTOM_JWT"] + }, + "authorizerConfiguration": { + "type": "object", + "properties": { + "customJwtAuthorizer": { + "type": "object", + "properties": { + "discoveryUrl": { + "type": "string", + "format": "uri" + }, + "allowedAudience": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedClients": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "allowedScopes": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customClaims": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "inboundTokenClaimName": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.:-]+$" + }, + "inboundTokenClaimValueType": { + "type": "string", + "enum": ["STRING", "STRING_ARRAY"] + }, + "authorizingClaimMatchValue": { + "type": "object", + "properties": { + "claimMatchOperator": { + "type": "string", + "enum": ["EQUALS", "CONTAINS", "CONTAINS_ANY"] + }, + "claimMatchValue": { + "type": "object", + "properties": { + "matchValueString": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.-]+$" + }, + "matchValueStringList": { + "minItems": 1, + "maxItems": 255, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9_.-]+$" + } + } + }, + "additionalProperties": false + } + }, + "required": ["claimMatchOperator", "claimMatchValue"], + "additionalProperties": false + } + }, + "required": ["inboundTokenClaimName", "inboundTokenClaimValueType", "authorizingClaimMatchValue"], + "additionalProperties": false + } + } + }, + "required": ["discoveryUrl"], + "additionalProperties": false + } + }, + "additionalProperties": false + }, "tags": { "type": "object", "propertyNames": { @@ -155,6 +259,22 @@ "maxLength": 256, "pattern": "^[\\p{L}\\p{N}\\s_.:/=+\\-@]*$" } + }, + "lifecycleConfiguration": { + "type": "object", + "properties": { + "idleRuntimeSessionTimeout": { + "type": "integer", + "minimum": 60, + "maximum": 28800 + }, + "maxLifetime": { + "type": "integer", + "minimum": 60, + "maximum": 28800 + } + }, + "additionalProperties": false } }, "required": ["type", "name", "build", "entrypoint", "codeLocation", "runtimeVersion"], @@ -190,7 +310,7 @@ "properties": { "type": { "type": "string", - "enum": ["SEMANTIC", "SUMMARIZATION", "USER_PREFERENCE"] + "enum": ["SEMANTIC", "SUMMARIZATION", "USER_PREFERENCE", "EPISODIC"] }, "name": { "type": "string", @@ -206,6 +326,12 @@ "items": { "type": "string" } + }, + "reflectionNamespaces": { + "type": "array", + "items": { + "type": "string" + } } }, "required": ["type"], @@ -227,7 +353,7 @@ } } }, - "required": ["type", "name", "eventExpiryDuration", "strategies"], + "required": ["type", "name", "eventExpiryDuration"], "additionalProperties": false } }, @@ -288,7 +414,7 @@ "enum": ["inbound", "outbound"] } }, - "required": ["type", "name", "discoveryUrl", "vendor"], + "required": ["type", "name"], "additionalProperties": false } ] @@ -645,7 +771,6 @@ "type": "boolean" } }, - "required": ["enableOtel"], "additionalProperties": false }, "networkMode": { @@ -657,14 +782,7 @@ "type": "string" } }, - "required": [ - "artifact", - "pythonVersion", - "name", - "entrypoint", - "codeLocation", - "networkMode" - ], + "required": ["artifact", "pythonVersion", "name", "entrypoint", "codeLocation"], "additionalProperties": false }, "iamPolicy": { @@ -710,7 +828,6 @@ } } }, - "required": ["type"], "additionalProperties": false }, "apiGateway": { @@ -985,7 +1102,7 @@ } } }, - "required": ["name", "targets", "authorizerType", "enableSemanticSearch", "exceptionLevel"], + "required": ["name", "targets"], "additionalProperties": false } }, @@ -1084,7 +1201,6 @@ "type": "boolean" } }, - "required": ["enableOtel"], "additionalProperties": false }, "networkMode": { @@ -1096,7 +1212,7 @@ "type": "string" } }, - "required": ["artifact", "pythonVersion", "name", "entrypoint", "codeLocation", "networkMode"], + "required": ["artifact", "pythonVersion", "name", "entrypoint", "codeLocation"], "additionalProperties": false }, "iamPolicy": { @@ -1308,7 +1424,6 @@ "type": "boolean" } }, - "required": ["enableOtel"], "additionalProperties": false }, "networkMode": { @@ -1320,7 +1435,7 @@ "type": "string" } }, - "required": ["artifact", "pythonVersion", "name", "entrypoint", "codeLocation", "networkMode"], + "required": ["artifact", "pythonVersion", "name", "entrypoint", "codeLocation"], "additionalProperties": false }, "iamPolicy": { @@ -1366,7 +1481,6 @@ } } }, - "required": ["type"], "additionalProperties": false }, "apiGateway": { @@ -1568,17 +1682,14 @@ "enum": ["FAIL_ON_ANY_FINDINGS", "IGNORE_ALL_FINDINGS"] } }, - "required": ["name", "statement", "validationMode"], + "required": ["name", "statement"], "additionalProperties": false } } }, - "required": ["name", "policies"], + "required": ["name"], "additionalProperties": false } - }, - "$schema": { - "type": "string" } }, "required": ["name", "version"], diff --git a/scripts/generate-schema.mjs b/scripts/generate-schema.mjs index 8e5d47f09..6bf4ba8e2 100644 --- a/scripts/generate-schema.mjs +++ b/scripts/generate-schema.mjs @@ -17,13 +17,23 @@ const { AgentCoreProjectSpecSchema } = await import('../dist/schema/schemas/agen const schema = z.toJSONSchema(AgentCoreProjectSpecSchema, { target: 'draft-07' }); -// Allow $schema field alongside the strict properties -schema.properties.$schema = { type: 'string' }; - -// Fields with defaults should not be required — Zod's toJSONSchema marks them required anyway -if (schema.required && schema.properties) { - schema.required = schema.required.filter(field => !('default' in schema.properties[field])); +// Fields with defaults should not be required — Zod's toJSONSchema marks them required anyway. +// Walk the entire schema tree so nested objects are fixed too. +function stripDefaultsFromRequired(node) { + if (typeof node !== 'object' || node === null) return; + if (Array.isArray(node)) { + node.forEach(stripDefaultsFromRequired); + return; + } + if (Array.isArray(node.required) && node.properties) { + node.required = node.required.filter(field => !('default' in (node.properties[field] ?? {}))); + if (node.required.length === 0) delete node.required; + } + for (const value of Object.values(node)) { + stripDefaultsFromRequired(value); + } } +stripDefaultsFromRequired(schema); mkdirSync(dirname(outPath), { recursive: true }); writeFileSync(outPath, JSON.stringify(schema, null, 2) + '\n'); diff --git a/src/cli/commands/create/action.ts b/src/cli/commands/create/action.ts index 3c2f26efa..b1d17f47b 100644 --- a/src/cli/commands/create/action.ts +++ b/src/cli/commands/create/action.ts @@ -1,6 +1,5 @@ import { APP_DIR, CONFIG_DIR, ConfigIO, setEnvVar, setSessionProjectRoot } from '../../../lib'; import type { - AgentCoreProjectSpec, BuildType, DeployedState, ModelProvider, @@ -19,30 +18,12 @@ import { } from '../../operations/agent/generate'; import { executeImportAgent } from '../../operations/agent/import'; import { credentialPrimitive } from '../../primitives/registry'; +import { createDefaultProjectSpec } from '../../project'; import { CDKRenderer, createRenderer } from '../../templates'; import type { CreateResult } from './types'; import { mkdir } from 'fs/promises'; import { join } from 'path'; -function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { - return { - name: projectName, - version: 1, - managedBy: 'CDK' as const, - agents: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - tags: { - 'agentcore:created-by': 'agentcore-cli', - 'agentcore:project-name': projectName, - }, - }; -} - function createDefaultDeployedState(): DeployedState { return { targets: {} }; } diff --git a/src/cli/project.ts b/src/cli/project.ts new file mode 100644 index 000000000..39fb9b5f5 --- /dev/null +++ b/src/cli/project.ts @@ -0,0 +1,26 @@ +import { getSchemaUrlForVersion } from '../lib'; +import type { AgentCoreProjectSpec } from '../schema'; +import { SCHEMA_VERSION } from './constants'; + +/** + * Create a default AgentCore project spec with standard defaults. + */ +export function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { + return { + $schema: getSchemaUrlForVersion(SCHEMA_VERSION), + name: projectName, + version: SCHEMA_VERSION, + managedBy: 'CDK' as const, + agents: [], + memories: [], + credentials: [], + evaluators: [], + onlineEvalConfigs: [], + agentCoreGateways: [], + policyEngines: [], + tags: { + 'agentcore:created-by': 'agentcore-cli', + 'agentcore:project-name': projectName, + }, + }; +} diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index 6f3086753..5b18bf2a9 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -1,5 +1,5 @@ import { APP_DIR, CONFIG_DIR, ConfigIO, findConfigRoot, setEnvVar, setSessionProjectRoot } from '../../../../lib'; -import type { AgentCoreProjectSpec, DeployedState } from '../../../../schema'; +import type { DeployedState } from '../../../../schema'; import { getErrorMessage } from '../../../errors'; import { CreateLogger } from '../../../logging'; import { initGitRepo, setupPythonProject, writeEnvFile, writeGitignore } from '../../../operations'; @@ -13,6 +13,7 @@ import { executeImportAgent } from '../../../operations/agent/import'; import { createManagedOAuthCredential } from '../../../primitives/auth-utils'; import { computeDefaultCredentialEnvVarName } from '../../../primitives/credential-utils'; import { credentialPrimitive } from '../../../primitives/registry'; +import { createDefaultProjectSpec } from '../../../project'; import { CDKRenderer, createRenderer } from '../../../templates'; import { type Step, areStepsComplete, hasStepError } from '../../components'; import { withMinDuration } from '../../utils'; @@ -69,25 +70,6 @@ function getCreateSteps(projectName: string, agentConfig: AddAgentConfig | null) return steps; } -function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { - return { - name: projectName, - version: 1, - managedBy: 'CDK' as const, - tags: { - 'agentcore:created-by': 'agentcore-cli', - 'agentcore:project-name': projectName, - }, - agents: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - }; -} - function createDefaultDeployedState(): DeployedState { return { targets: {}, diff --git a/src/cli/tui/screens/remove/useRemoveFlow.ts b/src/cli/tui/screens/remove/useRemoveFlow.ts index 8594a5b77..ccf88b640 100644 --- a/src/cli/tui/screens/remove/useRemoveFlow.ts +++ b/src/cli/tui/screens/remove/useRemoveFlow.ts @@ -1,7 +1,7 @@ import { ConfigIO, getWorkingDirectory } from '../../../../lib'; -import type { AgentCoreProjectSpec } from '../../../../schema'; import { findStack } from '../../../cloudformation/stack-discovery'; import { getErrorMessage } from '../../../errors'; +import { createDefaultProjectSpec } from '../../../project'; import { type Step, areStepsComplete, hasStepError } from '../../components'; import { withMinDuration } from '../../utils'; import { useCallback, useEffect, useMemo, useState } from 'react'; @@ -27,21 +27,6 @@ function getRemoveSteps(): Step[] { return [{ label: 'Reset project schemas', status: 'pending' }]; } -function createDefaultProjectSpec(projectName: string): AgentCoreProjectSpec { - return { - name: projectName, - version: 1, - managedBy: 'CDK' as const, - agents: [], - memories: [], - credentials: [], - evaluators: [], - onlineEvalConfigs: [], - agentCoreGateways: [], - policyEngines: [], - }; -} - export function useRemoveFlow({ force, dryRun }: RemoveFlowOptions): RemoveFlowState { const [phase, setPhase] = useState('checking'); const [steps, setSteps] = useState([]); diff --git a/src/lib/schemas/io/config-io.ts b/src/lib/schemas/io/config-io.ts index cf5391937..c1ce7ccde 100644 --- a/src/lib/schemas/io/config-io.ts +++ b/src/lib/schemas/io/config-io.ts @@ -21,6 +21,13 @@ import { mkdir, readFile, writeFile } from 'fs/promises'; import { dirname } from 'path'; import { type ZodType } from 'zod'; +/** Supported schema versions. Extend this union as new versions are published. */ +type SchemaVersion = 1; + +export function getSchemaUrlForVersion(version: SchemaVersion): string { + return `https://schema.agentcore.aws.dev/v${version}/agentcore.json`; +} + /** * Manages reading, writing, and validation of AgentCore configuration files */ diff --git a/src/lib/schemas/io/index.ts b/src/lib/schemas/io/index.ts index d9e8c9b52..e8ddddc0f 100644 --- a/src/lib/schemas/io/index.ts +++ b/src/lib/schemas/io/index.ts @@ -10,5 +10,5 @@ export { NoProjectError, type PathConfig, } from './path-resolver'; -export { ConfigIO, createConfigIO } from './config-io'; +export { ConfigIO, createConfigIO, getSchemaUrlForVersion } from './config-io'; export { readCliConfig, type CliConfig } from './cli-config'; diff --git a/src/schema/schemas/agentcore-project.ts b/src/schema/schemas/agentcore-project.ts index c1b934b79..0af37c276 100644 --- a/src/schema/schemas/agentcore-project.ts +++ b/src/schema/schemas/agentcore-project.ts @@ -175,6 +175,7 @@ const ARN_PREFIX = 'arn:'; export const AgentCoreProjectSpecSchema = z .object({ + $schema: z.string().optional(), name: ProjectNameSchema, version: z.number().int().min(1), managedBy: ManagedBySchema,