diff --git a/src/core/project/manager.test.ts b/src/core/project/manager.test.ts index a355dc4b4..8da256d2e 100644 --- a/src/core/project/manager.test.ts +++ b/src/core/project/manager.test.ts @@ -180,7 +180,7 @@ describe("FsProjectManager.create", () => { ]); expect(project.name).toBe("example"); expect(project.rootPath).toContain("example"); - expect(project.runtimes).toHaveLength(1); + expect(project.spec.runtimes).toHaveLength(1); }); test("a failed step propagates and leaves the scaffolded files in place", async () => { @@ -280,7 +280,10 @@ describe("FsProjectManager.build", () => { commands.length = 0; // CDK is the only backend today; the cast stands in for a future one. - const foreign = { ...project, managedBy: "Terraform" as Project["managedBy"] }; + const foreign = { + ...project, + spec: { ...project.spec, managedBy: "Terraform" as Project["spec"]["managedBy"] }, + }; await expect(drain(subject.build(foreign))).rejects.toThrow(/unsupported backend: Terraform/); expect(commands).toEqual([]); }); @@ -312,8 +315,8 @@ describe("FsProjectManager.resolve", () => { expect(resolved?.name).toBe("example"); expect(resolved?.rootPath).toBe(join(root, "example")); - expect(resolved?.managedBy).toBe("CDK"); - expect(resolved?.runtimes).toHaveLength(1); + expect(resolved?.spec.managedBy).toBe("CDK"); + expect(resolved?.spec.runtimes).toHaveLength(1); }); test("returns undefined when no project encloses the path", async () => { diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index dbbce6106..a10daa1df 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,13 +1,14 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { copyFile, rm } from "node:fs/promises"; +import { join, relative } from "node:path"; import type { + AddResourceInput, CreateProjectInput, ResolveProjectInput, Project, ProjectManager, ProjectEvent, ProjectResource, - ProjectResourceConfig, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { @@ -18,7 +19,7 @@ import { type ReadWriteJson, } from "../../io"; import { defaultSource, type AssetSource } from "./source"; -import { createProjectTreeFromTemplate, TEMPLATES } from "./templates"; +import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { @@ -27,6 +28,8 @@ import { NotImplementedError, ProjectStateError, } from "../../errors/errors"; +import type { HarnessSpecSchema } from "../../projectSchemas/harness"; +import type z from "zod"; type ProjectManagerConfig = { logger: Logger; @@ -64,8 +67,7 @@ export class FsProjectManager implements ProjectManager { return { name: spec.name, rootPath, - managedBy: spec.managedBy, - runtimes: spec.runtimes, + spec, }; } catch (error) { // A malformed agentcore.json is a user-correctable problem, not a crash. @@ -130,26 +132,104 @@ export class FsProjectManager implements ProjectManager { return project; } - // eslint-disable-next-line require-yield - public async *addResource( - _project: Project, - _resourceType: TResource, - _resourceConfig: ProjectResourceConfig, + public async *addResource( + project: Project, + input: AddResourceInput, ): AsyncGenerator { - throw new NotImplementedError("FsProjectManager.addResource is not yet implemented"); + const { resourceType, resourceConfig } = input; + const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json"); + const projectSpecKey = toProjectSpecKey(resourceType); + + yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; + const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); + + const existingResources = existingProjectSpec[projectSpecKey]; + if (existingResources.find((r) => r.name === resourceConfig.name)) + throw new InputValidationError( + `a ${resourceType} with name '${resourceConfig.name}' already exists`, + ); + + const newResources = [...existingResources]; + const scaffoldedPaths: string[] = []; + + switch (resourceType) { + case "harness": { + yield { message: `Scaffolding harness in project` }; + const harnessPath = await this.scaffoldHarness(project.rootPath, input.resourceConfig); + scaffoldedPaths.push(harnessPath); + newResources.push({ + name: input.resourceConfig.name, + path: relative(project.rootPath, harnessPath), + }); + break; + } + case "runtime": { + throw new NotImplementedError( + "runtime case not yet implemented in FsProjectManager.addResource", + ); + } + // TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes. + } + + yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; + + // rollback scaffolding changes on failed config writes to prevent bad state. + try { + const newProjectSpec = await this.json.write(agentCoreSpecPath, { + ...existingProjectSpec, + [projectSpecKey]: newResources, + }); + + return { + ...project, + spec: newProjectSpec, + }; + } catch (err) { + this.logger.warn( + `Failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`, + ); + await Promise.all( + scaffoldedPaths.map((p) => rm(p, { recursive: true, force: true }).catch(() => {})), + ); + throw err; + } + } + + private async scaffoldHarness( + projectRoot: string, + harnessSpec: z.input, + ): Promise { + const outputPath = join(projectRoot, "app", harnessSpec.name); + + const harness = await createHarnessTreeFromSpec({ + ...harnessSpec, + dockerfile: harnessSpec.dockerfile ? "Dockerfile" : undefined, + }); + + if (harnessSpec.dockerfile) { + if (!existsSync(harnessSpec.dockerfile)) + throw new InputValidationError(`dockerfile not found: '${harnessSpec.dockerfile}'`); + } + + await harness.write(outputPath); + + if (harnessSpec.dockerfile) { + await copyFile(harnessSpec.dockerfile, join(outputPath, "Dockerfile")); + } + return outputPath; } public async *build(project: Project): AsyncGenerator { // agentcore.json records which backend owns the project's artifacts. CDK is the // only one today; a terraform or no-IaC backend adds an arm here rather than // editing the CDK path. - switch (project.managedBy) { + switch (project.spec.managedBy) { case "CDK": yield* this.buildWithCdk(project); break; default: { // Exhaustiveness: a new ManagedBy member fails to compile until it is handled. - const unsupported: never = project.managedBy; + const unsupported: never = project.spec.managedBy; throw new ProjectStateError( `project '${project.name}' declares an unsupported backend: ${String(unsupported)}`, ); @@ -183,3 +263,15 @@ export class FsProjectManager implements ProjectManager { return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); } } + +/** Map {@link ProjectResource} to keys in the project spec. + * Note: we let TS infer the return type to avoid pulling in keys that do not correspond to resources (ex. name, managedBy, etc.) + */ +function toProjectSpecKey(resourceType: ProjectResource) { + switch (resourceType) { + case "harness": + return "harnesses"; + case "runtime": + return "runtimes"; + } +} diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 4d7aa5150..661b2db5e 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,6 +1,9 @@ +import { ZodError, z } from "zod"; import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; +import { HarnessSpecSchema } from "../../projectSchemas/harness"; import { FsTreeNode } from "./fsTree"; import type { AssetSource } from "./source"; +import { InputValidationError } from "../../errors/errors"; type TemplateSpec = { runtimes?: unknown[]; @@ -90,3 +93,30 @@ export async function createProjectTreeFromTemplate( FsTreeNode.createDirectory("app", [await FsTreeNode.fromAssetSource(src, assetDir, appDir)]), ]); } + +const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant"; + +export async function createHarnessTreeFromSpec( + spec: z.input, +): Promise { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { systemPrompt, ...rest } = spec; + // strip system prompt such that markdown file is source of truth. + const parsed = parseHarnessSpec(rest); + return FsTreeNode.createDirectory(".", [ + FsTreeNode.createFile("harness.json", async () => json(parsed)), + FsTreeNode.createFile( + "system-prompt.md", + async () => spec.systemPrompt ?? DEFAULT_HARNESS_SYSTEM_PROMPT, + ), + ]); +} + +function parseHarnessSpec(spec: z.input) { + try { + return HarnessSpecSchema.parse(spec); + } catch (err) { + if (err instanceof ZodError) throw new InputValidationError(z.prettifyError(err)); + throw err; + } +} diff --git a/src/core/types.tsx b/src/core/types.tsx index 9b1a2d4a8..98a2f338d 100644 --- a/src/core/types.tsx +++ b/src/core/types.tsx @@ -37,7 +37,7 @@ export type CoreFetch = ( // full ClientConfig so callers can request any client customization (region, // endpoint, ...). export interface AwsClients { - control(config: ClientConfig): BedrockAgentCoreControlClient + control(config: ClientConfig): BedrockAgentCoreControlClient; data(config: ClientConfig): BedrockAgentCoreClient; iam(config: ClientConfig): IAMClient; // logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation diff --git a/src/handlers/eval/ondemand/ondemand.test.tsx b/src/handlers/eval/ondemand/ondemand.test.tsx index 6d7173444..77c0c688a 100644 --- a/src/handlers/eval/ondemand/ondemand.test.tsx +++ b/src/handlers/eval/ondemand/ondemand.test.tsx @@ -24,7 +24,9 @@ const TRACE: SessionTrace = { const RESULT: EvaluateResult = { sessionsRequested: 1, sessionsEvaluated: 1, - results: [{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number]], + results: [ + { evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number], + ], }; async function run(args: string[], configure?: (core: TestCoreClient) => void) { diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 9e98690d1..1c160fa06 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -7,6 +7,7 @@ import type { AuthorizerConfiguration as SdkAuthorizerConfiguration, HarnessEnvironmentArtifact, HarnessEnvironmentProviderRequest, + HarnessGatewayOutboundAuth as SdkHarnessGatewayOutboundAuth, HarnessMemoryConfiguration as SdkMemoryConfiguration, HarnessModelConfiguration, HarnessSkill as SdkHarnessSkill, @@ -14,6 +15,7 @@ import type { HarnessTruncationConfiguration as SdkTruncationConfiguration, } from "@aws-sdk/client-bedrock-agentcore-control"; import type { + HarnessGatewayOutboundAuth, HarnessMemoryRef, HarnessModel, HarnessSkill, @@ -82,6 +84,11 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => "path to local dockerfile to use as the container image for the harness", z.string().optional(), ), + flag( + "vpc-id", + "VPC ID for Dockerfile builds in VPC mode (required when combining --dockerfile with VPC networking)", + z.string().optional(), + ), ], handle: async (ctx, flags) => { if (!flags.name) @@ -136,7 +143,9 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => timeoutSeconds: flags["timeout-seconds"], tags: parseJsonFlag>("tags", flags["tags"]), networkMode: env?.networkMode, - networkConfig: env?.networkConfig, + networkConfig: env?.networkConfig + ? { ...env.networkConfig, vpcId: flags["vpc-id"] } + : undefined, lifecycleConfig: env?.lifecycleConfig, sessionStoragePath: env?.sessionStoragePath, efsAccessPoints: env?.efsAccessPoints, @@ -146,11 +155,10 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => }; const project = ctx.require(ProjectKey); - for await (const event of config.projectManager.addResource( - project, - "harness", - harnessConfig, - )) { + for await (const event of config.projectManager.addResource(project, { + resourceType: "harness", + resourceConfig: harnessConfig, + })) { config.io.stderr.write(`${event.message}\n`); } @@ -233,6 +241,9 @@ function toTool(tool: SdkHarnessTool): HarnessTool { config: { agentCoreGateway: { gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), + outboundAuth: c.agentCoreGateway.outboundAuth + ? toOutboundAuth(c.agentCoreGateway.outboundAuth) + : undefined, }, }, }; @@ -263,6 +274,27 @@ function toTool(tool: SdkHarnessTool): HarnessTool { return { type: tool.type, name: tool.name }; } +/** Converts an SDK HarnessGatewayOutboundAuth tagged union into the project-schema shape. */ +function toOutboundAuth(auth: SdkHarnessGatewayOutboundAuth): HarnessGatewayOutboundAuth { + if ("awsIam" in auth && auth.awsIam) return { awsIam: {} }; + if ("none" in auth && auth.none) return { none: {} }; + if ("oauth" in auth && auth.oauth) { + return { + oauth: { + providerArn: requireField(auth.oauth.providerArn, "outboundAuth.oauth.providerArn"), + scopes: requireField(auth.oauth.scopes, "outboundAuth.oauth.scopes"), + // SDK does not expose this type directly. + grantType: auth.oauth.grantType as Extract< + HarnessGatewayOutboundAuth, + { oauth: unknown } + >["oauth"]["grantType"], + customParameters: auth.oauth.customParameters, + }, + }; + } + throw new InputValidationError("unrecognized outboundAuth variant"); +} + /** Converts an SDK HarnessSkill tagged union into the project-schema shape. */ function toSkill(skill: SdkHarnessSkill): HarnessSkill { if ("path" in skill && skill.path) { @@ -277,7 +309,7 @@ function toSkill(skill: SdkHarnessSkill): HarnessSkill { path: skill.git.path, auth: skill.git.auth ? { - credentialName: requireField( + credentialArn: requireField( skill.git.auth.credentialArn, "skill.git.auth.credentialArn", ), diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d9dce3bba..63a4cedee 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -1,4 +1,5 @@ import { afterEach, test, expect, describe } from "bun:test"; +import { existsSync } from "node:fs"; import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; @@ -10,10 +11,11 @@ import { testIO, } from "../../testing"; import { InputValidationError } from "../../errors"; +import { FsReadWriteJson, type ReadWriteJson } from "../../io"; -async function run(args: string[]) { +async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); - const core = new TestCoreClient(); + const core = opts?.core ?? new TestCoreClient(); const root = createRootHandler(core, { io: io.io, globalConfigAccessor: new TestGlobalConfigAccessor(), @@ -109,8 +111,10 @@ describe("project create", () => { }); describe("project add harness", () => { - test.each([ - ["minimal — name only", ["--name", "my-agent"]], + const defaultModel = { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" }; + + test.each<[string, string[], Record]>([ + ["minimal — name only", ["--name", "x"], { model: defaultModel }], [ "model — bedrock", [ @@ -119,6 +123,7 @@ describe("project add harness", () => { "--model", '{"bedrockModelConfig":{"modelId":"us.anthropic.claude-sonnet-4-5-20250929-v1:0"}}', ], + { model: { provider: "bedrock", modelId: "us.anthropic.claude-sonnet-4-5-20250929-v1:0" } }, ], [ "model — openai", @@ -128,6 +133,13 @@ describe("project add harness", () => { "--model", '{"openAiModelConfig":{"modelId":"gpt-4","apiKeyArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k"}}', ], + { + model: { + provider: "open_ai", + modelId: "gpt-4", + apiKeyArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k", + }, + }, ], [ "model — gemini", @@ -137,10 +149,18 @@ describe("project add harness", () => { "--model", '{"geminiModelConfig":{"modelId":"gemini-pro","apiKeyArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k"}}', ], + { + model: { + provider: "gemini", + modelId: "gemini-pro", + apiKeyArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:api-key/k", + }, + }, ], [ "model — litellm", ["--name", "x", "--model", '{"liteLlmModelConfig":{"modelId":"anthropic/claude-3"}}'], + { model: { provider: "lite_llm", modelId: "anthropic/claude-3" } }, ], [ "tools — remote_mcp", @@ -150,6 +170,15 @@ describe("project add harness", () => { "--tools", '[{"type":"remote_mcp","name":"mcp1","config":{"remoteMcp":{"url":"https://mcp.example.com"}}}]', ], + { + tools: [ + { + type: "remote_mcp", + name: "mcp1", + config: { remoteMcp: { url: "https://mcp.example.com" } }, + }, + ], + }, ], [ "tools — agentcore_gateway", @@ -159,6 +188,48 @@ describe("project add harness", () => { "--tools", '[{"type":"agentcore_gateway","name":"gw1","config":{"agentCoreGateway":{"gatewayArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g"}}}]', ], + { + tools: [ + { + type: "agentcore_gateway", + name: "gw1", + config: { + agentCoreGateway: { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g", + }, + }, + }, + ], + }, + ], + [ + "tools — agentcore_gateway with outboundAuth", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_gateway","name":"gw1","config":{"agentCoreGateway":{"gatewayArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g","outboundAuth":{"oauth":{"providerArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:oauth2-credential-provider/p","scopes":["read","write"]}}}}}]', + ], + { + tools: [ + { + type: "agentcore_gateway", + name: "gw1", + config: { + agentCoreGateway: { + gatewayArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g", + outboundAuth: { + oauth: { + providerArn: + "arn:aws:bedrock-agentcore:us-east-1:123456789012:oauth2-credential-provider/p", + scopes: ["read", "write"], + }, + }, + }, + }, + }, + ], + }, ], [ "tools — agentcore_browser", @@ -168,6 +239,7 @@ describe("project add harness", () => { "--tools", '[{"type":"agentcore_browser","name":"br1","config":{"agentCoreBrowser":{}}}]', ], + { tools: [{ type: "agentcore_browser", name: "br1", config: { agentCoreBrowser: {} } }] }, ], [ "tools — inline_function", @@ -177,6 +249,15 @@ describe("project add harness", () => { "--tools", '[{"type":"inline_function","name":"fn1","config":{"inlineFunction":{"description":"test","inputSchema":{"type":"object"}}}}]', ], + { + tools: [ + { + type: "inline_function", + name: "fn1", + config: { inlineFunction: { description: "test", inputSchema: { type: "object" } } }, + }, + ], + }, ], [ "tools — agentcore_code_interpreter", @@ -186,10 +267,20 @@ describe("project add harness", () => { "--tools", '[{"type":"agentcore_code_interpreter","name":"ci1","config":{"agentCoreCodeInterpreter":{}}}]', ], + { + tools: [ + { + type: "agentcore_code_interpreter", + name: "ci1", + config: { agentCoreCodeInterpreter: {} }, + }, + ], + }, ], [ "tools — no config", ["--name", "x", "--tools", '[{"type":"agentcore_browser","name":"br1"}]'], + { tools: [{ type: "agentcore_browser", name: "br1" }] }, ], [ "tools — unrecognized config variant (passes through without config)", @@ -199,9 +290,18 @@ describe("project add harness", () => { "--tools", '[{"type":"agentcore_browser","name":"br1","config":{"someFutureConfig":{}}}]', ], + { tools: [{ type: "agentcore_browser", name: "br1" }] }, + ], + [ + "skills — path", + ["--name", "x", "--skills", '[{"path":"./my-skill"}]'], + { skills: [{ path: "./my-skill" }] }, + ], + [ + "skills — s3", + ["--name", "x", "--skills", '[{"s3":{"uri":"s3://bucket/skill/"}}]'], + { skills: [{ s3Uri: "s3://bucket/skill/" }] }, ], - ["skills — path", ["--name", "x", "--skills", '[{"path":"./my-skill"}]']], - ["skills — s3", ["--name", "x", "--skills", '[{"s3":{"uri":"s3://bucket/skill/"}}]']], [ "skills — git", [ @@ -210,10 +310,23 @@ describe("project add harness", () => { "--skills", '[{"git":{"url":"https://github.com/org/repo","path":"skills/","auth":{"credentialArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c","username":"oauth2"}}}]', ], + { + skills: [ + { + gitUrl: "https://github.com/org/repo", + path: "skills/", + auth: { + credentialArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c", + username: "oauth2", + }, + }, + ], + }, ], [ "skills — awsSkills", ["--name", "x", "--skills", '[{"awsSkills":{"paths":["core-skills/*"]}}]'], + { skills: [{ awsSkills: { paths: ["core-skills/*"] } }] }, ], [ "memory — managed", @@ -223,6 +336,7 @@ describe("project add harness", () => { "--memory", '{"managedMemoryConfiguration":{"strategies":["SEMANTIC"],"eventExpiryDuration":30}}', ], + { memory: { mode: "managed", strategies: ["SEMANTIC"], eventExpiryDuration: 30 } }, ], [ "memory — existing", @@ -232,8 +346,18 @@ describe("project add harness", () => { "--memory", '{"agentCoreMemoryConfiguration":{"arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m"}}', ], + { + memory: { + mode: "existing", + arn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m", + }, + }, + ], + [ + "memory — disabled", + ["--name", "x", "--memory", '{"disabled":{}}'], + { memory: { mode: "disabled" } }, ], - ["memory — disabled", ["--name", "x", "--memory", '{"disabled":{}}']], [ "truncation — sliding_window", [ @@ -242,6 +366,12 @@ describe("project add harness", () => { "--truncation", '{"strategy":"sliding_window","config":{"slidingWindow":{"messagesCount":40}}}', ], + { + truncation: { + strategy: "sliding_window", + config: { slidingWindow: { messagesCount: 40 } }, + }, + }, ], [ "truncation — summarization", @@ -251,11 +381,22 @@ describe("project add harness", () => { "--truncation", '{"strategy":"summarization","config":{"summarization":{"summaryRatio":0.5,"preserveRecentMessages":5}}}', ], + { + truncation: { + strategy: "summarization", + config: { summarization: { summaryRatio: 0.5, preserveRecentMessages: 5 } }, + }, + }, + ], + [ + "truncation — none", + ["--name", "x", "--truncation", '{"strategy":"none"}'], + { truncation: { strategy: "none" } }, ], - ["truncation — none", ["--name", "x", "--truncation", '{"strategy":"none"}']], [ "truncation — unrecognized config variant (passes through strategy only)", ["--name", "x", "--truncation", '{"strategy":"none","config":{"someFutureStrategy":{}}}'], + { truncation: { strategy: "none" } }, ], [ "authorizer — customJWT", @@ -265,6 +406,15 @@ describe("project add harness", () => { "--authorizer-configuration", '{"customJWTAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["my-app"]}}', ], + { + authorizerType: "CUSTOM_JWT", + authorizerConfiguration: { + customJwtAuthorizer: { + discoveryUrl: "https://idp.example.com/.well-known/openid-configuration", + allowedAudience: ["my-app"], + }, + }, + }, ], [ "environment — VPC + lifecycle", @@ -272,8 +422,16 @@ describe("project add harness", () => { "--name", "x", "--environment", - '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-abc"]}},"lifecycleConfiguration":{"idleRuntimeSessionTimeout":900,"maxLifetime":28800}}}', + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}},"lifecycleConfiguration":{"idleRuntimeSessionTimeout":900,"maxLifetime":28800}}}', ], + { + networkMode: "VPC", + networkConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + lifecycleConfig: { idleRuntimeSessionTimeout: 900, maxLifetime: 28800 }, + }, ], [ "environment — with filesystem mounts", @@ -281,8 +439,30 @@ describe("project add harness", () => { "--name", "x", "--environment", - '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-abc"],"securityGroups":["sg-abc"]}},"filesystemConfigurations":[{"sessionStorage":{"mountPath":"/mnt/data"}},{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-abc","mountPath":"/mnt/efs"}},{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-abc/access-point/fsap-abc","mountPath":"/mnt/s3"}}]}}', + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}},"filesystemConfigurations":[{"sessionStorage":{"mountPath":"/mnt/data"}},{"efsAccessPoint":{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0","mountPath":"/mnt/efs"}},{"s3FilesAccessPoint":{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef01/access-point/fsap-0123456789abcdef01","mountPath":"/mnt/s3"}}]}}', ], + { + networkMode: "VPC", + networkConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + sessionStoragePath: "/mnt/data", + efsAccessPoints: [ + { + accessPointArn: + "arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0", + mountPath: "/mnt/efs", + }, + ], + s3AccessPoints: [ + { + accessPointArn: + "arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef01/access-point/fsap-0123456789abcdef01", + mountPath: "/mnt/s3", + }, + ], + }, ], [ "environment-artifact — containerUri", @@ -292,18 +472,134 @@ describe("project add harness", () => { "--environment-artifact", '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest"}}', ], + { containerUri: "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest" }, + ], + [ + "environment-variables", + ["--name", "x", "--environment-variables", '{"LOG_LEVEL":"debug"}'], + { environmentVariables: { LOG_LEVEL: "debug" } }, + ], + ["tags", ["--name", "x", "--tags", '{"team":"ml"}'], { tags: { team: "ml" } }], + [ + "allowed-tools", + ["--name", "x", "--allowed-tools", "*", "@builtin"], + { allowedTools: ["*", "@builtin"] }, ], - ["environment-variables", ["--name", "x", "--environment-variables", '{"LOG_LEVEL":"debug"}']], - ["tags", ["--name", "x", "--tags", '{"team":"ml"}']], - ["allowed-tools", ["--name", "x", "--allowed-tools", "*", "@builtin"]], [ "max-iterations, max-tokens, timeout-seconds", ["--name", "x", "--max-iterations", "10", "--max-tokens", "4096", "--timeout-seconds", "60"], + { maxIterations: 10, maxTokens: 4096, timeoutSeconds: 60 }, ], - ])("%s", async (_label, flags) => { - await inProject(); - // TODO: update to verify that the project updates. - await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); + ])("%s", async (_label, flags, expected) => { + const projectRoot = await inProject(); + await run(["add", "harness", ...flags]); + + const harnessJson = await Bun.file(join(projectRoot, "app", "x", "harness.json")).json(); + expect(harnessJson).toMatchObject(expected); + + const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); + expect(agentcoreJson.harnesses).toContainEqual({ + name: "x", + path: join("app", "x"), + }); + }); + + test("--system-prompt overrides the default system-prompt.md", async () => { + const projectRoot = await inProject(); + await run(["add", "harness", "--name", "x", "--system-prompt", "You are a pirate."]); + + const prompt = await Bun.file(join(projectRoot, "app", "x", "system-prompt.md")).text(); + expect(prompt).toBe("You are a pirate."); + + const harnessJson = await Bun.file(join(projectRoot, "app", "x", "harness.json")).json(); + expect(harnessJson).not.toHaveProperty("systemPrompt"); + }); + + test("--dockerfile copies the file into the harness directory and stores the relative path", async () => { + const projectRoot = await inProject(); + + const dockerfilePath = join(projectRoot, "Dockerfile"); + await Bun.write(dockerfilePath, "FROM python:3.12-slim\nCOPY . /app\n"); + + await run(["add", "harness", "--name", "x", "--dockerfile", dockerfilePath]); + + const copiedContent = await Bun.file(join(projectRoot, "app", "x", "Dockerfile")).text(); + expect(copiedContent).toBe("FROM python:3.12-slim\nCOPY . /app\n"); + + const harnessJson = await Bun.file(join(projectRoot, "app", "x", "harness.json")).json(); + expect(harnessJson.dockerfile).toBe("Dockerfile"); + }); + + test("--dockerfile with VPC mode succeeds when --vpc-id is provided", async () => { + const projectRoot = await inProject(); + + const dockerfilePath = join(projectRoot, "Dockerfile"); + await Bun.write(dockerfilePath, "FROM python:3.12-slim\n"); + + await run([ + "add", + "harness", + "--name", + "x", + "--dockerfile", + dockerfilePath, + "--environment", + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}}}}', + "--vpc-id", + "vpc-0123456789abcdef0", + ]); + + const harnessJson = await Bun.file(join(projectRoot, "app", "x", "harness.json")).json(); + expect(harnessJson).toMatchObject({ + dockerfile: "Dockerfile", + networkMode: "VPC", + networkConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + vpcId: "vpc-0123456789abcdef0", + }, + }); + }); + + test("--dockerfile with VPC mode fails without --vpc-id", async () => { + const projectRoot = await inProject(); + + const dockerfilePath = join(projectRoot, "Dockerfile"); + await Bun.write(dockerfilePath, "FROM python:3.12-slim\n"); + + await expect( + run([ + "add", + "harness", + "--name", + "x", + "--dockerfile", + dockerfilePath, + "--environment", + '{"agentCoreRuntimeEnvironment":{"networkConfiguration":{"networkMode":"VPC","networkModeConfig":{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}}}}', + ]), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("cleans up scaffolded files when the spec write fails", async () => { + const projectRoot = await inProject(); + const logger = createSilentLogger(); + const realJson = new FsReadWriteJson({ logger }); + + // A json adapter that delegates reads but always fails on write. + const failingJson: ReadWriteJson = { + read: (path, schema) => realJson.read(path, schema), + write: () => { + throw new Error("simulated write failure"); + }, + }; + + const core = new TestCoreClient({ json: failingJson }); + + await expect(run(["add", "harness", "--name", "x"], { core })).rejects.toThrow(); + + // The scaffolded harness directory should have been cleaned up. + expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); }); test.each([ @@ -336,6 +632,15 @@ describe("project add harness", () => { "unrecognized environment-artifact variant", ["--name", "x", "--environment-artifact", '{"unknownArtifact":{}}'], ], + [ + "unrecognized outboundAuth variant", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_gateway","name":"gw1","config":{"agentCoreGateway":{"gatewayArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:gateway/g","outboundAuth":{"unknownAuth":{}}}}}]', + ], + ], [ "containerUri and dockerfile are mutually exclusive", [ diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 7e8c25cb5..c187abb96 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,7 +1,7 @@ import { HarnessSpecSchema } from "../../projectSchemas/harness"; -import type { ManagedBy } from "../../projectSchemas/project"; -import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type { ProjectSpecSchema } from "../../projectSchemas/project"; import type z from "zod"; +import type { ProjectRuntimeSchema } from "../../projectSchemas/runtime"; /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { @@ -11,16 +11,6 @@ export const PROJECT_TEMPLATES = { export type ProjectTemplate = (typeof PROJECT_TEMPLATES)[keyof typeof PROJECT_TEMPLATES]; -/** Resources that may be added to an agentcore project **/ -export const PROJECT_RESOURCE_TYPES = { - harness: { schema: HarnessSpecSchema }, -}; - -export type ProjectResource = keyof typeof PROJECT_RESOURCE_TYPES; -export type ProjectResourceConfig = z.input< - (typeof PROJECT_RESOURCE_TYPES)[TResource]["schema"] ->; - export type CreateProjectInput = { /** The name of the project; also the directory it is scaffolded into. */ name: string; @@ -46,12 +36,23 @@ export type Project = { name: string; /** Absolute path to the project root (the parent of agentcore/). */ rootPath: string; - /** The infrastructure backend that owns the project's deployable artifacts. */ - managedBy: ManagedBy; - /** The runtimes registered in agentcore.json. */ - runtimes: ProjectRuntime[]; + /** The spec of the project (agentcore.json loaded into memory) */ + spec: z.infer; }; +/** Discriminated union input for {@link ProjectManager.addResource}. */ +export type AddResourceInput = + | { + resourceType: "harness"; + resourceConfig: z.input; + } + | { + resourceType: "runtime"; + resourceConfig: z.input; + }; + +export type ProjectResource = AddResourceInput["resourceType"]; + /** * The primary interface for interacting with projects */ @@ -66,9 +67,5 @@ export interface ProjectManager { resolve(input: ResolveProjectInput): Promise; /** Add a resource to an existing AgentCore project. */ - addResource( - project: Project, - resourceType: TResource, - resourceConfig: ProjectResourceConfig, - ): AsyncGenerator; + addResource(project: Project, input: AddResourceInput): AsyncGenerator; } diff --git a/src/projectSchemas/harness.ts b/src/projectSchemas/harness.ts index 727d9db6c..fd058fed3 100644 --- a/src/projectSchemas/harness.ts +++ b/src/projectSchemas/harness.ts @@ -371,10 +371,16 @@ export const HarnessTruncationConfigSchema = z } }); export type HarnessTruncationConfig = z.infer; -export const HarnessSkillGitAuthSchema = z.object({ - credentialName: z.string().min(1), - username: z.string().optional(), -}); +export const HarnessSkillGitAuthSchema = z + .object({ + credentialName: z.string().min(1).optional(), + credentialArn: z.string().min(1).optional(), + username: z.string().optional(), + }) + .refine((data) => Boolean(data.credentialName) !== Boolean(data.credentialArn), { + message: "Exactly one of credentialName or credentialArn must be provided", + path: ["credentialName"], + }); export type HarnessSkillGitAuth = z.infer; export const HarnessSkillS3SourceSchema = z .object({ diff --git a/src/testing/TestCoreClient.tsx b/src/testing/TestCoreClient.tsx index 15ad2b854..50047db83 100644 --- a/src/testing/TestCoreClient.tsx +++ b/src/testing/TestCoreClient.tsx @@ -146,6 +146,7 @@ import { abortable } from "../core/abortable"; import type { CoreOptions } from "../core/types"; import type { ProjectManager } from "../handlers/project/types"; import type { Logger } from "../logging"; +import type { ReadWriteJson } from "../io"; import { createSilentLogger } from "./logging"; import { FsProjectManager } from "../core/project"; @@ -1177,6 +1178,7 @@ export class TestGatewayClient implements CoreGatewayClient { type TestCoreClientOptions = { logger?: Logger; + json?: ReadWriteJson; }; export class TestIdentityClient implements CoreIdentityClient { @@ -1948,6 +1950,7 @@ export class TestCoreClient implements Core { constructor(options?: TestCoreClientOptions) { this.projectManager = new FsProjectManager({ logger: options?.logger ?? createSilentLogger(), + json: options?.json, runner: async (command, { cwd }) => { this.projectCommands.push({ command, cwd }); },