diff --git a/src/handlers/project/add/harness/index.test.ts b/src/handlers/project/add/harness/index.test.ts new file mode 100644 index 000000000..6992f2a2d --- /dev/null +++ b/src/handlers/project/add/harness/index.test.ts @@ -0,0 +1,637 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { existsSync } from "node:fs"; +import { mkdtemp, rm } from "node:fs/promises"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { createRootHandler } from "../../../index"; +import { + createSilentLogger, + TestCoreClient, + TestGlobalConfigAccessor, + testIO, +} from "../../../../testing"; +import { DeserializationError, InputValidationError } from "../../../../errors"; +import { FsReadWriteJson, type ReadWriteJson } from "../../../../io"; + +const originalCwd = process.cwd(); +const tempDirectories: string[] = []; + +async function inTempDirectory(): Promise { + const directory = await mkdtemp(join(tmpdir(), "agentcore-harness-")); + tempDirectories.push(directory); + process.chdir(directory); + return process.cwd(); +} + +afterEach(async () => { + process.chdir(originalCwd); + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function run(args: string[], opts?: { core?: TestCoreClient }) { + const io = testIO(); + const core = opts?.core ?? new TestCoreClient(); + const root = createRootHandler(core, { + io: io.io, + globalConfigAccessor: new TestGlobalConfigAccessor(), + logger: createSilentLogger(), + }); + await root.route(["node", "agentcore", "project", ...args]); + return { io, core }; +} + +async function inProject(name = "TestProject"): Promise { + const directory = await inTempDirectory(); + await run(["create", "--name", name, "--skip-install", "--skip-git"]); + const projectRoot = join(directory, name); + process.chdir(projectRoot); + return projectRoot; +} + +describe("project add harness", () => { + 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", + [ + "--name", + "x", + "--model", + '{"provider":"bedrock","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", + [ + "--name", + "x", + "--model", + '{"provider":"open_ai","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", + [ + "--name", + "x", + "--model", + '{"provider":"gemini","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", '{"provider":"lite_llm","modelId":"anthropic/claude-3"}'], + { model: { provider: "lite_llm", modelId: "anthropic/claude-3" } }, + ], + [ + "tools — remote_mcp", + [ + "--name", + "x", + "--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", + [ + "--name", + "x", + "--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", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_browser","name":"br1","config":{"agentCoreBrowser":{}}}]', + ], + { tools: [{ type: "agentcore_browser", name: "br1", config: { agentCoreBrowser: {} } }] }, + ], + [ + "tools — inline_function", + [ + "--name", + "x", + "--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", + [ + "--name", + "x", + "--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" }] }, + ], + [ + "skills — path", + ["--name", "x", "--skills", '[{"path":"./my-skill"}]'], + { skills: [{ path: "./my-skill" }] }, + ], + [ + "skills — s3", + ["--name", "x", "--skills", '[{"s3Uri":"s3://bucket/skill/"}]'], + { skills: [{ s3Uri: "s3://bucket/skill/" }] }, + ], + [ + "skills — git", + [ + "--name", + "x", + "--skills", + '[{"gitUrl":"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/org/repo","path":"skills/","auth":{"credentialArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c","username":"oauth2"}}]', + ], + { + skills: [ + { + gitUrl: "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/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", + [ + "--name", + "x", + "--memory", + '{"mode":"managed","strategies":["SEMANTIC"],"eventExpiryDuration":30}', + ], + { memory: { mode: "managed", strategies: ["SEMANTIC"], eventExpiryDuration: 30 } }, + ], + [ + "memory — existing", + [ + "--name", + "x", + "--memory", + '{"mode":"existing","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", '{"mode":"disabled"}'], + { memory: { mode: "disabled" } }, + ], + [ + "truncation — sliding_window", + [ + "--name", + "x", + "--truncation", + '{"strategy":"sliding_window","config":{"slidingWindow":{"messagesCount":40}}}', + ], + { + truncation: { + strategy: "sliding_window", + config: { slidingWindow: { messagesCount: 40 } }, + }, + }, + ], + [ + "truncation — summarization", + [ + "--name", + "x", + "--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" } }, + ], + [ + "authorizer — customJWT", + [ + "--name", + "x", + "--authorizer-type", + "CUSTOM_JWT", + "--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", + [ + "--name", + "x", + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}', + "--lifecycle-config", + '{"idleRuntimeSessionTimeout":900,"maxLifetime":28800}', + "--container-uri", + "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest", + ], + { + networkMode: "VPC", + networkConfig: { + subnets: ["subnet-0123456789abcdef0"], + securityGroups: ["sg-0123456789abcdef0"], + }, + lifecycleConfig: { idleRuntimeSessionTimeout: 900, maxLifetime: 28800 }, + }, + ], + [ + "environment — with filesystem mounts", + [ + "--name", + "x", + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}', + "--session-storage-path", + "/mnt/data", + "--efs-access-points", + '[{"accessPointArn":"arn:aws:elasticfilesystem:us-east-1:123456789012:access-point/fsap-0123456789abcdef0","mountPath":"/mnt/efs"}]', + "--s3-access-points", + '[{"accessPointArn":"arn:aws:s3files:us-east-1:123456789012:file-system/fs-0123456789abcdef01/access-point/fsap-0123456789abcdef01","mountPath":"/mnt/s3"}]', + "--container-uri", + "123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest", + ], + { + 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", + }, + ], + }, + ], + [ + "container-uri", + [ + "--name", + "x", + "--container-uri", + "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"] }, + ], + [ + "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, 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 vpcId is in network-config", 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, + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"],"vpcId":"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 vpcId in network-config", 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, + "--network-mode", + "VPC", + "--network-config", + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}', + ]), + ).rejects.toBeInstanceOf(InputValidationError); + }); + + test("rejects a duplicate harness name", async () => { + await inProject(); + await run(["add", "harness", "--name", "x"]); + await expect(run(["add", "harness", "--name", "x"])).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 }); + + 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(); + + expect(existsSync(join(projectRoot, "app", "x"))).toBe(false); + }); + + test("rejects when the existing spec is invalid", async () => { + const projectRoot = await inProject(); + + const specPath = join(projectRoot, "agentcore", "agentcore.json"); + const spec = await Bun.file(specPath).json(); + spec.unknownField = "bad"; + await Bun.write(specPath, JSON.stringify(spec)); + + await expect(run(["add", "harness", "--name", "x"])).rejects.toBeInstanceOf( + DeserializationError, + ); + }); + + test.each([ + ["missing --name", ["--model", '{"provider":"bedrock","modelId":"x"}']], + ["model without modelId", ["--name", "x", "--model", '{"provider":"bedrock"}']], + [ + "unrecognized model provider", + ["--name", "x", "--model", '{"provider":"unknown","modelId":"x"}'], + ], + ["tool without type", ["--name", "x", "--tools", '[{"name":"t1"}]']], + ["tool without name", ["--name", "x", "--tools", '[{"type":"remote_mcp"}]']], + ["unrecognized skill variant", ["--name", "x", "--skills", '[{"unknown":true}]']], + ["unrecognized memory variant", ["--name", "x", "--memory", '{"mode":"unknown"}']], + [ + "missing truncation strategy", + ["--name", "x", "--truncation", '{"config":{"slidingWindow":{"messagesCount":10}}}'], + ], + [ + "authorizer config without matching type", + [ + "--name", + "x", + "--authorizer-configuration", + '{"customJwtAuthorizer":{"discoveryUrl":"https://idp.example.com/.well-known/openid-configuration","allowedAudience":["a"]}}', + ], + ], + [ + "missing discoveryUrl in authorizer", + [ + "--name", + "x", + "--authorizer-type", + "CUSTOM_JWT", + "--authorizer-configuration", + '{"customJwtAuthorizer":{"allowedAudience":["a"]}}', + ], + ], + [ + "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", + [ + "--name", + "x", + "--container-uri", + "123456789012.dkr.ecr.us-east-1.amazonaws.com/img:v1", + "--dockerfile", + "Dockerfile", + ], + ], + [ + "networkConfig without VPC mode", + [ + "--name", + "x", + "--network-config", + '{"subnets":["subnet-0123456789abcdef0"],"securityGroups":["sg-0123456789abcdef0"]}', + ], + ], + ])("%s", async (_label, flags) => { + await inProject(); + await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index e1f41a8f1..cf82e7761 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -1,29 +1,14 @@ import z from "zod"; import { createHandler, flag, ProjectKey } from "../../../../router"; import type { AddProjectResourceConfig } from "../types"; -import { parseJsonFlag } from "../../../utils"; +import { parseJsonFlag, parseTags } from "../../../utils"; import { InputValidationError } from "../../../../errors"; -import type { - AuthorizerConfiguration as SdkAuthorizerConfiguration, - HarnessEnvironmentArtifact, - HarnessEnvironmentProviderRequest, - HarnessGatewayOutboundAuth as SdkHarnessGatewayOutboundAuth, - HarnessMemoryConfiguration as SdkMemoryConfiguration, - HarnessModelConfiguration, - HarnessSkill as SdkHarnessSkill, - HarnessTool as SdkHarnessTool, - HarnessTruncationConfiguration as SdkTruncationConfiguration, -} from "@aws-sdk/client-bedrock-agentcore-control"; -import type { - HarnessGatewayOutboundAuth, - HarnessMemoryRef, - HarnessModel, - HarnessSkill, - HarnessTool, - HarnessTruncationConfig, - ManagedMemoryStrategy, -} from "../../../../projectSchemas/harness"; -import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; +import { HarnessSpecSchema } from "../../../../projectSchemas/harness"; + +const DEFAULT_MODEL = { + provider: "bedrock", + modelId: "global.anthropic.claude-sonnet-4-6", +}; export const createAddHarnessHandler = (config: AddProjectResourceConfig) => createHandler({ @@ -37,135 +22,94 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => z.string().optional(), ), flag("system-prompt", "the agent's system prompt", z.string().optional()), - flag("model", "model configuration (JSON HarnessModelConfiguration)", z.string().optional()), - flag("tools", "tools available to the agent (JSON HarnessTool[])", z.string().optional()), - flag("skills", "skills available to the agent (JSON HarnessSkill[])", z.string().optional()), + flag("model", "model configuration (JSON)", z.string().optional()), + flag("tools", "tools available to the agent (JSON)", z.string().optional()), + flag("skills", "skills available to the agent (JSON)", z.string().optional()), flag( "allowed-tools", "tool allowlist patterns (e.g. * or @serverName/toolName)", z.array(z.string()).optional(), ), + flag("memory", "memory configuration (JSON)", z.string().optional()), + flag("truncation", "context truncation configuration (JSON)", z.string().optional()), flag( - "memory", - "memory configuration (JSON HarnessMemoryConfiguration)", - z.string().optional(), - ), - flag( - "truncation", - "context truncation configuration (JSON HarnessTruncationConfiguration)", - z.string().optional(), - ), - flag( - "environment", - "compute environment configuration (JSON HarnessEnvironmentProviderRequest)", + "network-mode", + "network mode for the harness environment (PUBLIC or VPC)", z.string().optional(), ), + flag("network-config", "VPC network configuration (JSON)", z.string().optional()), + flag("lifecycle-config", "lifecycle configuration (JSON)", z.string().optional()), + flag("session-storage-path", "mount path for session storage", z.string().optional()), + flag("efs-access-points", "EFS access point configurations (JSON)", z.string().optional()), + flag("s3-access-points", "S3 access point configurations (JSON)", z.string().optional()), flag( "environment-variables", "environment variables (JSON object of key/value strings)", z.string().optional(), ), + flag("container-uri", "ECR container image URI", z.string().optional()), flag( - "environment-artifact", - "environment artifact configuration (ex. container image) (JSON HarnessEnvironmentArtifact)", + "authorizer-type", + "inbound authorizer type (AWS_IAM or CUSTOM_JWT)", z.string().optional(), ), flag( "authorizer-configuration", - "inbound authorizer configuration (JSON AuthorizerConfiguration)", + "inbound authorizer configuration (JSON)", z.string().optional(), ), flag("max-iterations", "max agent loop iterations per invocation", z.number().optional()), flag("max-tokens", "max total output tokens per invocation", z.number().optional()), flag("timeout-seconds", "max duration in seconds per invocation", z.number().optional()), - flag("tags", "tags to apply (JSON object of key/value strings)", z.string().optional()), + flag("tags", "tags as key=value (repeatable) or JSON object", z.array(z.string()).optional()), flag( "dockerfile", "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) - throw new InputValidationError("required option '--name ' not specified"); - - const inputModelConfig = parseJsonFlag("model", flags["model"]); - const inputTools = parseJsonFlag("tools", flags["tools"]); - const inputSkills = parseJsonFlag("skills", flags["skills"]); - const inputMemory = parseJsonFlag("memory", flags["memory"]); - const inputTruncation = parseJsonFlag( - "truncation", - flags["truncation"], - ); - const inputAuthConfig = parseJsonFlag( - "authorizer-configuration", - flags["authorizer-configuration"], - ); - const inputEnvironment = parseJsonFlag( - "environment", - flags["environment"], - ); - const inputArtifact = parseJsonFlag( - "environment-artifact", - flags["environment-artifact"], - ); - const env = inputEnvironment ? toEnvironment(inputEnvironment) : undefined; - const artifact = inputArtifact ? toEnvironmentArtifact(inputArtifact) : undefined; - - const inputVpcId = flags["vpc-id"]; - - if (inputArtifact?.containerConfiguration?.containerUri && flags.dockerfile) - throw new InputValidationError(`containerUri and dockerfile are mutually exclusive`); - - if (inputVpcId && !env?.networkConfig) { - throw new InputValidationError( - "--vpc-id requires --environment with VPC network configuration", - ); - } - - const harnessConfig = { + const harnessInput = { name: flags.name, - model: inputModelConfig - ? toModelConfig(inputModelConfig) - : { provider: "bedrock" as const, modelId: "global.anthropic.claude-sonnet-4-6" }, + model: parseJsonFlag("model", flags["model"]) ?? DEFAULT_MODEL, systemPrompt: flags["system-prompt"], executionRoleArn: flags["execution-role-arn"], - tools: inputTools?.map(toTool), - skills: inputSkills?.map(toSkill), + tools: parseJsonFlag("tools", flags["tools"]), + skills: parseJsonFlag("skills", flags["skills"]), allowedTools: flags["allowed-tools"], - memory: inputMemory ? toMemory(inputMemory) : undefined, - truncation: inputTruncation ? toTruncation(inputTruncation) : undefined, - environmentVariables: parseJsonFlag>( + memory: parseJsonFlag("memory", flags["memory"]), + truncation: parseJsonFlag("truncation", flags["truncation"]), + networkMode: flags["network-mode"], + networkConfig: parseJsonFlag("network-config", flags["network-config"]), + lifecycleConfig: parseJsonFlag("lifecycle-config", flags["lifecycle-config"]), + sessionStoragePath: flags["session-storage-path"], + efsAccessPoints: parseJsonFlag("efs-access-points", flags["efs-access-points"]), + s3AccessPoints: parseJsonFlag("s3-access-points", flags["s3-access-points"]), + environmentVariables: parseJsonFlag( "environment-variables", flags["environment-variables"], ), - authorizerType: inputAuthConfig ? ("CUSTOM_JWT" as const) : undefined, - authorizerConfiguration: inputAuthConfig ? toAuthorizerConfig(inputAuthConfig) : undefined, + containerUri: flags["container-uri"], + authorizerType: flags["authorizer-type"], + authorizerConfiguration: parseJsonFlag( + "authorizer-configuration", + flags["authorizer-configuration"], + ), maxIterations: flags["max-iterations"], maxTokens: flags["max-tokens"], timeoutSeconds: flags["timeout-seconds"], - tags: parseJsonFlag>("tags", flags["tags"]), - networkMode: env?.networkMode, - networkConfig: env?.networkConfig - ? { ...env.networkConfig, ...(inputVpcId ? { vpcId: inputVpcId } : {}) } - : undefined, - lifecycleConfig: env?.lifecycleConfig, - sessionStoragePath: env?.sessionStoragePath, - efsAccessPoints: env?.efsAccessPoints, - s3AccessPoints: env?.s3AccessPoints, - containerUri: artifact?.containerUri, + tags: parseTags(flags["tags"]), dockerfile: flags["dockerfile"], }; + const result = HarnessSpecSchema.safeParse(harnessInput); + if (!result.success) + throw new InputValidationError(z.prettifyError(result.error), { cause: result.error }); + const project = ctx.require(ProjectKey); for await (const event of config.projectManager.addResource(project, { resourceType: "harness", - resourceConfig: harnessConfig, + resourceConfig: result.data, })) { config.io.stderr.write(`${event.message}\n`); } @@ -173,317 +117,3 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'\n`); }, }); - -/** Converts the SDK's tagged-union model config into the flat project-schema shape. */ -function toModelConfig(modelConfig: HarnessModelConfiguration): HarnessModel { - function commonFields(c: { - modelId?: string; - maxTokens?: number; - temperature?: number; - topP?: number; - additionalParams?: unknown; - }) { - if (!c.modelId) throw new InputValidationError("modelId is required in model configuration"); - return { - modelId: c.modelId, - maxTokens: c.maxTokens, - temperature: c.temperature, - topP: c.topP, - additionalParams: c.additionalParams as Record | undefined, - }; - } - - if ("bedrockModelConfig" in modelConfig && modelConfig.bedrockModelConfig) { - const c = modelConfig.bedrockModelConfig; - return { provider: "bedrock", ...commonFields(c), apiFormat: c.apiFormat }; - } - if ("openAiModelConfig" in modelConfig && modelConfig.openAiModelConfig) { - const c = modelConfig.openAiModelConfig; - return { - provider: "open_ai", - ...commonFields(c), - apiKeyArn: c.apiKeyArn, - apiFormat: c.apiFormat, - }; - } - if ("geminiModelConfig" in modelConfig && modelConfig.geminiModelConfig) { - const c = modelConfig.geminiModelConfig; - return { provider: "gemini", ...commonFields(c), apiKeyArn: c.apiKeyArn, topK: c.topK }; - } - if ("liteLlmModelConfig" in modelConfig && modelConfig.liteLlmModelConfig) { - const c = modelConfig.liteLlmModelConfig; - return { provider: "lite_llm", ...commonFields(c), apiKeyArn: c.apiKeyArn, apiBase: c.apiBase }; - } - throw new InputValidationError("Unrecognized model configuration variant"); -} - -/** Converts an SDK HarnessTool into the flat project-schema shape. */ -function toTool(tool: SdkHarnessTool): HarnessTool { - if (!tool.type) throw new InputValidationError("tool type is required"); - if (!tool.name) throw new InputValidationError(`tool name is required (type: ${tool.type})`); - if (!tool.config) return { type: tool.type, name: tool.name }; - const c = tool.config; - if ("remoteMcp" in c && c.remoteMcp) { - return { - type: tool.type, - name: tool.name, - config: { - remoteMcp: { - url: requireField(c.remoteMcp.url, "remoteMcp.url"), - headers: c.remoteMcp.headers, - }, - }, - }; - } - if ("agentCoreBrowser" in c && c.agentCoreBrowser) { - return { - type: tool.type, - name: tool.name, - config: { agentCoreBrowser: { browserArn: c.agentCoreBrowser.browserArn } }, - }; - } - if ("agentCoreGateway" in c && c.agentCoreGateway) { - return { - type: tool.type, - name: tool.name, - config: { - agentCoreGateway: { - gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), - outboundAuth: c.agentCoreGateway.outboundAuth - ? toOutboundAuth(c.agentCoreGateway.outboundAuth) - : undefined, - }, - }, - }; - } - if ("inlineFunction" in c && c.inlineFunction) { - return { - type: tool.type, - name: tool.name, - config: { - inlineFunction: { - description: requireField(c.inlineFunction.description, "inlineFunction.description"), - inputSchema: c.inlineFunction.inputSchema as Record, - }, - }, - }; - } - if ("agentCoreCodeInterpreter" in c && c.agentCoreCodeInterpreter) { - return { - type: tool.type, - name: tool.name, - config: { - agentCoreCodeInterpreter: { - codeInterpreterArn: c.agentCoreCodeInterpreter.codeInterpreterArn, - }, - }, - }; - } - 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) { - return { path: skill.path }; - } - if ("s3" in skill && skill.s3) { - return { s3Uri: requireField(skill.s3.uri, "skill.s3.uri") }; - } - if ("git" in skill && skill.git) { - return { - gitUrl: requireField(skill.git.url, "skill.git.url"), - path: skill.git.path, - auth: skill.git.auth - ? { - credentialArn: requireField( - skill.git.auth.credentialArn, - "skill.git.auth.credentialArn", - ), - username: skill.git.auth.username, - } - : undefined, - }; - } - if ("awsSkills" in skill && skill.awsSkills) { - return { awsSkills: { paths: skill.awsSkills.paths } }; - } - throw new InputValidationError("Unrecognized skill variant"); -} - -/** Converts an SDK HarnessMemoryConfiguration tagged union into the project-schema shape. */ -function toMemory(memory: SdkMemoryConfiguration): HarnessMemoryRef { - if ("managedMemoryConfiguration" in memory && memory.managedMemoryConfiguration) { - const c = memory.managedMemoryConfiguration; - return { - mode: "managed", - strategies: c.strategies as ManagedMemoryStrategy[] | undefined, - eventExpiryDuration: c.eventExpiryDuration, - encryptionKeyArn: c.encryptionKeyArn, - }; - } - if ("agentCoreMemoryConfiguration" in memory && memory.agentCoreMemoryConfiguration) { - const c = memory.agentCoreMemoryConfiguration; - return { - mode: "existing", - arn: c.arn, - actorId: c.actorId, - messagesCount: c.messagesCount, - }; - } - if ("disabled" in memory && memory.disabled) { - return { mode: "disabled" }; - } - throw new InputValidationError("Unrecognized memory configuration variant"); -} - -/** Converts an SDK HarnessTruncationConfiguration into the project-schema shape. */ -function toTruncation(truncation: SdkTruncationConfiguration): HarnessTruncationConfig { - if (!truncation.strategy) throw new InputValidationError("truncation strategy is required"); - const config = truncation.config; - if (!config) return { strategy: truncation.strategy }; - if ("slidingWindow" in config && config.slidingWindow) { - return { - strategy: truncation.strategy, - config: { slidingWindow: { messagesCount: config.slidingWindow.messagesCount } }, - }; - } - if ("summarization" in config && config.summarization) { - return { - strategy: truncation.strategy, - config: { - summarization: { - summaryRatio: config.summarization.summaryRatio, - preserveRecentMessages: config.summarization.preserveRecentMessages, - summarizationSystemPrompt: config.summarization.summarizationSystemPrompt, - }, - }, - }; - } - return { strategy: truncation.strategy }; -} - -/** Converts an SDK AuthorizerConfiguration tagged union into the project-schema shape. */ -function toAuthorizerConfig(auth: SdkAuthorizerConfiguration): AuthorizerConfig { - if ("customJWTAuthorizer" in auth && auth.customJWTAuthorizer) { - const c = auth.customJWTAuthorizer; - if (!c.discoveryUrl) - throw new InputValidationError("discoveryUrl is required in authorizer configuration"); - return { - customJwtAuthorizer: { - discoveryUrl: c.discoveryUrl, - allowedAudience: c.allowedAudience, - allowedClients: c.allowedClients, - allowedScopes: c.allowedScopes, - }, - }; - } - throw new InputValidationError("Unrecognized authorizer configuration variant"); -} - -/** Decomposes the SDK's environment tagged union into flat HarnessSpec fields. */ -function toEnvironment(env: HarnessEnvironmentProviderRequest) { - if (!("agentCoreRuntimeEnvironment" in env) || !env.agentCoreRuntimeEnvironment) { - throw new InputValidationError("Unrecognized environment configuration variant"); - } - const rt = env.agentCoreRuntimeEnvironment; - const net = rt.networkConfiguration; - const fss = rt.filesystemConfigurations ?? []; - - const sessionStorage = fss.find((f) => "sessionStorage" in f && f.sessionStorage); - const efsAccessPoints = fss.filter((f) => "efsAccessPoint" in f && f.efsAccessPoint); - const s3AccessPoints = fss.filter((f) => "s3FilesAccessPoint" in f && f.s3FilesAccessPoint); - - return { - networkMode: net?.networkMode as "PUBLIC" | "VPC" | undefined, - networkConfig: net?.networkModeConfig - ? { - subnets: requireField(net.networkModeConfig.subnets, "networkConfiguration.subnets"), - securityGroups: requireField( - net.networkModeConfig.securityGroups, - "networkConfiguration.securityGroups", - ), - } - : undefined, - lifecycleConfig: rt.lifecycleConfiguration - ? { - idleRuntimeSessionTimeout: rt.lifecycleConfiguration.idleRuntimeSessionTimeout, - maxLifetime: rt.lifecycleConfiguration.maxLifetime, - } - : undefined, - sessionStoragePath: - sessionStorage && "sessionStorage" in sessionStorage - ? requireField( - ("sessionStorage" in sessionStorage ? sessionStorage.sessionStorage : undefined) - ?.mountPath, - "sessionStorage.mountPath", - ) - : undefined, - efsAccessPoints: - efsAccessPoints.length > 0 - ? efsAccessPoints.map((f) => { - const efs = requireField( - "efsAccessPoint" in f ? f.efsAccessPoint : undefined, - "efsAccessPoint", - ); - return { - accessPointArn: requireField(efs.accessPointArn, "efsAccessPoint.accessPointArn"), - mountPath: requireField(efs.mountPath, "efsAccessPoint.mountPath"), - }; - }) - : undefined, - s3AccessPoints: - s3AccessPoints.length > 0 - ? s3AccessPoints.map((f) => { - const s3 = requireField( - "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint : undefined, - "s3FilesAccessPoint", - ); - return { - accessPointArn: requireField(s3.accessPointArn, "s3FilesAccessPoint.accessPointArn"), - mountPath: requireField(s3.mountPath, "s3FilesAccessPoint.mountPath"), - }; - }) - : undefined, - }; -} - -/** Decomposes the SDK's environment artifact tagged union into flat HarnessSpec fields. */ -function toEnvironmentArtifact(artifact: HarnessEnvironmentArtifact) { - if ("containerConfiguration" in artifact && artifact.containerConfiguration) { - return { - containerUri: requireField( - artifact.containerConfiguration.containerUri, - "containerConfiguration.containerUri", - ), - }; - } - throw new InputValidationError("Unrecognized environment artifact variant"); -} - -/** Validates a required field is present, throwing with context instead of crashing opaquely. */ -function requireField(value: T | undefined | null, field: string): T { - if (value == null) throw new InputValidationError(`${field} is required`); - return value; -} diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 1eadaa7b3..a438260fc 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -10,8 +10,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; -import { DeserializationError, InputValidationError } from "../../errors"; -import { FsReadWriteJson, type ReadWriteJson } from "../../io"; +import { InputValidationError } from "../../errors"; async function run(args: string[], opts?: { core?: TestCoreClient }) { const io = testIO(); @@ -115,580 +114,6 @@ describe("project create", () => { }); }); -describe("project add harness", () => { - const defaultModel = { provider: "bedrock", modelId: "global.anthropic.claude-sonnet-4-6" }; - /** Verify error case for different flags **/ - test.each<[string, string[], Record]>([ - ["minimal — name only", ["--name", "x"], { model: defaultModel }], - [ - "model — bedrock", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--tools", - '[{"type":"agentcore_browser","name":"br1","config":{"agentCoreBrowser":{}}}]', - ], - { tools: [{ type: "agentcore_browser", name: "br1", config: { agentCoreBrowser: {} } }] }, - ], - [ - "tools — inline_function", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--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)", - [ - "--name", - "x", - "--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 — git", - [ - "--name", - "x", - "--skills", - '[{"git":{"url":"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/org/repo","path":"skills/","auth":{"credentialArn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c","username":"oauth2"}}}]', - ], - { - skills: [ - { - gitUrl: "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/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", - [ - "--name", - "x", - "--memory", - '{"managedMemoryConfiguration":{"strategies":["SEMANTIC"],"eventExpiryDuration":30}}', - ], - { memory: { mode: "managed", strategies: ["SEMANTIC"], eventExpiryDuration: 30 } }, - ], - [ - "memory — existing", - [ - "--name", - "x", - "--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" } }, - ], - [ - "truncation — sliding_window", - [ - "--name", - "x", - "--truncation", - '{"strategy":"sliding_window","config":{"slidingWindow":{"messagesCount":40}}}', - ], - { - truncation: { - strategy: "sliding_window", - config: { slidingWindow: { messagesCount: 40 } }, - }, - }, - ], - [ - "truncation — summarization", - [ - "--name", - "x", - "--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 — unrecognized config variant (passes through strategy only)", - ["--name", "x", "--truncation", '{"strategy":"none","config":{"someFutureStrategy":{}}}'], - { truncation: { strategy: "none" } }, - ], - [ - "authorizer — customJWT", - [ - "--name", - "x", - "--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", - [ - "--name", - "x", - "--environment", - '{"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", - [ - "--name", - "x", - "--environment", - '{"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", - [ - "--name", - "x", - "--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"] }, - ], - [ - "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, 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("rejects a duplicate harness name", async () => { - await inProject(); - await run(["add", "harness", "--name", "x"]); - await expect(run(["add", "harness", "--name", "x"])).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("rejects when the existing spec is invalid", async () => { - const projectRoot = await inProject(); - - // create a corrupted agentcore.json - const specPath = join(projectRoot, "agentcore", "agentcore.json"); - const spec = await Bun.file(specPath).json(); - spec.unknownField = "bad"; - await Bun.write(specPath, JSON.stringify(spec)); - - await expect(run(["add", "harness", "--name", "x"])).rejects.toBeInstanceOf( - DeserializationError, - ); - }); - - test.each([ - ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], - ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], - ["unrecognized model variant", ["--name", "x", "--model", '{"unknownConfig":{"modelId":"x"}}']], - ["tool without type", ["--name", "x", "--tools", '[{"name":"t1"}]']], - ["tool without name", ["--name", "x", "--tools", '[{"type":"remote_mcp"}]']], - ["unrecognized skill variant", ["--name", "x", "--skills", '[{"unknown":true}]']], - ["unrecognized memory variant", ["--name", "x", "--memory", '{"unknownMemory":{}}']], - [ - "missing truncation strategy", - ["--name", "x", "--truncation", '{"config":{"slidingWindow":{"messagesCount":10}}}'], - ], - [ - "unrecognized authorizer variant", - ["--name", "x", "--authorizer-configuration", '{"unknownAuth":{}}'], - ], - [ - "missing discoveryUrl in authorizer", - [ - "--name", - "x", - "--authorizer-configuration", - '{"customJWTAuthorizer":{"allowedAudience":["a"]}}', - ], - ], - ["unrecognized environment variant", ["--name", "x", "--environment", '{"unknownEnv":{}}']], - [ - "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", - [ - "--name", - "x", - "--environment-artifact", - '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/img:v1"}}', - "--dockerfile", - "Dockerfile", - ], - ], - [ - "--vpc-id requires --environment with VPC network configuration", - ["--name", "x", "--vpc-id", "vpc-0123456789abcdef0"], - ], - ])("%s", async (_label, flags) => { - await inProject(); - await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); - }); -}); - describe("project add config-bundle", () => { const components = { "arn:aws:bedrock-agentcore:us-east-1:123456789012:runtime/orders-agent": {