Skip to content

Commit a317a83

Browse files
authored
feat(project): wire dev handler (#1966)
* feat(project): wire dev handler * fix(dev): read runtimes from Project.spec after #2004 * fix(dev): address project dev review feedback
1 parent 2dba459 commit a317a83

24 files changed

Lines changed: 948 additions & 88 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/codezip.test.ts

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { afterEach, describe, expect, test } from "bun:test";
2-
import { mkdir, mkdtemp, rm } from "node:fs/promises";
2+
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
33
import { tmpdir } from "node:os";
4-
import { join } from "node:path";
4+
import { join, relative } from "node:path";
5+
import { InputValidationError } from "../../errors";
56
import type { ProjectRuntime } from "../../projectSchemas/runtime";
67
import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types";
78
import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io";
@@ -21,7 +22,11 @@ afterEach(async () => {
2122
});
2223

2324
function runtime(
24-
overrides: { entrypoint?: string; protocol?: ProjectRuntime["protocol"] } = {},
25+
overrides: {
26+
codeLocation?: string;
27+
entrypoint?: string;
28+
protocol?: ProjectRuntime["protocol"];
29+
} = {},
2530
): ProjectRuntime {
2631
return {
2732
name: "hello_world",
@@ -37,6 +42,12 @@ async function projectRoot(withNodeModules = false): Promise<string> {
3742
const root = await mkdtemp(join(tmpdir(), "agentcore-codezip-"));
3843
tempDirectories.push(root);
3944
await mkdir(join(root, "app", "hello-world"), { recursive: true });
45+
await mkdir(join(root, "app", "hello-world", "src"));
46+
await Promise.all(
47+
["main.py", "index.js", "src/main.py", "src/index.ts"].map((path) =>
48+
writeFile(join(root, "app", "hello-world", path), ""),
49+
),
50+
);
4051
if (withNodeModules) {
4152
await mkdir(join(root, "app", "hello-world", "node_modules"));
4253
}
@@ -81,6 +92,35 @@ describe("CodeZipDevRunner", () => {
8192
);
8293
});
8394

95+
test("rejects code and entrypoint paths outside the project root", async () => {
96+
const root = await projectRoot();
97+
const outside = await mkdtemp(join(tmpdir(), "agentcore-codezip-outside-"));
98+
tempDirectories.push(outside);
99+
await writeFile(join(outside, "main.py"), "");
100+
await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir");
101+
await symlink(
102+
outside,
103+
join(root, "app", "hello-world", "linked"),
104+
process.platform === "win32" ? "junction" : "dir",
105+
);
106+
const directory = join(root, "app", "hello-world");
107+
108+
const unsafeRuntimes = [
109+
runtime({ codeLocation: relative(root, outside) }),
110+
runtime({ codeLocation: "linked" }),
111+
runtime({ entrypoint: relative(directory, join(outside, "main.py")) }),
112+
runtime({ entrypoint: join("linked", "main.py") }),
113+
];
114+
115+
for (const projectRuntime of unsafeRuntimes) {
116+
const { calls, runner } = harness();
117+
const result = collect(runner.run(input(root, projectRuntime)));
118+
await expect(result).rejects.toBeInstanceOf(InputValidationError);
119+
await expect(result).rejects.toThrow("must be within the project root");
120+
expect(calls).toHaveLength(0);
121+
}
122+
});
123+
84124
test("runs HTTP Python entrypoints with uvicorn", async () => {
85125
const root = await projectRoot();
86126
const { calls, runner } = harness([{ type: "stdout", line: "server output" }]);

src/core/dev/codezip.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { existsSync } from "node:fs";
2-
import { join } from "node:path";
2+
import { join, resolve } from "node:path";
33
import { InputValidationError } from "../../errors";
44
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
55
import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io";
6+
import { isDirectory, isFile, resolvePathWithinProject } from "./path";
67

78
type CodeZipDevRunnerConfig = {
89
streamProcess?: ProcessStreamer;
@@ -16,12 +17,19 @@ export class CodeZipDevRunner implements DevRunner {
1617
}
1718

1819
public async *run(input: DevServerInput): AsyncGenerator<DevEvent, void> {
19-
const directory = join(input.projectRoot, input.runtime.codeLocation);
20-
if (!existsSync(directory)) {
20+
const directory = resolve(input.projectRoot, input.runtime.codeLocation);
21+
if (!isDirectory(directory)) {
2122
throw new InputValidationError(`runtime code directory not found: ${directory}`);
2223
}
24+
resolvePathWithinProject(input.projectRoot, directory, "runtime code directory");
2325

2426
const [entrypoint] = input.runtime.entrypoint.split(":");
27+
const entrypointPath = resolve(directory, entrypoint!);
28+
if (!isFile(entrypointPath)) {
29+
throw new InputValidationError(`runtime entrypoint not found: ${entrypointPath}`);
30+
}
31+
resolvePathWithinProject(input.projectRoot, entrypointPath, "runtime entrypoint");
32+
2533
if (!entrypoint!.endsWith(".py") && !existsSync(join(directory, "node_modules"))) {
2634
yield { type: "status", message: "Installing Node dependencies with npm" };
2735
yield* this.streamProcess(["npm", "install"], {

src/core/dev/container.test.ts

Lines changed: 109 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
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, stat, symlink, writeFile } from "node:fs/promises";
44
import { tmpdir } from "node:os";
55
import { join, resolve } from "node:path";
6+
import { parseEnv } from "node:util";
67
import { InputValidationError, InvalidEnvironmentError } from "../../errors";
78
import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types";
89
import {
@@ -17,6 +18,7 @@ import { ContainerDevRunner } from "./container";
1718
type ProcessCall = {
1819
command: string[];
1920
options: StreamProcessOptions;
21+
envFile?: { path: string; contents: string; mode: number };
2022
};
2123

2224
type StreamBehavior = (
@@ -61,11 +63,20 @@ function harness(
6163
config: {
6264
available?: (tool: string, probeArgs?: string[]) => Promise<boolean>;
6365
stream?: StreamBehavior;
66+
awsDirectory?: string;
67+
processEnv?: NodeJS.ProcessEnv;
6468
} = {},
6569
) {
6670
const calls: ProcessCall[] = [];
6771
const fakeStreamProcess: ProcessStreamer = async function* (command, options) {
68-
calls.push({ command, options });
72+
const call: ProcessCall = { command, options };
73+
calls.push(call);
74+
const envFileFlag = command.indexOf("--env-file");
75+
if (envFileFlag >= 0) {
76+
const path = command[envFileFlag + 1]!;
77+
const [contents, metadata] = await Promise.all([readFile(path, "utf8"), stat(path)]);
78+
call.envFile = { path, contents, mode: metadata.mode & 0o777 };
79+
}
6980
if (config.stream) yield* config.stream(command, options);
7081
};
7182
return {
@@ -77,6 +88,11 @@ function harness(
7788
(async (tool) => {
7889
return tool === "docker";
7990
}),
91+
awsDirectory: config.awsDirectory ?? join(tmpdir(), "agentcore-container-no-aws"),
92+
processEnv: config.processEnv ?? {
93+
AWS_ACCESS_KEY_ID: "test-access-key",
94+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
95+
},
8096
}),
8197
};
8298
}
@@ -154,7 +170,10 @@ describe("ContainerDevRunner", () => {
154170
".",
155171
]);
156172
expect(build.options.cwd).toBe(root);
157-
expect(build.options.env).toBe(process.env);
173+
expect(build.options.env).toEqual({
174+
AWS_ACCESS_KEY_ID: "test-access-key",
175+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
176+
});
158177
expect(build.options.redactedCommand).toContain("AGENT_NAME=<redacted>");
159178
expect(build.options.redactedCommand).toContain("TARGET=<redacted>");
160179
expect(build.options.redactedCommand?.join(" ")).not.toContain("hello-world");
@@ -188,18 +207,63 @@ describe("ContainerDevRunner", () => {
188207
containerName(root),
189208
"-p",
190209
`127.0.0.1:3000:${containerPort}`,
191-
"-e",
192-
"API_KEY=super-secret",
193-
"-e",
194-
`PORT=${containerPort}`,
195-
"-e",
196-
"LOCAL_DEV=1",
197-
...(protocol === "MCP" ? ["-e", "FASTMCP_PORT=8000"] : []),
210+
"--env-file",
211+
run.envFile!.path,
198212
imageTag(root),
199213
]);
200-
expect(run.options.env).toBe(process.env);
201-
expect(run.options.redactedCommand).toContain("API_KEY=<redacted>");
202-
expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret");
214+
expect(run.options.env).toEqual({
215+
AWS_ACCESS_KEY_ID: "test-access-key",
216+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
217+
});
218+
expect(parseEnv(run.envFile!.contents)).toEqual({
219+
AWS_ACCESS_KEY_ID: "test-access-key",
220+
AWS_SECRET_ACCESS_KEY: "test-secret-key",
221+
API_KEY: "super-secret",
222+
PORT: String(containerPort),
223+
LOCAL_DEV: "1",
224+
...(protocol === "MCP" ? { FASTMCP_PORT: "8000" } : {}),
225+
});
226+
if (process.platform !== "win32") expect(run.envFile!.mode).toBe(0o600);
227+
await expect(readFile(run.envFile!.path, "utf8")).rejects.toThrow();
228+
expect(run.command.join(" ")).not.toContain("super-secret");
229+
expect(run.command.join(" ")).not.toContain("test-secret-key");
230+
});
231+
232+
test("uses a shared AWS config and rejects missing credentials", async () => {
233+
const projectRuntime = runtime();
234+
const root = await projectRoot(projectRuntime);
235+
const awsDirectory = join(root, ".aws");
236+
await mkdir(awsDirectory);
237+
await writeFile(join(awsDirectory, "config"), "[profile sandbox]\nregion=us-east-1\n");
238+
const { calls, runner } = harness({
239+
awsDirectory,
240+
processEnv: { AWS_PROFILE: "sandbox", AWS_REGION: "us-east-1" },
241+
});
242+
243+
await collect(runner.run(input(root, projectRuntime)));
244+
245+
const run = commandCall(calls, "run");
246+
expect(run.command).toContain(`${awsDirectory}:/aws-config:ro`);
247+
expect(run.command).not.toContain("AWS_PROFILE");
248+
expect(run.command).not.toContain("AWS_CONFIG_FILE");
249+
expect(parseEnv(run.envFile!.contents)).toMatchObject({
250+
AWS_PROFILE: "sandbox",
251+
AWS_REGION: "us-east-1",
252+
AWS_CONFIG_FILE: "/aws-config/config",
253+
AWS_SHARED_CREDENTIALS_FILE: "/aws-config/credentials",
254+
});
255+
expect(run.command.join(" ")).not.toContain("sandbox");
256+
257+
const missing = harness({
258+
awsDirectory: join(root, "missing-aws"),
259+
processEnv: {},
260+
});
261+
const missingCredentials = collect(missing.runner.run(input(root, projectRuntime)));
262+
await expect(missingCredentials).rejects.toBeInstanceOf(InvalidEnvironmentError);
263+
await expect(missingCredentials).rejects.toThrow(
264+
"Unable to resolve AWS credentials for the container",
265+
);
266+
expect(missing.calls).toHaveLength(0);
203267
});
204268

205269
test("preserves an existing build context .dockerignore", async () => {
@@ -244,7 +308,7 @@ describe("ContainerDevRunner", () => {
244308
);
245309
});
246310

247-
test("keeps app variables out of the container CLI environment", async () => {
311+
test("keeps app variables out of the container CLI control environment", async () => {
248312
const projectRuntime = runtime();
249313
const root = await projectRoot(projectRuntime);
250314
const { calls, runner } = harness();
@@ -254,9 +318,10 @@ describe("ContainerDevRunner", () => {
254318
await collect(runner.run(runInput));
255319

256320
const run = commandCall(calls, "run");
257-
expect(run.command).toContain("DOCKER_HOST=tcp://application-value");
258-
expect(run.options.env).toBe(process.env);
259-
expect(run.options.redactedCommand).toContain("DOCKER_HOST=<redacted>");
321+
expect(run.command).not.toContain("DOCKER_HOST");
322+
expect(run.command.join(" ")).not.toContain("tcp://application-value");
323+
expect(run.options.env?.DOCKER_HOST).toBeUndefined();
324+
expect(parseEnv(run.envFile!.contents).DOCKER_HOST).toBe("tcp://application-value");
260325
});
261326

262327
test("selects the first tool that supports container builds", async () => {
@@ -409,6 +474,33 @@ describe("ContainerDevRunner", () => {
409474
expect(calls.map(({ command }) => command[1])).toEqual(["rm"]);
410475
});
411476

477+
test("rejects build contexts outside the project root, including symlinks", async () => {
478+
const root = await mkdtemp(join(tmpdir(), "agentcore-container-"));
479+
const outside = await mkdtemp(join(tmpdir(), "agentcore-container-outside-"));
480+
tempDirectories.push(root, outside);
481+
await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir");
482+
const probes: string[] = [];
483+
484+
for (const buildContextPath of ["..", "linked"]) {
485+
const { calls, runner } = harness({
486+
available: async (tool) => {
487+
probes.push(tool);
488+
return true;
489+
},
490+
});
491+
492+
const escapedContext = collect(runner.run(input(root, runtime({ buildContextPath }))));
493+
await expect(escapedContext).rejects.toBeInstanceOf(InputValidationError);
494+
await expect(escapedContext).rejects.toThrow(
495+
"container build context must be within the project root",
496+
);
497+
expect(calls).toHaveLength(0);
498+
}
499+
500+
expect(probes).toHaveLength(0);
501+
await expect(readFile(join(outside, ".dockerignore"), "utf8")).rejects.toThrow();
502+
});
503+
412504
test("rejects a build context that is not a directory", async () => {
413505
const root = await mkdtemp(join(tmpdir(), "agentcore-container-"));
414506
tempDirectories.push(root);

0 commit comments

Comments
 (0)