From 7dc2d574377553f27fecd7eded75df4c61ea59e2 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Tue, 11 Aug 2026 23:23:53 +0000 Subject: [PATCH 01/16] feat(project): implement add harness scaffolding --- src/core/project/manager.tsx | 18 +- src/handlers/project/add/harness/index.ts | 399 ++++++++++++++++++++++ src/handlers/project/add/index.ts | 20 +- src/handlers/project/add/types.ts | 7 + src/handlers/project/index.ts | 4 +- src/handlers/project/project.test.ts | 243 ++++++++++++- src/handlers/project/types.ts | 19 ++ src/middleware/index.tsx | 1 + 8 files changed, 697 insertions(+), 14 deletions(-) create mode 100644 src/handlers/project/add/harness/index.ts create mode 100644 src/handlers/project/add/types.ts diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index eeaea3ca0..1e601728f 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -6,6 +6,8 @@ import type { Project, ProjectManager, ProjectEvent, + ProjectResource, + ProjectResourceConfig, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { @@ -19,7 +21,12 @@ import { defaultSource, type AssetSource } from "./source"; import { createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; -import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { + DeserializationError, + InputValidationError, + NotImplementedError, + ProjectStateError, +} from "../../errors/errors"; type ProjectManagerConfig = { logger: Logger; @@ -118,6 +125,15 @@ export class FsProjectManager implements ProjectManager { return project; } + // eslint-disable-next-line require-yield + public async *add( + _project: Project, + _resourceType: TResource, + _resourceConfig: ProjectResourceConfig, + ): AsyncGenerator { + throw new NotImplementedError("FsProjectManager.add is not yet implemented"); + } + // Runs a command with its output streamed to the file logger. private run(command: string[], cwd: string): Promise { return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts new file mode 100644 index 000000000..c7a4d2a95 --- /dev/null +++ b/src/handlers/project/add/harness/index.ts @@ -0,0 +1,399 @@ +import z from "zod"; +import { createHandler, flag, ProjectKey } from "../../../../router"; +import type { AddProjectResourceConfig } from "../types"; +import { parseJsonFlag } from "../../../utils"; +import { InputValidationError } from "../../../../errors"; +import type { + AuthorizerConfiguration as SdkAuthorizerConfiguration, + HarnessEnvironmentArtifact, + HarnessEnvironmentProviderRequest, + HarnessMemoryConfiguration as SdkMemoryConfiguration, + HarnessModelConfiguration, + HarnessSkill as SdkHarnessSkill, + HarnessTool as SdkHarnessTool, + HarnessTruncationConfiguration as SdkTruncationConfiguration, +} from "@aws-sdk/client-bedrock-agentcore-control"; +import type { + HarnessMemoryRef, + HarnessModel, + HarnessSkill, + HarnessTool, + HarnessTruncationConfig, + ManagedMemoryStrategy, +} from "../../../../projectSchemas/harness"; +import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; + +export const createAddHarnessHandler = (config: AddProjectResourceConfig) => + createHandler({ + name: "harness", + description: "adds a harness to the active project", + flags: [ + flag("name", "the name of the harness", z.string().optional()), + flag( + "execution-role-arn", + "IAM role the harness assumes; a default role is created when omitted", + 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( + "allowed-tools", + "tool allowlist patterns (e.g. * or @serverName/toolName)", + z.array(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)", + z.string().optional(), + ), + flag( + "environment-variables", + "environment variables (JSON object of key/value strings)", + z.string().optional(), + ), + flag( + "environment-artifact", + "environment artifact configuration (ex. container image) (JSON HarnessEnvironmentArtifact)", + z.string().optional(), + ), + flag( + "authorizer-configuration", + "inbound authorizer configuration (JSON AuthorizerConfiguration)", + 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( + "dockerfile", + "path to local dockerfile to use as the container image for the harness", + 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; + + if (inputArtifact?.containerConfiguration?.containerUri && flags.dockerfile) + throw new InputValidationError(`containerUri and dockerfile are mutually exclusive`); + + const harnessConfig = { + name: flags.name, + model: inputModelConfig + ? toModelConfig(inputModelConfig) + : { provider: "bedrock" as const, modelId: "global.anthropic.claude-sonnet-4-6" }, + systemPrompt: flags["system-prompt"], + executionRoleArn: flags["execution-role-arn"], + tools: inputTools?.map(toTool), + skills: inputSkills?.map(toSkill), + allowedTools: flags["allowed-tools"], + memory: inputMemory ? toMemory(inputMemory) : undefined, + truncation: inputTruncation ? toTruncation(inputTruncation) : undefined, + environmentVariables: parseJsonFlag>( + "environment-variables", + flags["environment-variables"], + ), + authorizerType: inputAuthConfig ? ("CUSTOM_JWT" as const) : undefined, + authorizerConfiguration: inputAuthConfig ? toAuthorizerConfig(inputAuthConfig) : undefined, + maxIterations: flags["max-iterations"], + maxTokens: flags["max-tokens"], + timeoutSeconds: flags["timeout-seconds"], + tags: parseJsonFlag>("tags", flags["tags"]), + networkMode: env?.networkMode, + networkConfig: env?.networkConfig, + lifecycleConfig: env?.lifecycleConfig, + sessionStoragePath: env?.sessionStoragePath, + efsAccessPoints: env?.efsAccessPoints, + s3AccessPoints: env?.s3AccessPoints, + containerUri: artifact?.containerUri, + }; + + const project = ctx.require(ProjectKey); + for await (const event of config.projectManager.add(project, "harness", harnessConfig)) { + config.io.stderr.write(`${event.message}\n`); + } + + config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'`); + }, + }); + +/** 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: c.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: c.agentCoreGateway.gatewayArn! } }, + }; + } + if ("inlineFunction" in c && c.inlineFunction) { + return { + type: tool.type, + name: tool.name, + config: { + inlineFunction: { + description: c.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 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: skill.s3.uri! }; + } + if ("git" in skill && skill.git) { + return { + gitUrl: skill.git.url!, + path: skill.git.path, + auth: skill.git.auth + ? { credentialName: 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: net.networkModeConfig.subnets!, + securityGroups: net.networkModeConfig.securityGroups!, + } + : undefined, + lifecycleConfig: rt.lifecycleConfiguration + ? { + idleRuntimeSessionTimeout: rt.lifecycleConfiguration.idleRuntimeSessionTimeout, + maxLifetime: rt.lifecycleConfiguration.maxLifetime, + } + : undefined, + sessionStoragePath: + sessionStorage && "sessionStorage" in sessionStorage + ? sessionStorage.sessionStorage!.mountPath! + : undefined, + efsAccessPoints: + efsAccessPoints.length > 0 + ? efsAccessPoints.map((f) => { + const efs = "efsAccessPoint" in f ? f.efsAccessPoint! : undefined; + return { accessPointArn: efs!.accessPointArn!, mountPath: efs!.mountPath! }; + }) + : undefined, + s3AccessPoints: + s3AccessPoints.length > 0 + ? s3AccessPoints.map((f) => { + const s3 = "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint! : undefined; + return { accessPointArn: s3!.accessPointArn!, mountPath: s3!.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: artifact.containerConfiguration.containerUri! }; + } + throw new InputValidationError("Unrecognized environment artifact variant"); +} diff --git a/src/handlers/project/add/index.ts b/src/handlers/project/add/index.ts index add436cc5..8545ccba3 100644 --- a/src/handlers/project/add/index.ts +++ b/src/handlers/project/add/index.ts @@ -1,11 +1,11 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import { withProject } from "../../../middleware/"; +import { Router } from "../../../router"; +import { createAddHarnessHandler } from "./harness"; +import type { AddProjectResourceConfig } from "./types"; -export const createAddProjectHandler = () => - createHandler({ - name: "add", - description: "add a resource to the project", - handle: async () => { - throw new NotImplementedError("agentcore project add is not implemented yet"); - }, - }); +export function createAddProjectResourceHandler(config: AddProjectResourceConfig): Router { + const projectAdd = new Router("add", "add project resources"); + projectAdd.use(withProject({ projectManager: config.projectManager, cwd: process.cwd() })); + projectAdd.handler(createAddHarnessHandler(config)); + return projectAdd; +} diff --git a/src/handlers/project/add/types.ts b/src/handlers/project/add/types.ts new file mode 100644 index 000000000..26943b932 --- /dev/null +++ b/src/handlers/project/add/types.ts @@ -0,0 +1,7 @@ +import type { AppIO } from "../../../io"; +import type { ProjectManager } from "../types"; + +export type AddProjectResourceConfig = { + projectManager: ProjectManager; + io: AppIO; +}; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 6c681a32d..c36a84b60 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,13 +1,13 @@ import { Router } from "../../router"; import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; -import { createAddProjectHandler } from "./add"; import { createRemoveProjectHandler } from "./remove"; import { createDevProjectHandler } from "./dev"; import { createDeployProjectHandler } from "./deploy"; import { createStatusProjectHandler } from "./status"; import { createBuildProjectHandler } from "./build"; import type { ProjectManager } from "./types"; +import { createAddProjectResourceHandler } from "./add"; type ProjectHandlerConfig = { projectManager: ProjectManager; @@ -20,7 +20,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { project.handler( createCreateProjectHandler({ projectManager: config.projectManager, io: config.io }), ); - project.handler(createAddProjectHandler()); + project.handler(createAddProjectResourceHandler(config)); project.handler(createRemoveProjectHandler()); project.handler(createDevProjectHandler()); project.handler(createDeployProjectHandler()); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8ca6f537a..88d3f3936 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -9,6 +9,7 @@ import { TestGlobalConfigAccessor, testIO, } from "../../testing"; +import { InputValidationError } from "../../errors"; async function run(args: string[]) { const io = testIO(); @@ -22,7 +23,7 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { +describe.each(["remove", "dev", "deploy", "status", "build"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); @@ -97,3 +98,243 @@ describe("project create", () => { await expect(run(["create", "--name", "MyAgent", "--template", "nonsense"])).rejects.toThrow(); }); }); + +describe("project add harness", () => { + async function scaffoldProject() { + const directory = await inTempDirectory(); + await run(["create", "--name", "TestProject", "--skip-install", "--skip-git"]); + process.chdir(join(directory, "TestProject")); + } + + test.each([ + ["minimal — name only", ["--name", "my-agent"]], + [ + "model — bedrock", + [ + "--name", + "x", + "--model", + '{"bedrockModelConfig":{"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 — gemini", + [ + "--name", + "x", + "--model", + '{"geminiModelConfig":{"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"}}'], + ], + [ + "tools — remote_mcp", + [ + "--name", + "x", + "--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 — agentcore_browser", + [ + "--name", + "x", + "--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 — agentcore_code_interpreter", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_code_interpreter","name":"ci1","config":{"agentCoreCodeInterpreter":{}}}]', + ], + ], + [ + "tools — no config", + ["--name", "x", "--tools", '[{"type":"agentcore_browser","name":"br1"}]'], + ], + [ + "tools — unrecognized config variant (passes through without config)", + [ + "--name", + "x", + "--tools", + '[{"type":"agentcore_browser","name":"br1","config":{"someFutureConfig":{}}}]', + ], + ], + ["skills — path", ["--name", "x", "--skills", '[{"path":"./my-skill"}]']], + ["skills — s3", ["--name", "x", "--skills", '[{"s3":{"uri":"s3://bucket/skill/"}}]']], + [ + "skills — git", + [ + "--name", + "x", + "--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 — awsSkills", + ["--name", "x", "--skills", '[{"awsSkills":{"paths":["core-skills/*"]}}]'], + ], + [ + "memory — managed", + [ + "--name", + "x", + "--memory", + '{"managedMemoryConfiguration":{"strategies":["SEMANTIC"],"eventExpiryDuration":30}}', + ], + ], + [ + "memory — existing", + [ + "--name", + "x", + "--memory", + '{"agentCoreMemoryConfiguration":{"arn":"arn:aws:bedrock-agentcore:us-east-1:123456789012:memory/m"}}', + ], + ], + ["memory — disabled", ["--name", "x", "--memory", '{"disabled":{}}']], + [ + "truncation — sliding_window", + [ + "--name", + "x", + "--truncation", + '{"strategy":"sliding_window","config":{"slidingWindow":{"messagesCount":40}}}', + ], + ], + [ + "truncation — summarization", + [ + "--name", + "x", + "--truncation", + '{"strategy":"summarization","config":{"summarization":{"summaryRatio":0.5,"preserveRecentMessages":5}}}', + ], + ], + ["truncation — none", ["--name", "x", "--truncation", '{"strategy":"none"}']], + [ + "truncation — unrecognized config variant (passes through strategy only)", + ["--name", "x", "--truncation", '{"strategy":"none","config":{"someFutureStrategy":{}}}'], + ], + [ + "authorizer — customJWT", + [ + "--name", + "x", + "--authorizer-configuration", + '{"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-abc"],"securityGroups":["sg-abc"]}},"lifecycleConfiguration":{"idleRuntimeSessionTimeout":900,"maxLifetime":28800}}}', + ], + ], + [ + "environment — with filesystem mounts", + [ + "--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"}}]}}', + ], + ], + [ + "environment-artifact — containerUri", + [ + "--name", + "x", + "--environment-artifact", + '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/my-agent:latest"}}', + ], + ], + ["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"], + ], + ])("%s", async (_label, flags) => { + await scaffoldProject(); + // TODO: update to verify that the project updates. + await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); + }); + + 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":{}}'], + ], + ])("%s", async (_label, flags) => { + await scaffoldProject(); + await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); + }); +}); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 4b0478998..5b8050074 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -1,4 +1,6 @@ +import { HarnessSpecSchema } from "../../projectSchemas/harness"; import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type z from "zod"; /** Available project templates for scaffolding new AgentCore projects. */ export const PROJECT_TEMPLATES = { @@ -8,6 +10,16 @@ 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,4 +58,11 @@ export interface ProjectManager { /** Locate an existing AgentCore project. Returns undefined if no project can be found. */ resolve(input: ResolveProjectInput): Promise; + + /** Add a resource to an existing AgentCore project. */ + add( + project: Project, + resourceType: TResource, + resourceConfig: ProjectResourceConfig, + ): AsyncGenerator; } diff --git a/src/middleware/index.tsx b/src/middleware/index.tsx index e77355898..4838ad322 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,3 +3,4 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withProject } from "./withProject"; From bb093caa0d47c1b8db17840354ba3ce4639a1c1e Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 13 Aug 2026 19:06:26 +0000 Subject: [PATCH 02/16] refactor(test): clean up tests --- src/handlers/project/add/harness/index.ts | 2 +- src/handlers/project/project.test.ts | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index af882f61a..4843ba7ab 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -26,7 +26,7 @@ import type { AuthorizerConfig } from "../../../../projectSchemas/auth"; export const createAddHarnessHandler = (config: AddProjectResourceConfig) => createHandler({ name: "harness", - description: "adds a harness to the active project", + description: "adds a harness to the current project", flags: [ flag("name", "the name of the harness", z.string().optional()), flag( diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 4211adf43..bdd1a2699 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -333,6 +333,17 @@ describe("project add harness", () => { "unrecognized environment-artifact variant", ["--name", "x", "--environment-artifact", '{"unknownArtifact":{}}'], ], + [ + "containerUri and dockerfile are mutually exclusive", + [ + "--name", + "x", + "--environment-artifact", + '{"containerConfiguration":{"containerUri":"123456789012.dkr.ecr.us-east-1.amazonaws.com/img:v1"}}', + "--dockerfile", + "Dockerfile", + ], + ], ])("%s", async (_label, flags) => { await scaffoldProject(); await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); From d9138cdd5b96ff5ec131ebbaf62077b130a1b17c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Thu, 13 Aug 2026 19:11:59 +0000 Subject: [PATCH 03/16] refactor(project): rename add to addResource --- src/core/project/manager.tsx | 4 +- src/handlers/project/add/harness/index.ts | 81 ++++++++++++++++++----- src/handlers/project/project.test.ts | 34 +++++----- src/handlers/project/types.ts | 2 +- 4 files changed, 84 insertions(+), 37 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index e3f3b45d9..dbbce6106 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -131,12 +131,12 @@ export class FsProjectManager implements ProjectManager { } // eslint-disable-next-line require-yield - public async *add( + public async *addResource( _project: Project, _resourceType: TResource, _resourceConfig: ProjectResourceConfig, ): AsyncGenerator { - throw new NotImplementedError("FsProjectManager.add is not yet implemented"); + throw new NotImplementedError("FsProjectManager.addResource is not yet implemented"); } public async *build(project: Project): AsyncGenerator { diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 4843ba7ab..9e98690d1 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -146,11 +146,15 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) => }; const project = ctx.require(ProjectKey); - for await (const event of config.projectManager.add(project, "harness", harnessConfig)) { + for await (const event of config.projectManager.addResource( + project, + "harness", + harnessConfig, + )) { config.io.stderr.write(`${event.message}\n`); } - config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'`); + config.io.stderr.write(`added harness '${flags["name"]}' to '${project.name}'\n`); }, }); @@ -207,7 +211,12 @@ function toTool(tool: SdkHarnessTool): HarnessTool { return { type: tool.type, name: tool.name, - config: { remoteMcp: { url: c.remoteMcp.url!, headers: c.remoteMcp.headers } }, + config: { + remoteMcp: { + url: requireField(c.remoteMcp.url, "remoteMcp.url"), + headers: c.remoteMcp.headers, + }, + }, }; } if ("agentCoreBrowser" in c && c.agentCoreBrowser) { @@ -221,7 +230,11 @@ function toTool(tool: SdkHarnessTool): HarnessTool { return { type: tool.type, name: tool.name, - config: { agentCoreGateway: { gatewayArn: c.agentCoreGateway.gatewayArn! } }, + config: { + agentCoreGateway: { + gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), + }, + }, }; } if ("inlineFunction" in c && c.inlineFunction) { @@ -230,7 +243,7 @@ function toTool(tool: SdkHarnessTool): HarnessTool { name: tool.name, config: { inlineFunction: { - description: c.inlineFunction.description!, + description: requireField(c.inlineFunction.description, "inlineFunction.description"), inputSchema: c.inlineFunction.inputSchema as Record, }, }, @@ -256,14 +269,20 @@ function toSkill(skill: SdkHarnessSkill): HarnessSkill { return { path: skill.path }; } if ("s3" in skill && skill.s3) { - return { s3Uri: skill.s3.uri! }; + return { s3Uri: requireField(skill.s3.uri, "skill.s3.uri") }; } if ("git" in skill && skill.git) { return { - gitUrl: skill.git.url!, + gitUrl: requireField(skill.git.url, "skill.git.url"), path: skill.git.path, auth: skill.git.auth - ? { credentialName: skill.git.auth.credentialArn!, username: skill.git.auth.username } + ? { + credentialName: requireField( + skill.git.auth.credentialArn, + "skill.git.auth.credentialArn", + ), + username: skill.git.auth.username, + } : undefined, }; } @@ -360,8 +379,11 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { networkMode: net?.networkMode as "PUBLIC" | "VPC" | undefined, networkConfig: net?.networkModeConfig ? { - subnets: net.networkModeConfig.subnets!, - securityGroups: net.networkModeConfig.securityGroups!, + subnets: requireField(net.networkModeConfig.subnets, "networkConfiguration.subnets"), + securityGroups: requireField( + net.networkModeConfig.securityGroups, + "networkConfiguration.securityGroups", + ), } : undefined, lifecycleConfig: rt.lifecycleConfiguration @@ -372,20 +394,36 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { : undefined, sessionStoragePath: sessionStorage && "sessionStorage" in sessionStorage - ? sessionStorage.sessionStorage!.mountPath! + ? requireField( + ("sessionStorage" in sessionStorage ? sessionStorage.sessionStorage : undefined) + ?.mountPath, + "sessionStorage.mountPath", + ) : undefined, efsAccessPoints: efsAccessPoints.length > 0 ? efsAccessPoints.map((f) => { - const efs = "efsAccessPoint" in f ? f.efsAccessPoint! : undefined; - return { accessPointArn: efs!.accessPointArn!, mountPath: efs!.mountPath! }; + 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 = "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint! : undefined; - return { accessPointArn: s3!.accessPointArn!, mountPath: s3!.mountPath! }; + const s3 = requireField( + "s3FilesAccessPoint" in f ? f.s3FilesAccessPoint : undefined, + "s3FilesAccessPoint", + ); + return { + accessPointArn: requireField(s3.accessPointArn, "s3FilesAccessPoint.accessPointArn"), + mountPath: requireField(s3.mountPath, "s3FilesAccessPoint.mountPath"), + }; }) : undefined, }; @@ -394,7 +432,18 @@ function toEnvironment(env: HarnessEnvironmentProviderRequest) { /** Decomposes the SDK's environment artifact tagged union into flat HarnessSpec fields. */ function toEnvironmentArtifact(artifact: HarnessEnvironmentArtifact) { if ("containerConfiguration" in artifact && artifact.containerConfiguration) { - return { containerUri: artifact.containerConfiguration.containerUri! }; + 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 bdd1a2699..d9dce3bba 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -48,6 +48,15 @@ afterEach(async () => { ); }); +/** Scaffolds a project and cds into it so withProject resolves it. */ +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 create", () => { test("scaffolds the project into a fresh directory named for the project", async () => { const directory = await inTempDirectory(); @@ -100,12 +109,6 @@ describe("project create", () => { }); describe("project add harness", () => { - async function scaffoldProject() { - const directory = await inTempDirectory(); - await run(["create", "--name", "TestProject", "--skip-install", "--skip-git"]); - process.chdir(join(directory, "TestProject")); - } - test.each([ ["minimal — name only", ["--name", "my-agent"]], [ @@ -298,7 +301,7 @@ describe("project add harness", () => { ["--name", "x", "--max-iterations", "10", "--max-tokens", "4096", "--timeout-seconds", "60"], ], ])("%s", async (_label, flags) => { - await scaffoldProject(); + await inProject(); // TODO: update to verify that the project updates. await expect(run(["add", "harness", ...flags])).rejects.toThrow("not yet implemented"); }); @@ -345,26 +348,21 @@ describe("project add harness", () => { ], ], ])("%s", async (_label, flags) => { - await scaffoldProject(); + await inProject(); await expect(run(["add", "harness", ...flags])).rejects.toBeInstanceOf(InputValidationError); }); }); describe("project build", () => { - // Scaffolds a project, then runs from inside it so withProject resolves it. - async function inProject(): Promise { - const directory = await inTempDirectory(); - await run(["create", "--name", "MyAgent", "--skip-install", "--skip-git"]); - - const projectRoot = join(directory, "MyAgent"); + async function inBuildableProject(): Promise { + const projectRoot = await inProject("MyAgent"); // create --skip-install leaves no node_modules, which build requires. await mkdir(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); - process.chdir(projectRoot); return projectRoot; } test("synthesizes the CDK app of the enclosing project", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); const { io, core } = await run(["build"]); expect(core.projectCommands).toEqual([ @@ -378,7 +376,7 @@ describe("project build", () => { }); test("resolves the project from a nested directory", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); process.chdir(join(projectRoot, "app", "hello-world")); const { core } = await run(["build"]); @@ -394,7 +392,7 @@ describe("project build", () => { }); test("fails when the CDK dependencies have not been installed", async () => { - const projectRoot = await inProject(); + const projectRoot = await inBuildableProject(); await rm(join(projectRoot, "agentcore", "cdk", "node_modules"), { recursive: true }); await expect(run(["build"])).rejects.toThrow(/npm install/); diff --git a/src/handlers/project/types.ts b/src/handlers/project/types.ts index 3a2f266d6..7e8c25cb5 100644 --- a/src/handlers/project/types.ts +++ b/src/handlers/project/types.ts @@ -66,7 +66,7 @@ export interface ProjectManager { resolve(input: ResolveProjectInput): Promise; /** Add a resource to an existing AgentCore project. */ - add( + addResource( project: Project, resourceType: TResource, resourceConfig: ProjectResourceConfig, From 53f7b5d185960b1e001575ee7085772527d6f8ac Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 19:46:03 +0000 Subject: [PATCH 04/16] feat(project): implement full add functionality for harness --- src/core/project/manager.test.ts | 11 +- src/core/project/manager.tsx | 84 +++++++-- src/core/project/templates.ts | 13 ++ src/handlers/project/add/harness/index.ts | 9 +- src/handlers/project/project.test.ts | 205 ++++++++++++++++++++-- src/handlers/project/types.ts | 39 ++-- 6 files changed, 298 insertions(+), 63 deletions(-) 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..6a22a5583 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,13 +1,13 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import type { + AddResourceInput, CreateProjectInput, ResolveProjectInput, Project, ProjectManager, ProjectEvent, ProjectResource, - ProjectResourceConfig, } from "../../handlers/project/types"; import type { Logger } from "../../logging"; import { @@ -18,15 +18,12 @@ 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 { - DeserializationError, - InputValidationError, - NotImplementedError, - ProjectStateError, -} from "../../errors/errors"; +import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import type { HarnessSpec, HarnessSpecSchema } from "../../projectSchemas/harness"; +import type z from "zod"; type ProjectManagerConfig = { logger: Logger; @@ -64,8 +61,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 +126,69 @@ 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 config file from '${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 ${projectSpecKey} with name '${resourceConfig.name}' already exists`, + ); + + const newResources = [...existingResources]; + + switch (resourceType) { + case "harness": { + yield { message: `Scaffolding harness in project` }; + const harnessPath = await this.scaffoldHarness(project.rootPath, input.resourceConfig); + newResources.push({ name: input.resourceConfig.name, path: harnessPath }); + break; + } + // TODO: add a default case to push the resource config. Only runtime/harness and other resources that require non-spec changes should need special casing. + } + + yield { message: `Updating project config file from '${agentCoreSpecPath}'` }; + const newProjectSpec = await this.json.write(agentCoreSpecPath, { + ...existingProjectSpec, + [projectSpecKey]: newResources, + }); + + return { + ...project, + spec: newProjectSpec, + }; + } + + private async scaffoldHarness( + projectRoot: string, + harnessSpec: z.input, + ): Promise { + const outputPath = join(projectRoot, "app", harnessSpec.name); + const harness = await createHarnessTreeFromSpec(harnessSpec as HarnessSpec); + + await harness.write(outputPath); + 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 +222,12 @@ export class FsProjectManager implements ProjectManager { return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) }); } } + +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..682c7758f 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,4 +1,5 @@ import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; +import { HarnessSpecSchema, type HarnessSpec } from "../../projectSchemas/harness"; import { FsTreeNode } from "./fsTree"; import type { AssetSource } from "./source"; @@ -90,3 +91,15 @@ 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: HarnessSpec): Promise { + return FsTreeNode.createDirectory(".", [ + FsTreeNode.createFile("harness.json", async () => json(HarnessSpecSchema.parse(spec))), + FsTreeNode.createFile( + "system-prompt.md", + async () => spec.systemPrompt ?? DEFAULT_HARNESS_SYSTEM_PROMPT, + ), + ]); +} diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 9e98690d1..d67ba115d 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -146,11 +146,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`); } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index d9dce3bba..857663748 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -109,8 +109,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 +121,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 +131,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 +147,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 +168,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 +186,19 @@ 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_browser", @@ -168,6 +208,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 +218,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 +236,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 +259,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 +279,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: { + credentialName: "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 +305,7 @@ describe("project add harness", () => { "--memory", '{"managedMemoryConfiguration":{"strategies":["SEMANTIC"],"eventExpiryDuration":30}}', ], + { memory: { mode: "managed", strategies: ["SEMANTIC"], eventExpiryDuration: 30 } }, ], [ "memory — existing", @@ -232,8 +315,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 +335,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 +350,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 +375,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 +391,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 +408,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 +441,44 @@ 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(projectRoot, "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."); }); test.each([ 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; } From efd9c85524465264d3bbc4a45a17d3640848d24c Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 19:58:11 +0000 Subject: [PATCH 05/16] feat(project): finish add implementation --- src/core/project/manager.tsx | 11 +++++++---- src/core/project/templates.ts | 7 +++++-- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 6a22a5583..b26c66c40 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -22,7 +22,7 @@ import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } f import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; -import type { HarnessSpec, HarnessSpecSchema } from "../../projectSchemas/harness"; +import type { HarnessSpecSchema } from "../../projectSchemas/harness"; import type z from "zod"; type ProjectManagerConfig = { @@ -140,7 +140,7 @@ export class FsProjectManager implements ProjectManager { const existingResources = existingProjectSpec[projectSpecKey]; if (existingResources.find((r) => r.name === resourceConfig.name)) throw new InputValidationError( - `a ${projectSpecKey} with name '${resourceConfig.name}' already exists`, + `a ${resourceType} with name '${resourceConfig.name}' already exists`, ); const newResources = [...existingResources]; @@ -152,7 +152,7 @@ export class FsProjectManager implements ProjectManager { newResources.push({ name: input.resourceConfig.name, path: harnessPath }); break; } - // TODO: add a default case to push the resource config. Only runtime/harness and other resources that require non-spec changes should need special casing. + // TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes. } yield { message: `Updating project config file from '${agentCoreSpecPath}'` }; @@ -172,7 +172,7 @@ export class FsProjectManager implements ProjectManager { harnessSpec: z.input, ): Promise { const outputPath = join(projectRoot, "app", harnessSpec.name); - const harness = await createHarnessTreeFromSpec(harnessSpec as HarnessSpec); + const harness = await createHarnessTreeFromSpec(harnessSpec); await harness.write(outputPath); return outputPath; @@ -223,6 +223,9 @@ export class FsProjectManager implements ProjectManager { } } +/** 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": diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 682c7758f..7be2f2433 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,5 +1,6 @@ +import type z from "zod"; import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types"; -import { HarnessSpecSchema, type HarnessSpec } from "../../projectSchemas/harness"; +import { HarnessSpecSchema } from "../../projectSchemas/harness"; import { FsTreeNode } from "./fsTree"; import type { AssetSource } from "./source"; @@ -94,7 +95,9 @@ export async function createProjectTreeFromTemplate( const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant"; -export async function createHarnessTreeFromSpec(spec: HarnessSpec): Promise { +export async function createHarnessTreeFromSpec( + spec: z.input, +): Promise { return FsTreeNode.createDirectory(".", [ FsTreeNode.createFile("harness.json", async () => json(HarnessSpecSchema.parse(spec))), FsTreeNode.createFile( From 8025178a2fc0a462775ff0fada1a6c196f37a9f0 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 20:44:16 +0000 Subject: [PATCH 06/16] refactor(proj): config --> spec --- src/core/project/manager.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index b26c66c40..aea661807 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -134,7 +134,7 @@ export class FsProjectManager implements ProjectManager { const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json"); const projectSpecKey = toProjectSpecKey(resourceType); - yield { message: `Reading project config file from '${agentCoreSpecPath}'` }; + yield { message: `Reading project spec file at '${agentCoreSpecPath}'` }; const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema); const existingResources = existingProjectSpec[projectSpecKey]; @@ -155,7 +155,7 @@ export class FsProjectManager implements ProjectManager { // TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes. } - yield { message: `Updating project config file from '${agentCoreSpecPath}'` }; + yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; const newProjectSpec = await this.json.write(agentCoreSpecPath, { ...existingProjectSpec, [projectSpecKey]: newResources, From fae3141a6a84c6e4d7cadad937f388fb88961cb5 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 21:31:23 +0000 Subject: [PATCH 07/16] fix(proj): fail runtime early --- src/core/project/manager.tsx | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index aea661807..c12784359 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -21,7 +21,12 @@ import { defaultSource, type AssetSource } from "./source"; import { createHarnessTreeFromSpec, createProjectTreeFromTemplate, TEMPLATES } from "./templates"; import { ProjectSpecSchema } from "../../projectSchemas/project"; import { enclosingProjectRoot } from "./fsUtils"; -import { DeserializationError, InputValidationError, ProjectStateError } from "../../errors/errors"; +import { + DeserializationError, + InputValidationError, + NotImplementedError, + ProjectStateError, +} from "../../errors/errors"; import type { HarnessSpecSchema } from "../../projectSchemas/harness"; import type z from "zod"; @@ -152,6 +157,11 @@ export class FsProjectManager implements ProjectManager { newResources.push({ name: input.resourceConfig.name, path: 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. } From 77b169afa0bf60a8d4562f3fcf5eab65e7cb1b88 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 21:34:14 +0000 Subject: [PATCH 08/16] fix(proj): swap to relative path for harness.json path reference --- src/core/project/manager.tsx | 7 +++++-- src/handlers/project/project.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c12784359..c52b6e80d 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { join, relative } from "node:path"; import type { AddResourceInput, CreateProjectInput, @@ -154,7 +154,10 @@ export class FsProjectManager implements ProjectManager { case "harness": { yield { message: `Scaffolding harness in project` }; const harnessPath = await this.scaffoldHarness(project.rootPath, input.resourceConfig); - newResources.push({ name: input.resourceConfig.name, path: harnessPath }); + newResources.push({ + name: input.resourceConfig.name, + path: relative(project.rootPath, harnessPath), + }); break; } case "runtime": { diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 857663748..23fa64948 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -469,7 +469,7 @@ describe("project add harness", () => { const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); expect(agentcoreJson.harnesses).toContainEqual({ name: "x", - path: join(projectRoot, "app", "x"), + path: "app/x", }); }); From 8722d8470f6c9255a493f47a0b58cdb085b07a74 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 21:51:18 +0000 Subject: [PATCH 09/16] feat(proj): wire up dockerfile support --- src/core/project/manager.tsx | 16 +++++++++++++++- src/handlers/project/project.test.ts | 15 +++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index c52b6e80d..2daf6d54d 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -29,6 +29,7 @@ import { } from "../../errors/errors"; import type { HarnessSpecSchema } from "../../projectSchemas/harness"; import type z from "zod"; +import { copyFile } from "node:fs/promises"; type ProjectManagerConfig = { logger: Logger; @@ -185,9 +186,22 @@ export class FsProjectManager implements ProjectManager { harnessSpec: z.input, ): Promise { const outputPath = join(projectRoot, "app", harnessSpec.name); - const harness = await createHarnessTreeFromSpec(harnessSpec); + + 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; } diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 23fa64948..b5e87105d 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -481,6 +481,21 @@ describe("project add harness", () => { expect(prompt).toBe("You are a pirate."); }); + 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.each([ ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], From ec740c6f68155311247cfad45191d9ee8373ee12 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 22:05:02 +0000 Subject: [PATCH 10/16] fix(proj): wire in gateway outbound auth --- src/handlers/project/add/harness/index.ts | 26 ++++++++++++++++++++ src/handlers/project/project.test.ts | 29 +++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index d67ba115d..5e160f205 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, @@ -232,6 +234,9 @@ function toTool(tool: SdkHarnessTool): HarnessTool { config: { agentCoreGateway: { gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"), + outboundAuth: c.agentCoreGateway.outboundAuth + ? toOutboundAuth(c.agentCoreGateway.outboundAuth) + : undefined, }, }, }; @@ -262,6 +267,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) { diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index b5e87105d..b913cbea2 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -200,6 +200,35 @@ describe("project add harness", () => { ], }, ], + [ + "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", [ From 98c9fd86ccdfb187f61ca9e78abddd1691ed3dd4 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 22:14:21 +0000 Subject: [PATCH 11/16] feat(schemas): add credentialArn for harness skills for non-project credentials --- src/handlers/project/add/harness/index.ts | 2 +- src/handlers/project/project.test.ts | 2 +- src/projectSchemas/harness.ts | 14 ++++++++++---- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 5e160f205..35845eb86 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -302,7 +302,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 b913cbea2..44d974fea 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -314,7 +314,7 @@ describe("project add harness", () => { gitUrl: "https://github.com/org/repo", path: "skills/", auth: { - credentialName: "arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c", + credentialArn: "arn:aws:bedrock-agentcore:us-east-1:123456789012:credential/c", username: "oauth2", }, }, 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({ From ee86add477e4a0794f6d4baa7eea7912e480d36b Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Fri, 14 Aug 2026 22:27:29 +0000 Subject: [PATCH 12/16] fix(harness): add flag for explicit vpc id to support vpc + dockerfile edgecase --- src/core/project/manager.tsx | 2 +- src/core/project/templates.ts | 15 ++++++- src/handlers/project/add/harness/index.ts | 9 +++- src/handlers/project/project.test.ts | 51 +++++++++++++++++++++++ 4 files changed, 73 insertions(+), 4 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index 2daf6d54d..cf35fa95e 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,4 +1,5 @@ import { existsSync } from "node:fs"; +import { copyFile } from "node:fs/promises"; import { join, relative } from "node:path"; import type { AddResourceInput, @@ -29,7 +30,6 @@ import { } from "../../errors/errors"; import type { HarnessSpecSchema } from "../../projectSchemas/harness"; import type z from "zod"; -import { copyFile } from "node:fs/promises"; type ProjectManagerConfig = { logger: Logger; diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 7be2f2433..46ac81bb5 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -1,8 +1,9 @@ -import type z from "zod"; +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[]; @@ -98,11 +99,21 @@ const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant"; export async function createHarnessTreeFromSpec( spec: z.input, ): Promise { + const parsed = parseHarnessSpec(spec); return FsTreeNode.createDirectory(".", [ - FsTreeNode.createFile("harness.json", async () => json(HarnessSpecSchema.parse(spec))), + 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/handlers/project/add/harness/index.ts b/src/handlers/project/add/harness/index.ts index 35845eb86..1c160fa06 100644 --- a/src/handlers/project/add/harness/index.ts +++ b/src/handlers/project/add/harness/index.ts @@ -84,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) @@ -138,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, diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 44d974fea..03e7b6af8 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -525,6 +525,57 @@ describe("project add harness", () => { 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.each([ ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], From 10d631a4625563e232cd7a699b73dbb1364d16b3 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 15 Aug 2026 02:43:05 +0000 Subject: [PATCH 13/16] feat(proj): handle partial failures of harnesss scaffolding --- src/core/project/manager.tsx | 32 ++++++++++++++++++++-------- src/handlers/project/project.test.ts | 27 +++++++++++++++++++++-- src/testing/TestCoreClient.tsx | 3 +++ 3 files changed, 51 insertions(+), 11 deletions(-) diff --git a/src/core/project/manager.tsx b/src/core/project/manager.tsx index cf35fa95e..a10daa1df 100644 --- a/src/core/project/manager.tsx +++ b/src/core/project/manager.tsx @@ -1,5 +1,5 @@ import { existsSync } from "node:fs"; -import { copyFile } from "node:fs/promises"; +import { copyFile, rm } from "node:fs/promises"; import { join, relative } from "node:path"; import type { AddResourceInput, @@ -150,11 +150,13 @@ export class FsProjectManager implements ProjectManager { ); 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), @@ -170,15 +172,27 @@ export class FsProjectManager implements ProjectManager { } yield { message: `Updating project spec file at '${agentCoreSpecPath}'` }; - const newProjectSpec = await this.json.write(agentCoreSpecPath, { - ...existingProjectSpec, - [projectSpecKey]: newResources, - }); - return { - ...project, - spec: newProjectSpec, - }; + // 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( diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 03e7b6af8..13038f45a 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(), @@ -576,6 +578,27 @@ describe("project add harness", () => { ).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([ ["missing --name", ["--model", '{"bedrockModelConfig":{"modelId":"x"}}']], ["model without modelId", ["--name", "x", "--model", '{"bedrockModelConfig":{}}']], 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 }); }, From 22444caca3c0b46c13fbd182e3968989d995f740 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 15 Aug 2026 02:53:19 +0000 Subject: [PATCH 14/16] fix(harness): strip system prompt from config to ensure file is source of truth --- src/core/project/templates.ts | 5 ++++- src/handlers/project/project.test.ts | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/core/project/templates.ts b/src/core/project/templates.ts index 46ac81bb5..661b2db5e 100644 --- a/src/core/project/templates.ts +++ b/src/core/project/templates.ts @@ -99,7 +99,10 @@ const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant"; export async function createHarnessTreeFromSpec( spec: z.input, ): Promise { - const parsed = parseHarnessSpec(spec); + // 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( diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 13038f45a..233f84597 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -510,6 +510,9 @@ describe("project add harness", () => { 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 () => { From 52c734c946853ed141c97829b9ace5342cb567ca Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 15 Aug 2026 02:59:07 +0000 Subject: [PATCH 15/16] fix(test): use path module to build path for windows support --- src/handlers/project/project.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 233f84597..430285995 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -500,7 +500,7 @@ describe("project add harness", () => { const agentcoreJson = await Bun.file(join(projectRoot, "agentcore", "agentcore.json")).json(); expect(agentcoreJson.harnesses).toContainEqual({ name: "x", - path: "app/x", + path: join("app", "x"), }); }); From 05f32683bb1c88a2d7dec59a486698fc4069b613 Mon Sep 17 00:00:00 2001 From: Hweinstock Date: Sat, 15 Aug 2026 03:02:45 +0000 Subject: [PATCH 16/16] test(harness): add case for unrecognized add flags --- src/handlers/project/project.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 430285995..63a4cedee 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -632,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", [