Skip to content

Commit c55046e

Browse files
committed
feat(project): wire dev handler
1 parent 113dcde commit c55046e

26 files changed

Lines changed: 768 additions & 33 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: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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,35 @@ 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+
await expect(collect(missing.runner.run(input(root, projectRuntime)))).rejects.toThrow(
240+
"Unable to resolve AWS credentials for the container",
241+
);
242+
expect(missing.calls).toHaveLength(0);
243+
});
244+
205245
test("preserves an existing build context .dockerignore", async () => {
206246
const projectRuntime = runtime({ buildContextPath: "." });
207247
const root = await projectRoot(projectRuntime);

src/core/dev/container.ts

Lines changed: 42 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createHash } from "node:crypto";
22
import { existsSync, statSync, writeFileSync } from "node:fs";
3+
import { homedir } from "node:os";
34
import { join, resolve } from "node:path";
45
import { InputValidationError, InvalidEnvironmentError } from "../../errors";
56
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
@@ -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> {
@@ -71,6 +87,18 @@ export class ContainerDevRunner implements DevRunner {
7187
throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`);
7288
}
7389

90+
const hasAwsCredentials = Boolean(
91+
(input.env?.AWS_ACCESS_KEY_ID ?? this.processEnv.AWS_ACCESS_KEY_ID) &&
92+
(input.env?.AWS_SECRET_ACCESS_KEY ?? this.processEnv.AWS_SECRET_ACCESS_KEY),
93+
);
94+
const hasAwsConfig = existsSync(this.awsDirectory);
95+
if (!hasAwsCredentials && !hasAwsConfig) {
96+
throw new InputValidationError(
97+
"Unable to resolve AWS credentials for the container. Configure AWS credentials " +
98+
"or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then retry.",
99+
);
100+
}
101+
74102
const tool = await this.resolveContainerTool(input.signal);
75103
input.signal.throwIfAborted();
76104
if (input.runtime.buildContextPath) {
@@ -116,15 +144,23 @@ export class ContainerDevRunner implements DevRunner {
116144
yield { type: "status", message: `Building image with ${tool}` };
117145
yield* this.streamProcess(buildCommand, buildOptions);
118146

119-
const containerPort = portForProtocol(input.runtime.protocol);
120-
const forwardedEnv: Record<string, string> = {
121-
...input.env,
147+
const containerPort = DEV_PORTS[input.runtime.protocol ?? "HTTP"];
148+
const forwardedEnv: Record<string, string> = {};
149+
for (const key of AWS_ENV_KEYS) {
150+
if (this.processEnv[key]) forwardedEnv[key] = this.processEnv[key];
151+
}
152+
Object.assign(forwardedEnv, input.env, {
122153
PORT: String(containerPort),
123154
LOCAL_DEV: "1",
124-
};
155+
});
125156
if (input.runtime.protocol === "MCP") {
126157
forwardedEnv.FASTMCP_PORT = String(containerPort);
127158
}
159+
const awsMount = hasAwsConfig ? ["-v", `${this.awsDirectory}:/aws-config:ro`] : [];
160+
if (awsMount.length) {
161+
forwardedEnv.AWS_CONFIG_FILE = "/aws-config/config";
162+
forwardedEnv.AWS_SHARED_CREDENTIALS_FILE = "/aws-config/credentials";
163+
}
128164
const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [
129165
"-e",
130166
`${key}=${value}`,
@@ -141,6 +177,7 @@ export class ContainerDevRunner implements DevRunner {
141177
containerName,
142178
"-p",
143179
`127.0.0.1:${input.port}:${containerPort}`,
180+
...awsMount,
144181
...envFlags,
145182
imageTag,
146183
];
@@ -158,6 +195,7 @@ export class ContainerDevRunner implements DevRunner {
158195
containerName,
159196
"-p",
160197
`127.0.0.1:${input.port}:${containerPort}`,
198+
...awsMount,
161199
...redactedEnvFlags,
162200
imageTag,
163201
],
@@ -204,12 +242,6 @@ export class ContainerDevRunner implements DevRunner {
204242
}
205243
}
206244

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-
213245
function isDirectory(path: string): boolean {
214246
try {
215247
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 { 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+
await expect(resolveDevPort("A2A", 4567, async () => false, signal)).rejects.toThrow(
40+
"lsof -i :4567",
41+
);
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { InputValidationError } from "../../errors";
2+
import type { ProjectRuntime } from "../project/schema";
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+
function portInUse(port: number, suffix = ""): InputValidationError {
14+
return new InputValidationError(
15+
`Port ${port} is already in use. Find the process with ` +
16+
`'lsof -i :${port}' (macOS/Linux) or 'netstat -ano | findstr :${port}' (Windows), ` +
17+
`then stop it${suffix}.`,
18+
);
19+
}
20+
21+
export async function resolveDevPort(
22+
protocol: ProjectRuntime["protocol"],
23+
explicitPort: number | undefined,
24+
checkPort: PortChecker,
25+
signal: AbortSignal,
26+
): Promise<DevPort> {
27+
const defaultPort = DEV_PORTS[protocol ?? "HTTP"];
28+
const requestedPort = explicitPort ?? defaultPort;
29+
30+
if (await checkPort(requestedPort, signal)) {
31+
return { port: requestedPort, requestedPort };
32+
}
33+
34+
if (explicitPort !== undefined) {
35+
throw portInUse(requestedPort, " or choose a different --port");
36+
}
37+
38+
for (let port = requestedPort + 1; port < requestedPort + MAX_PORT_ATTEMPTS; port++) {
39+
if (await checkPort(port, signal)) return { port, requestedPort };
40+
}
41+
42+
throw new InputValidationError(
43+
`No free port found in range ${requestedPort}-${requestedPort + MAX_PORT_ATTEMPTS - 1}.`,
44+
);
45+
}

src/errors/errors.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError {
135135
}
136136
}
137137

138-
export class RuntimeInvokeInterruptedError extends AgentCoreCLIError {
138+
export class CommandInterruptedError extends AgentCoreCLIError {
139139
readonly reported: boolean;
140140

141141
constructor(cause?: unknown, reported = false) {

src/errors/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export {
22
AgentCoreCLIError,
3+
CommandInterruptedError,
34
DeserializationError,
45
EmbeddedAssetNotFoundError,
56
FileWriteError,
@@ -10,7 +11,6 @@ export {
1011
NotImplementedError,
1112
ProjectFileExistsError,
1213
ResultTruncationError,
13-
RuntimeInvokeInterruptedError,
1414
RuntimeInvokeResponseError,
1515
SourceResolutionError,
1616
type AgentCoreCLIErrorOptions,

0 commit comments

Comments
 (0)