Skip to content

Commit b02cc5d

Browse files
committed
feat(project): wire dev handler
1 parent 01eb60b commit b02cc5d

25 files changed

Lines changed: 797 additions & 35 deletions

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@
77
"agentcore": "./dist/index.js"
88
},
99
"main": "./dist/index.js",
10+
"engines": {
11+
"node": ">=20.12.0"
12+
},
1013
"files": [
1114
"dist"
1215
],
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# Environment variables for local development.
2-
# `agentcore dev` loads this file into your agent's process. Values here
3-
# override anything the CLI injects. This file is gitignored — keep secrets
4-
# out of version control, but they are safe here.
2+
# `agentcore project dev` loads this file into your agent's process. Values here
3+
# override injected values except PORT, FASTMCP_PORT, and LOCAL_DEV, which the
4+
# CLI owns. This file is gitignored — keep secrets out of version control.
55
#
66
# Example:
77
# MY_API_KEY=...

src/core/dev/container.test.ts

Lines changed: 70 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { afterEach, describe, expect, test } from "bun:test";
22
import { createHash } from "node:crypto";
3-
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3+
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join, resolve } from "node:path";
66
import { InputValidationError, InvalidEnvironmentError } from "../../errors";
@@ -61,6 +61,8 @@ function harness(
6161
config: {
6262
available?: (tool: string, probeArgs?: string[]) => Promise<boolean>;
6363
stream?: StreamBehavior;
64+
awsDirectory?: string;
65+
processEnv?: NodeJS.ProcessEnv;
6466
} = {},
6567
) {
6668
const calls: ProcessCall[] = [];
@@ -77,6 +79,11 @@ function harness(
7779
(async (tool) => {
7880
return tool === "docker";
7981
}),
82+
awsDirectory: config.awsDirectory ?? join(tmpdir(), "agentcore-container-no-aws"),
83+
processEnv: config.processEnv ?? {
84+
AWS_ACCESS_KEY_ID: "test-access-key",
85+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
86+
},
8087
}),
8188
};
8289
}
@@ -189,6 +196,10 @@ describe("ContainerDevRunner", () => {
189196
"-p",
190197
`127.0.0.1:3000:${containerPort}`,
191198
"-e",
199+
"AWS_ACCESS_KEY_ID=test-access-key",
200+
"-e",
201+
"AWS_SECRET_ACCESS_KEY=test-secret-key",
202+
"-e",
192203
"API_KEY=super-secret",
193204
"-e",
194205
`PORT=${containerPort}`,
@@ -202,6 +213,37 @@ describe("ContainerDevRunner", () => {
202213
expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret");
203214
});
204215

216+
test("uses a shared AWS config and rejects missing credentials", async () => {
217+
const projectRuntime = runtime();
218+
const root = await projectRoot(projectRuntime);
219+
const awsDirectory = join(root, ".aws");
220+
await mkdir(awsDirectory);
221+
await writeFile(join(awsDirectory, "config"), "[profile sandbox]\nregion=us-east-1\n");
222+
const { calls, runner } = harness({
223+
awsDirectory,
224+
processEnv: { AWS_PROFILE: "sandbox", AWS_REGION: "us-east-1" },
225+
});
226+
227+
await collect(runner.run(input(root, projectRuntime)));
228+
229+
const run = commandCall(calls, "run");
230+
expect(run.command).toContain(`${awsDirectory}:/aws-config:ro`);
231+
expect(run.command).toContain("AWS_PROFILE=sandbox");
232+
expect(run.command).toContain("AWS_CONFIG_FILE=/aws-config/config");
233+
expect(run.options.redactedCommand?.join(" ")).not.toContain("sandbox");
234+
235+
const missing = harness({
236+
awsDirectory: join(root, "missing-aws"),
237+
processEnv: {},
238+
});
239+
const missingCredentials = collect(missing.runner.run(input(root, projectRuntime)));
240+
await expect(missingCredentials).rejects.toBeInstanceOf(InvalidEnvironmentError);
241+
await expect(missingCredentials).rejects.toThrow(
242+
"Unable to resolve AWS credentials for the container",
243+
);
244+
expect(missing.calls).toHaveLength(0);
245+
});
246+
205247
test("preserves an existing build context .dockerignore", async () => {
206248
const projectRuntime = runtime({ buildContextPath: "." });
207249
const root = await projectRoot(projectRuntime);
@@ -409,6 +451,33 @@ describe("ContainerDevRunner", () => {
409451
expect(calls.map(({ command }) => command[1])).toEqual(["rm"]);
410452
});
411453

454+
test("rejects build contexts outside the project root, including symlinks", async () => {
455+
const root = await mkdtemp(join(tmpdir(), "agentcore-container-"));
456+
const outside = await mkdtemp(join(tmpdir(), "agentcore-container-outside-"));
457+
tempDirectories.push(root, outside);
458+
await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir");
459+
const probes: string[] = [];
460+
461+
for (const buildContextPath of ["..", "linked"]) {
462+
const { calls, runner } = harness({
463+
available: async (tool) => {
464+
probes.push(tool);
465+
return true;
466+
},
467+
});
468+
469+
const escapedContext = collect(runner.run(input(root, runtime({ buildContextPath }))));
470+
await expect(escapedContext).rejects.toBeInstanceOf(InputValidationError);
471+
await expect(escapedContext).rejects.toThrow(
472+
"container build context must be within the project root",
473+
);
474+
expect(calls).toHaveLength(0);
475+
}
476+
477+
expect(probes).toHaveLength(0);
478+
await expect(readFile(join(outside, ".dockerignore"), "utf8")).rejects.toThrow();
479+
});
480+
412481
test("rejects a build context that is not a directory", async () => {
413482
const root = await mkdtemp(join(tmpdir(), "agentcore-container-"));
414483
tempDirectories.push(root);

src/core/dev/container.ts

Lines changed: 56 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from "node:crypto";
2-
import { existsSync, statSync, writeFileSync } from "node:fs";
3-
import { join, resolve } from "node:path";
2+
import { existsSync, realpathSync, statSync, writeFileSync } from "node:fs";
3+
import { homedir } from "node:os";
4+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
45
import { InputValidationError, InvalidEnvironmentError } from "../../errors";
56
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
67
import {
@@ -10,8 +11,17 @@ import {
1011
type ProcessStreamer,
1112
type StreamProcessOptions,
1213
} from "../../io";
14+
import { DEV_PORTS } from "./port";
1315

1416
const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const;
17+
const AWS_ENV_KEYS = [
18+
"AWS_ACCESS_KEY_ID",
19+
"AWS_SECRET_ACCESS_KEY",
20+
"AWS_SESSION_TOKEN",
21+
"AWS_REGION",
22+
"AWS_DEFAULT_REGION",
23+
"AWS_PROFILE",
24+
] as const;
1525
const CLEANUP_TIMEOUT_MS = 2_000;
1626
const DOCKERFILE_NAME = "Dockerfile";
1727
const CONTAINER_RUNTIME_INSTALL_HINT =
@@ -44,15 +54,21 @@ type ToolAvailable = typeof toolAvailable;
4454
type ContainerDevRunnerConfig = {
4555
streamProcess?: ProcessStreamer;
4656
toolAvailable?: ToolAvailable;
57+
awsDirectory?: string;
58+
processEnv?: NodeJS.ProcessEnv;
4759
};
4860

4961
export class ContainerDevRunner implements DevRunner {
5062
private readonly streamProcess: ProcessStreamer;
5163
private readonly toolAvailable: ToolAvailable;
64+
private readonly awsDirectory: string;
65+
private readonly processEnv: NodeJS.ProcessEnv;
5266

5367
constructor(config: ContainerDevRunnerConfig = {}) {
5468
this.streamProcess = config.streamProcess ?? streamProcess;
5569
this.toolAvailable = config.toolAvailable ?? toolAvailable;
70+
this.awsDirectory = config.awsDirectory ?? join(homedir(), ".aws");
71+
this.processEnv = config.processEnv ?? process.env;
5672
}
5773

5874
public async *run(input: DevServerInput): AsyncGenerator<DevEvent, void> {
@@ -65,12 +81,36 @@ export class ContainerDevRunner implements DevRunner {
6581
throw new InputValidationError(`container build context directory not found: ${context}`);
6682
}
6783

84+
const canonicalContext = realpathSync(context);
85+
const relativeContext = relative(realpathSync(input.projectRoot), canonicalContext);
86+
if (
87+
relativeContext === ".." ||
88+
relativeContext.startsWith(`..${sep}`) ||
89+
isAbsolute(relativeContext)
90+
) {
91+
throw new InputValidationError(
92+
`container build context must be within the project root: ${canonicalContext}`,
93+
);
94+
}
95+
6896
const dockerfile = input.runtime.dockerfile ?? DOCKERFILE_NAME;
6997
const dockerfilePath = join(context, dockerfile);
7098
if (!isFile(dockerfilePath)) {
7199
throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`);
72100
}
73101

102+
const hasAwsCredentials = Boolean(
103+
(input.env?.AWS_ACCESS_KEY_ID ?? this.processEnv.AWS_ACCESS_KEY_ID) &&
104+
(input.env?.AWS_SECRET_ACCESS_KEY ?? this.processEnv.AWS_SECRET_ACCESS_KEY),
105+
);
106+
const hasAwsConfig = existsSync(this.awsDirectory);
107+
if (!hasAwsCredentials && !hasAwsConfig) {
108+
throw new InvalidEnvironmentError(
109+
"Unable to resolve AWS credentials for the container. Configure AWS credentials " +
110+
"or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then retry.",
111+
);
112+
}
113+
74114
const tool = await this.resolveContainerTool(input.signal);
75115
input.signal.throwIfAborted();
76116
if (input.runtime.buildContextPath) {
@@ -116,15 +156,23 @@ export class ContainerDevRunner implements DevRunner {
116156
yield { type: "status", message: `Building image with ${tool}` };
117157
yield* this.streamProcess(buildCommand, buildOptions);
118158

119-
const containerPort = portForProtocol(input.runtime.protocol);
120-
const forwardedEnv: Record<string, string> = {
121-
...input.env,
159+
const containerPort = DEV_PORTS[input.runtime.protocol ?? "HTTP"];
160+
const forwardedEnv: Record<string, string> = {};
161+
for (const key of AWS_ENV_KEYS) {
162+
if (this.processEnv[key]) forwardedEnv[key] = this.processEnv[key];
163+
}
164+
Object.assign(forwardedEnv, input.env, {
122165
PORT: String(containerPort),
123166
LOCAL_DEV: "1",
124-
};
167+
});
125168
if (input.runtime.protocol === "MCP") {
126169
forwardedEnv.FASTMCP_PORT = String(containerPort);
127170
}
171+
const awsMount = hasAwsConfig ? ["-v", `${this.awsDirectory}:/aws-config:ro`] : [];
172+
if (awsMount.length) {
173+
forwardedEnv.AWS_CONFIG_FILE = "/aws-config/config";
174+
forwardedEnv.AWS_SHARED_CREDENTIALS_FILE = "/aws-config/credentials";
175+
}
128176
const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [
129177
"-e",
130178
`${key}=${value}`,
@@ -141,6 +189,7 @@ export class ContainerDevRunner implements DevRunner {
141189
containerName,
142190
"-p",
143191
`127.0.0.1:${input.port}:${containerPort}`,
192+
...awsMount,
144193
...envFlags,
145194
imageTag,
146195
];
@@ -158,6 +207,7 @@ export class ContainerDevRunner implements DevRunner {
158207
containerName,
159208
"-p",
160209
`127.0.0.1:${input.port}:${containerPort}`,
210+
...awsMount,
161211
...redactedEnvFlags,
162212
imageTag,
163213
],
@@ -204,12 +254,6 @@ export class ContainerDevRunner implements DevRunner {
204254
}
205255
}
206256

207-
function portForProtocol(protocol: DevServerInput["runtime"]["protocol"]): number {
208-
if (protocol === "MCP") return 8000;
209-
if (protocol === "A2A") return 9000;
210-
return 8080;
211-
}
212-
213257
function isDirectory(path: string): boolean {
214258
try {
215259
return statSync(path).isDirectory();

src/core/dev/port.test.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { describe, expect, test } from "bun:test";
2+
import type { PortChecker } from "../../io";
3+
import { PortInUseError, resolveDevPort } from "./port";
4+
5+
const signal = new AbortController().signal;
6+
7+
describe("resolveDevPort", () => {
8+
test.each([
9+
["HTTP", 8080],
10+
["AGUI", 8080],
11+
["MCP", 8000],
12+
["A2A", 9000],
13+
] as const)("uses the %s default", async (protocol, port) => {
14+
expect(await resolveDevPort(protocol, undefined, async () => true, signal)).toEqual({
15+
port,
16+
requestedPort: port,
17+
});
18+
});
19+
20+
test("walks up from occupied defaults", async () => {
21+
const checked: number[] = [];
22+
const check: PortChecker = async (port) => {
23+
checked.push(port);
24+
return port === 8002;
25+
};
26+
27+
expect(await resolveDevPort("MCP", undefined, check, signal)).toEqual({
28+
port: 8002,
29+
requestedPort: 8000,
30+
});
31+
expect(checked).toEqual([8000, 8001, 8002]);
32+
});
33+
34+
test("accepts a free explicit port and rejects an occupied one", async () => {
35+
expect(await resolveDevPort("A2A", 4567, async () => true, signal)).toEqual({
36+
port: 4567,
37+
requestedPort: 4567,
38+
});
39+
const occupied = resolveDevPort("A2A", 4567, async () => false, signal);
40+
await expect(occupied).rejects.toBeInstanceOf(PortInUseError);
41+
await expect(occupied).rejects.toThrow("lsof -i :4567");
42+
});
43+
44+
test("bounds the default search", async () => {
45+
await expect(resolveDevPort("HTTP", undefined, async () => false, signal)).rejects.toThrow(
46+
"No free port found in range 8080-8179",
47+
);
48+
});
49+
});

src/core/dev/port.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { InputValidationError } from "../../errors";
2+
import type { ProjectRuntime } from "../../projectSchemas/runtime";
3+
import type { PortChecker } from "../../io";
4+
5+
const MAX_PORT_ATTEMPTS = 100;
6+
export const DEV_PORTS = { HTTP: 8080, AGUI: 8080, MCP: 8000, A2A: 9000 } as const;
7+
8+
export type DevPort = {
9+
port: number;
10+
requestedPort: number;
11+
};
12+
13+
export class PortInUseError extends InputValidationError {
14+
constructor(port: number) {
15+
super(
16+
`Port ${port} is already in use. Find the process with ` +
17+
`'lsof -i :${port}' (macOS/Linux) or 'netstat -ano | findstr :${port}' (Windows), ` +
18+
"then stop it or choose a different --port.",
19+
);
20+
}
21+
}
22+
23+
export async function resolveDevPort(
24+
protocol: ProjectRuntime["protocol"],
25+
explicitPort: number | undefined,
26+
checkPort: PortChecker,
27+
signal: AbortSignal,
28+
): Promise<DevPort> {
29+
const defaultPort = DEV_PORTS[protocol ?? "HTTP"];
30+
const requestedPort = explicitPort ?? defaultPort;
31+
32+
if (await checkPort(requestedPort, signal)) {
33+
return { port: requestedPort, requestedPort };
34+
}
35+
36+
if (explicitPort !== undefined) {
37+
throw new PortInUseError(requestedPort);
38+
}
39+
40+
for (let port = requestedPort + 1; port < requestedPort + MAX_PORT_ATTEMPTS; port++) {
41+
if (await checkPort(port, signal)) return { port, requestedPort };
42+
}
43+
44+
throw new InputValidationError(
45+
`No free port found in range ${requestedPort}-${requestedPort + MAX_PORT_ATTEMPTS - 1}.`,
46+
);
47+
}

src/core/types.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ export type CoreFetch = (
3737
// full ClientConfig so callers can request any client customization (region,
3838
// endpoint, ...).
3939
export interface AwsClients {
40-
control(config: ClientConfig): BedrockAgentCoreControlClient
40+
control(config: ClientConfig): BedrockAgentCoreControlClient;
4141
data(config: ClientConfig): BedrockAgentCoreClient;
4242
iam(config: ClientConfig): IAMClient;
4343
// logs reads the CloudWatch Logs streams AgentCore writes batch-evaluation

0 commit comments

Comments
 (0)