Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
7dc2d57
feat(project): implement add harness scaffolding
Aug 11, 2026
b575a30
chore: merge in refactor branch
Aug 13, 2026
bb093ca
refactor(test): clean up tests
Aug 13, 2026
d9138cd
refactor(project): rename add to addResource
Aug 13, 2026
53f7b5d
feat(project): implement full add functionality for harness
Aug 14, 2026
a80d21c
chore: merge in refactor
Aug 14, 2026
efd9c85
feat(project): finish add implementation
Aug 14, 2026
8025178
refactor(proj): config --> spec
Aug 14, 2026
fae3141
fix(proj): fail runtime early
Aug 14, 2026
77b169a
fix(proj): swap to relative path for harness.json path reference
Aug 14, 2026
8722d84
feat(proj): wire up dockerfile support
Aug 14, 2026
ec740c6
fix(proj): wire in gateway outbound auth
Aug 14, 2026
98c9fd8
feat(schemas): add credentialArn for harness skills for non-project c…
Aug 14, 2026
ee86add
fix(harness): add flag for explicit vpc id to support vpc + dockerfil…
Aug 14, 2026
10d631a
feat(proj): handle partial failures of harnesss scaffolding
Aug 15, 2026
22444ca
fix(harness): strip system prompt from config to ensure file is sourc…
Aug 15, 2026
52c734c
fix(test): use path module to build path for windows support
Aug 15, 2026
05f3268
test(harness): add case for unrecognized add flags
Aug 15, 2026
75cb83a
fix(add): reject vpc id if networkConfig is not present
Aug 17, 2026
ed67907
test(harness): verify duplicate names are rejected
Aug 17, 2026
b92e5e9
fix(add): log failed rollback cleanup errors
Aug 17, 2026
0fcefd8
fix(add): add scaffolded paths before scaffolding
Aug 17, 2026
a95e8d0
docs: add comments above long paramtrized tests
Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions src/core/project/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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([]);
});
Expand Down Expand Up @@ -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 () => {
Expand Down
126 changes: 113 additions & 13 deletions src/core/project/manager.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { existsSync } from "node:fs";
import { join } from "node:path";
import { copyFile, rm } from "node:fs/promises";
import { join, relative } from "node:path";
import type {
AddResourceInput,
CreateProjectInput,
ResolveProjectInput,
Project,
ProjectManager,
ProjectEvent,
ProjectResource,
ProjectResourceConfig,
} from "../../handlers/project/types";
import type { Logger } from "../../logging";
import {
Expand All @@ -18,15 +19,18 @@ 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 {
AgentCoreCLIError,
DeserializationError,
InputValidationError,
NotImplementedError,
ProjectStateError,
} from "../../errors/errors";
import type { HarnessSpecSchema } from "../../projectSchemas/harness";
import type z from "zod";

type ProjectManagerConfig = {
logger: Logger;
Expand Down Expand Up @@ -64,8 +68,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.
Expand Down Expand Up @@ -130,26 +133,111 @@ export class FsProjectManager implements ProjectManager {
return project;
}

// eslint-disable-next-line require-yield
public async *addResource<TResource extends ProjectResource>(
_project: Project,
_resourceType: TResource,
_resourceConfig: ProjectResourceConfig<TResource>,
public async *addResource(
project: Project,
input: AddResourceInput,
): AsyncGenerator<ProjectEvent, Project> {
throw new NotImplementedError("FsProjectManager.addResource is not yet implemented");
const { resourceType, resourceConfig } = input;
const agentCoreSpecPath = join(project.rootPath, "agentcore", "agentcore.json");
const projectSpecKey = toProjectSpecKey(resourceType);

yield { message: `Reading project spec file at '${agentCoreSpecPath}'` };
const existingProjectSpec = await this.json.read(agentCoreSpecPath, ProjectSpecSchema);

const existingResources = existingProjectSpec[projectSpecKey];
if (existingResources.find((r) => r.name === resourceConfig.name))
throw new InputValidationError(
`a ${resourceType} with name '${resourceConfig.name}' already exists`,
);

const newResources = [...existingResources];
const scaffoldedPaths: string[] = [];

switch (resourceType) {
case "harness": {
yield { message: `Scaffolding harness in project` };
const outputPath = join(project.rootPath, "app", resourceConfig.name);
scaffoldedPaths.push(outputPath);
const harnessPath = await this.scaffoldHarness(outputPath, input.resourceConfig);

newResources.push({
name: input.resourceConfig.name,
path: relative(project.rootPath, harnessPath),
});
break;
}
case "runtime": {
throw new NotImplementedError(
"runtime case not yet implemented in FsProjectManager.addResource",
);
}
// TODO: add limited special casing for runtime and default for other resources that proxy directly to spec changes.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we either implement the runtime branch here or leave runtime out of AddResourceInput for now? At the moment a runtime call falls through this switch, emits the Updating project spec message, writes the unchanged runtime list, and returns successfully. That silent success will be hard for callers to diagnose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that's fine right? assuming we are implementing add runtime right after this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Going to be the next PR, but understand the current behavior is confusing. I'll have it throw early.

}

yield { message: `Updating project spec file at '${agentCoreSpecPath}'` };

// rollback scaffolding changes on failed config writes to prevent bad state.
try {
const newProjectSpec = await this.json.write(agentCoreSpecPath, {
...existingProjectSpec,
[projectSpecKey]: newResources,
});

return {
...project,
spec: newProjectSpec,
};
} catch (err) {
this.logger.warn(
`failed to update ${agentCoreSpecPath}; attempting best-effort cleanup of scaffolded files`,
);
await Promise.all(
scaffoldedPaths.map((p) =>
rm(p, { recursive: true, force: true }).catch((e) => {
const error = AgentCoreCLIError.fromError(e);
this.logger
.child({ errorName: error.name, errorMessage: error.message })
.warn(`failed to clean up ${p}`);
}),
),
);
throw err;
}
}

private async scaffoldHarness(
outputPath: string,
harnessSpec: z.input<typeof HarnessSpecSchema>,
): Promise<string> {
const harness = await createHarnessTreeFromSpec({
...harnessSpec,
dockerfile: harnessSpec.dockerfile ? "Dockerfile" : undefined,
});

if (harnessSpec.dockerfile) {
if (!existsSync(harnessSpec.dockerfile))
throw new InputValidationError(`dockerfile not found: '${harnessSpec.dockerfile}'`);
}

await harness.write(outputPath);

if (harnessSpec.dockerfile) {
await copyFile(harnessSpec.dockerfile, join(outputPath, "Dockerfile"));
}
return outputPath;
}

public async *build(project: Project): AsyncGenerator<ProjectEvent, void> {
// 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)}`,
);
Expand Down Expand Up @@ -183,3 +271,15 @@ export class FsProjectManager implements ProjectManager {
return this.runner(command, { cwd, onOutput: (chunk) => this.logger.debug(chunk) });
}
}

/** Map {@link ProjectResource} to keys in the project spec.
* Note: we let TS infer the return type to avoid pulling in keys that do not correspond to resources (ex. name, managedBy, etc.)
*/
function toProjectSpecKey(resourceType: ProjectResource) {
switch (resourceType) {
case "harness":
return "harnesses";
case "runtime":
return "runtimes";
}
}
30 changes: 30 additions & 0 deletions src/core/project/templates.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { ZodError, z } from "zod";
import { PROJECT_TEMPLATES, type ProjectTemplate } from "../../handlers/project/types";
import { HarnessSpecSchema } from "../../projectSchemas/harness";
import { FsTreeNode } from "./fsTree";
import type { AssetSource } from "./source";
import { InputValidationError } from "../../errors/errors";

type TemplateSpec = {
runtimes?: unknown[];
Expand Down Expand Up @@ -90,3 +93,30 @@ export async function createProjectTreeFromTemplate(
FsTreeNode.createDirectory("app", [await FsTreeNode.fromAssetSource(src, assetDir, appDir)]),
]);
}

const DEFAULT_HARNESS_SYSTEM_PROMPT = "You are a helpful assistant";

export async function createHarnessTreeFromSpec(
spec: z.input<typeof HarnessSpecSchema>,
): Promise<FsTreeNode> {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { systemPrompt, ...rest } = spec;
// strip system prompt such that markdown file is source of truth.
const parsed = parseHarnessSpec(rest);
return FsTreeNode.createDirectory(".", [
FsTreeNode.createFile("harness.json", async () => json(parsed)),
FsTreeNode.createFile(
"system-prompt.md",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

are we making the changes on the cdk side as well? resolveSystemPrompt() on there uses the inline systemPrompt first and onlyl reads system-prompt.md when that field is absent. so there are these 2 input sources. did we want that behavior to still exist?

what we are losing here is that there is no way a user will be warned if they make changes in their local md file here if they don't update the value from harness.json.

@Hweinstock Hweinstock Aug 15, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

oh, good callout, was not aware of that behavior. I feel like having two sources of system prompts might be unnecessary and confusing if we scaffold a system-prompt.md for them so I think stripping the system prompt field from the json to maintain backwards compatibility, and treat the file as source of truth might be the simplest path forward.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree!

async () => spec.systemPrompt ?? DEFAULT_HARNESS_SYSTEM_PROMPT,
),
]);
}

function parseHarnessSpec(spec: z.input<typeof HarnessSpecSchema>) {
try {
return HarnessSpecSchema.parse(spec);
} catch (err) {
if (err instanceof ZodError) throw new InputValidationError(z.prettifyError(err));
throw err;
}
}
2 changes: 1 addition & 1 deletion src/core/types.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export type CoreFetch = (
// full ClientConfig so callers can request any client customization (region,
// endpoint, ...).
export interface AwsClients {
control(config: ClientConfig): BedrockAgentCoreControlClient
control(config: ClientConfig): BedrockAgentCoreControlClient;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

perhaps a rebase issue, but this is failing ci on main https://github.com/aws/agentcore-cli/actions/runs/31832012548/job/94869692163.

data(config: ClientConfig): BedrockAgentCoreClient;
iam(config: ClientConfig): IAMClient;
// logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation
Expand Down
4 changes: 3 additions & 1 deletion src/handlers/eval/ondemand/ondemand.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,9 @@ const TRACE: SessionTrace = {
const RESULT: EvaluateResult = {
sessionsRequested: 1,
sessionsEvaluated: 1,
results: [{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number]],

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same as above.

results: [
{ evaluatorId: "Builtin.Helpfulness", value: 0.9 } as EvaluateResult["results"][number],
],
};

async function run(args: string[], configure?: (core: TestCoreClient) => void) {
Expand Down
54 changes: 47 additions & 7 deletions src/handlers/project/add/harness/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ import type {
AuthorizerConfiguration as SdkAuthorizerConfiguration,
HarnessEnvironmentArtifact,
HarnessEnvironmentProviderRequest,
HarnessGatewayOutboundAuth as SdkHarnessGatewayOutboundAuth,
HarnessMemoryConfiguration as SdkMemoryConfiguration,
HarnessModelConfiguration,
HarnessSkill as SdkHarnessSkill,
HarnessTool as SdkHarnessTool,
HarnessTruncationConfiguration as SdkTruncationConfiguration,
} from "@aws-sdk/client-bedrock-agentcore-control";
import type {
HarnessGatewayOutboundAuth,
HarnessMemoryRef,
HarnessModel,
HarnessSkill,
Expand Down Expand Up @@ -82,6 +84,11 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
"path to local dockerfile to use as the container image for the harness",
z.string().optional(),
),
flag(
"vpc-id",
"VPC ID for Dockerfile builds in VPC mode (required when combining --dockerfile with VPC networking)",
z.string().optional(),
),
],
handle: async (ctx, flags) => {
if (!flags.name)
Expand Down Expand Up @@ -110,9 +117,17 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
const env = inputEnvironment ? toEnvironment(inputEnvironment) : undefined;
const artifact = inputArtifact ? toEnvironmentArtifact(inputArtifact) : undefined;

const inputVpcId = flags["vpc-id"];

if (inputArtifact?.containerConfiguration?.containerUri && flags.dockerfile)
throw new InputValidationError(`containerUri and dockerfile are mutually exclusive`);

if (inputVpcId && !env?.networkConfig) {
throw new InputValidationError(
"--vpc-id requires --environment with VPC network configuration",
);
}

const harnessConfig = {
name: flags.name,
model: inputModelConfig
Expand All @@ -136,7 +151,9 @@ export const createAddHarnessHandler = (config: AddProjectResourceConfig) =>
timeoutSeconds: flags["timeout-seconds"],
tags: parseJsonFlag<Record<string, string>>("tags", flags["tags"]),
networkMode: env?.networkMode,
networkConfig: env?.networkConfig,
networkConfig: env?.networkConfig
? { ...env.networkConfig, ...(inputVpcId ? { vpcId: inputVpcId } : {}) }
: undefined,
lifecycleConfig: env?.lifecycleConfig,
sessionStoragePath: env?.sessionStoragePath,
efsAccessPoints: env?.efsAccessPoints,
Expand All @@ -146,11 +163,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, {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is currently no way to add a Dockerfile-backed harness in VPC mode. The environment conversion carries subnets and security groups, but the harness schema also requires networkConfig.vpcId for Dockerfile builds, and this handler has no --vpc-id input. The command always fails validation for that combination. Could we add an explicit VPC ID option and thread it into the resource config?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wow great edge case find, was not aware vpc id was required with VPC + docker. Let me add the explicit flag for this and make it clear.

resourceType: "harness",

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One part of the requested tool configuration gets lost before this call. For an agentcore_gateway tool, toTool copies gatewayArn but drops outboundAuth. For example, asking for { none: {} } produces a harness config with no outbound auth, which changes the behavior back to the default AWS IAM mode. Could we preserve the awsIam, none, and oauth variants?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think @notgitika spotted the same in #1998 (comment). Was going to address as follow-up, but let me bring in here.

resourceConfig: harnessConfig,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Private Git skill auth has a field mismatch here. The SDK input gives us a credentialArn, but toSkill writes that value into credentialName. During synth, CDK treats credentialName as a project credential key, so it cannot resolve the ARN-shaped value and deployment fails. Could we either accept a project credential name for this project command, or carry the ARN separately instead of renaming it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yeah, good catch, I don't think what I have is quite right. It looks like using name here is going against the pattern for harness, since gateway, browser, and memory references all leverage ARN here. AFAICT credential is the only name reference in the input shape. I think we should accept an ARN here to be consistent so I'll add it to the schema, and follow up with the CDK change to support.

})) {
config.io.stderr.write(`${event.message}\n`);
}

Expand Down Expand Up @@ -233,6 +249,9 @@ function toTool(tool: SdkHarnessTool): HarnessTool {
config: {
agentCoreGateway: {
gatewayArn: requireField(c.agentCoreGateway.gatewayArn, "agentCoreGateway.gatewayArn"),
outboundAuth: c.agentCoreGateway.outboundAuth
? toOutboundAuth(c.agentCoreGateway.outboundAuth)
: undefined,
},
},
};
Expand Down Expand Up @@ -263,6 +282,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) {
Expand All @@ -277,7 +317,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",
),
Expand Down
Loading
Loading