From 73ab8870e4aec56c6170a1e2d4df05b05d30d6fc Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 10 Aug 2026 16:40:38 -0400 Subject: [PATCH] feat(project): wire dev handler --- package.json | 3 + .../templates/shared/env.local.template | 6 +- src/core/dev/container.test.ts | 69 +++++- src/core/dev/container.ts | 68 +++++- src/core/dev/port.test.ts | 49 ++++ src/core/dev/port.ts | 45 ++++ src/errors/errors.tsx | 2 +- src/errors/index.tsx | 2 +- src/handlers/project/dev/index.test.ts | 222 ++++++++++++++++++ src/handlers/project/dev/index.ts | 118 +++++++++- src/handlers/project/index.ts | 19 +- src/handlers/project/project.test.ts | 7 +- src/handlers/runtime/invoke/index.tsx | 6 +- src/handlers/runtime/invoke/response.ts | 4 +- src/io/devEnvironment.test.ts | 71 ++++++ src/io/devEnvironment.ts | 65 +++++ src/io/index.ts | 8 + src/io/port.test.ts | 29 +++ src/io/port.ts | 27 +++ src/middleware/withJsonRenderer.tsx | 1 + src/runnable/index.test.ts | 8 +- src/testing/renderScreen.tsx | 2 +- src/tui/index.tsx | 1 + 23 files changed, 798 insertions(+), 34 deletions(-) create mode 100644 src/core/dev/port.test.ts create mode 100644 src/core/dev/port.ts create mode 100644 src/handlers/project/dev/index.test.ts create mode 100644 src/io/devEnvironment.test.ts create mode 100644 src/io/devEnvironment.ts create mode 100644 src/io/port.test.ts create mode 100644 src/io/port.ts diff --git a/package.json b/package.json index 916913ae9..603ae810a 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,9 @@ "agentcore": "./dist/index.js" }, "main": "./dist/index.js", + "engines": { + "node": ">=20.12.0" + }, "files": [ "dist" ], diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template index 30a18b616..cc9e703e7 100644 --- a/src/assets/templates/shared/env.local.template +++ b/src/assets/templates/shared/env.local.template @@ -1,7 +1,7 @@ # Environment variables for local development. -# `agentcore dev` loads this file into your agent's process. Values here -# override anything the CLI injects. This file is gitignored — keep secrets -# out of version control, but they are safe here. +# `agentcore project dev` loads this file into your agent's process. Values here +# override injected values except PORT, FASTMCP_PORT, and LOCAL_DEV, which the +# CLI owns. This file is gitignored — keep secrets out of version control. # # Example: # MY_API_KEY=... diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index 3767c88aa..7ec7c5acc 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createHash } from "node:crypto"; -import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; @@ -11,7 +11,7 @@ import { type ProcessStreamer, type StreamProcessOptions, } from "../../io"; -import type { ProjectRuntime } from "../project/schema"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; import { ContainerDevRunner } from "./container"; type ProcessCall = { @@ -61,6 +61,8 @@ function harness( config: { available?: (tool: string, probeArgs?: string[]) => Promise; stream?: StreamBehavior; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; } = {}, ) { const calls: ProcessCall[] = []; @@ -77,6 +79,11 @@ function harness( (async (tool) => { return tool === "docker"; }), + awsDirectory: config.awsDirectory ?? join(tmpdir(), "agentcore-container-no-aws"), + processEnv: config.processEnv ?? { + AWS_ACCESS_KEY_ID: "test-access-key", + AWS_SECRET_ACCESS_KEY: "test-secret-key", + }, }), }; } @@ -189,6 +196,10 @@ describe("ContainerDevRunner", () => { "-p", `127.0.0.1:3000:${containerPort}`, "-e", + "AWS_ACCESS_KEY_ID=test-access-key", + "-e", + "AWS_SECRET_ACCESS_KEY=test-secret-key", + "-e", "API_KEY=super-secret", "-e", `PORT=${containerPort}`, @@ -202,6 +213,35 @@ describe("ContainerDevRunner", () => { expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret"); }); + test("uses a shared AWS config and rejects missing credentials", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const awsDirectory = join(root, ".aws"); + await mkdir(awsDirectory); + await writeFile(join(awsDirectory, "config"), "[profile sandbox]\nregion=us-east-1\n"); + const { calls, runner } = harness({ + awsDirectory, + processEnv: { AWS_PROFILE: "sandbox", AWS_REGION: "us-east-1" }, + }); + + await collect(runner.run(input(root, projectRuntime))); + + const run = commandCall(calls, "run"); + expect(run.command).toContain(`${awsDirectory}:/aws-config:ro`); + expect(run.command).toContain("AWS_PROFILE=sandbox"); + expect(run.command).toContain("AWS_CONFIG_FILE=/aws-config/config"); + expect(run.options.redactedCommand?.join(" ")).not.toContain("sandbox"); + + const missing = harness({ + awsDirectory: join(root, "missing-aws"), + processEnv: {}, + }); + await expect(collect(missing.runner.run(input(root, projectRuntime)))).rejects.toThrow( + "Unable to resolve AWS credentials for the container", + ); + expect(missing.calls).toHaveLength(0); + }); + test("preserves an existing build context .dockerignore", async () => { const projectRuntime = runtime({ buildContextPath: "." }); const root = await projectRoot(projectRuntime); @@ -409,6 +449,31 @@ describe("ContainerDevRunner", () => { expect(calls.map(({ command }) => command[1])).toEqual(["rm"]); }); + test("rejects build contexts outside the project root, including symlinks", async () => { + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + const outside = await mkdtemp(join(tmpdir(), "agentcore-container-outside-")); + tempDirectories.push(root, outside); + await symlink(outside, join(root, "linked"), process.platform === "win32" ? "junction" : "dir"); + const probes: string[] = []; + + for (const buildContextPath of ["..", "linked"]) { + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + await expect(collect(runner.run(input(root, runtime({ buildContextPath }))))).rejects.toThrow( + "container build context must be within the project root", + ); + expect(calls).toHaveLength(0); + } + + expect(probes).toHaveLength(0); + await expect(readFile(join(outside, ".dockerignore"), "utf8")).rejects.toThrow(); + }); + test("rejects a build context that is not a directory", async () => { const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); tempDirectories.push(root); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts index 720d6e8dd..258d42aa0 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -1,6 +1,7 @@ import { createHash } from "node:crypto"; -import { existsSync, statSync, writeFileSync } from "node:fs"; -import { join, resolve } from "node:path"; +import { existsSync, realpathSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; import { @@ -10,8 +11,17 @@ import { type ProcessStreamer, type StreamProcessOptions, } from "../../io"; +import { DEV_PORTS } from "./port"; const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const; +const AWS_ENV_KEYS = [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_PROFILE", +] as const; const CLEANUP_TIMEOUT_MS = 2_000; const DOCKERFILE_NAME = "Dockerfile"; const CONTAINER_RUNTIME_INSTALL_HINT = @@ -44,15 +54,21 @@ type ToolAvailable = typeof toolAvailable; type ContainerDevRunnerConfig = { streamProcess?: ProcessStreamer; toolAvailable?: ToolAvailable; + awsDirectory?: string; + processEnv?: NodeJS.ProcessEnv; }; export class ContainerDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; private readonly toolAvailable: ToolAvailable; + private readonly awsDirectory: string; + private readonly processEnv: NodeJS.ProcessEnv; constructor(config: ContainerDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; this.toolAvailable = config.toolAvailable ?? toolAvailable; + this.awsDirectory = config.awsDirectory ?? join(homedir(), ".aws"); + this.processEnv = config.processEnv ?? process.env; } public async *run(input: DevServerInput): AsyncGenerator { @@ -65,12 +81,36 @@ export class ContainerDevRunner implements DevRunner { throw new InputValidationError(`container build context directory not found: ${context}`); } + const canonicalContext = realpathSync(context); + const relativeContext = relative(realpathSync(input.projectRoot), canonicalContext); + if ( + relativeContext === ".." || + relativeContext.startsWith(`..${sep}`) || + isAbsolute(relativeContext) + ) { + throw new InputValidationError( + `container build context must be within the project root: ${canonicalContext}`, + ); + } + const dockerfile = input.runtime.dockerfile ?? DOCKERFILE_NAME; const dockerfilePath = join(context, dockerfile); if (!isFile(dockerfilePath)) { throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`); } + const hasAwsCredentials = Boolean( + (input.env?.AWS_ACCESS_KEY_ID ?? this.processEnv.AWS_ACCESS_KEY_ID) && + (input.env?.AWS_SECRET_ACCESS_KEY ?? this.processEnv.AWS_SECRET_ACCESS_KEY), + ); + const hasAwsConfig = existsSync(this.awsDirectory); + if (!hasAwsCredentials && !hasAwsConfig) { + throw new InputValidationError( + "Unable to resolve AWS credentials for the container. Configure AWS credentials " + + "or set AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY, then retry.", + ); + } + const tool = await this.resolveContainerTool(input.signal); input.signal.throwIfAborted(); if (input.runtime.buildContextPath) { @@ -116,15 +156,23 @@ export class ContainerDevRunner implements DevRunner { yield { type: "status", message: `Building image with ${tool}` }; yield* this.streamProcess(buildCommand, buildOptions); - const containerPort = portForProtocol(input.runtime.protocol); - const forwardedEnv: Record = { - ...input.env, + const containerPort = DEV_PORTS[input.runtime.protocol ?? "HTTP"]; + const forwardedEnv: Record = {}; + for (const key of AWS_ENV_KEYS) { + if (this.processEnv[key]) forwardedEnv[key] = this.processEnv[key]; + } + Object.assign(forwardedEnv, input.env, { PORT: String(containerPort), LOCAL_DEV: "1", - }; + }); if (input.runtime.protocol === "MCP") { forwardedEnv.FASTMCP_PORT = String(containerPort); } + const awsMount = hasAwsConfig ? ["-v", `${this.awsDirectory}:/aws-config:ro`] : []; + if (awsMount.length) { + forwardedEnv.AWS_CONFIG_FILE = "/aws-config/config"; + forwardedEnv.AWS_SHARED_CREDENTIALS_FILE = "/aws-config/credentials"; + } const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [ "-e", `${key}=${value}`, @@ -141,6 +189,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...envFlags, imageTag, ]; @@ -158,6 +207,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...redactedEnvFlags, imageTag, ], @@ -204,12 +254,6 @@ export class ContainerDevRunner implements DevRunner { } } -function portForProtocol(protocol: DevServerInput["runtime"]["protocol"]): number { - if (protocol === "MCP") return 8000; - if (protocol === "A2A") return 9000; - return 8080; -} - function isDirectory(path: string): boolean { try { return statSync(path).isDirectory(); diff --git a/src/core/dev/port.test.ts b/src/core/dev/port.test.ts new file mode 100644 index 000000000..78e4cae21 --- /dev/null +++ b/src/core/dev/port.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, test } from "bun:test"; +import type { PortChecker } from "../../io"; +import { resolveDevPort } from "./port"; + +const signal = new AbortController().signal; + +describe("resolveDevPort", () => { + test.each([ + ["HTTP", 8080], + ["AGUI", 8080], + ["MCP", 8000], + ["A2A", 9000], + ] as const)("uses the %s default", async (protocol, port) => { + expect(await resolveDevPort(protocol, undefined, async () => true, signal)).toEqual({ + port, + requestedPort: port, + }); + }); + + test("walks up from occupied defaults", async () => { + const checked: number[] = []; + const check: PortChecker = async (port) => { + checked.push(port); + return port === 8002; + }; + + expect(await resolveDevPort("MCP", undefined, check, signal)).toEqual({ + port: 8002, + requestedPort: 8000, + }); + expect(checked).toEqual([8000, 8001, 8002]); + }); + + test("accepts a free explicit port and rejects an occupied one", async () => { + expect(await resolveDevPort("A2A", 4567, async () => true, signal)).toEqual({ + port: 4567, + requestedPort: 4567, + }); + await expect(resolveDevPort("A2A", 4567, async () => false, signal)).rejects.toThrow( + "lsof -i :4567", + ); + }); + + test("bounds the default search", async () => { + await expect(resolveDevPort("HTTP", undefined, async () => false, signal)).rejects.toThrow( + "No free port found in range 8080-8179", + ); + }); +}); diff --git a/src/core/dev/port.ts b/src/core/dev/port.ts new file mode 100644 index 000000000..165bf86e6 --- /dev/null +++ b/src/core/dev/port.ts @@ -0,0 +1,45 @@ +import { InputValidationError } from "../../errors"; +import type { ProjectRuntime } from "../../projectSchemas/runtime"; +import type { PortChecker } from "../../io"; + +const MAX_PORT_ATTEMPTS = 100; +export const DEV_PORTS = { HTTP: 8080, AGUI: 8080, MCP: 8000, A2A: 9000 } as const; + +export type DevPort = { + port: number; + requestedPort: number; +}; + +function portInUse(port: number, suffix = ""): InputValidationError { + return new InputValidationError( + `Port ${port} is already in use. Find the process with ` + + `'lsof -i :${port}' (macOS/Linux) or 'netstat -ano | findstr :${port}' (Windows), ` + + `then stop it${suffix}.`, + ); +} + +export async function resolveDevPort( + protocol: ProjectRuntime["protocol"], + explicitPort: number | undefined, + checkPort: PortChecker, + signal: AbortSignal, +): Promise { + const defaultPort = DEV_PORTS[protocol ?? "HTTP"]; + const requestedPort = explicitPort ?? defaultPort; + + if (await checkPort(requestedPort, signal)) { + return { port: requestedPort, requestedPort }; + } + + if (explicitPort !== undefined) { + throw portInUse(requestedPort, " or choose a different --port"); + } + + for (let port = requestedPort + 1; port < requestedPort + MAX_PORT_ATTEMPTS; port++) { + if (await checkPort(port, signal)) return { port, requestedPort }; + } + + throw new InputValidationError( + `No free port found in range ${requestedPort}-${requestedPort + MAX_PORT_ATTEMPTS - 1}.`, + ); +} diff --git a/src/errors/errors.tsx b/src/errors/errors.tsx index 262ac8d4d..54bab2402 100644 --- a/src/errors/errors.tsx +++ b/src/errors/errors.tsx @@ -135,7 +135,7 @@ export class EmbeddedAssetNotFoundError extends AgentCoreCLIError { } } -export class RuntimeInvokeInterruptedError extends AgentCoreCLIError { +export class CommandInterruptedError extends AgentCoreCLIError { readonly reported: boolean; constructor(cause?: unknown, reported = false) { diff --git a/src/errors/index.tsx b/src/errors/index.tsx index 6b81494be..f0eb75309 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,5 +1,6 @@ export { AgentCoreCLIError, + CommandInterruptedError, DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, @@ -11,7 +12,6 @@ export { NotImplementedError, ProjectFileExistsError, ResultTruncationError, - RuntimeInvokeInterruptedError, RuntimeInvokeResponseError, SourceResolutionError, type AgentCoreCLIErrorOptions, diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts new file mode 100644 index 000000000..a91d6c5a4 --- /dev/null +++ b/src/handlers/project/dev/index.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, test } from "bun:test"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { InputValidationError } from "../../../errors"; +import type { DevEnvironmentInput, PortChecker } from "../../../io"; +import { ProjectKey, ValueContext } from "../../../router"; +import { testIO } from "../../../testing"; +import { JsonRendererKey } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import { createDevProjectHandler, type DevProjectHandlerConfig } from "."; +import type { DevEvent, DevRunner, DevServerInput } from "./types"; + +function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { + return { + name, + build, + protocol: "HTTP", + entrypoint: "main.py", + codeLocation: `app/${name}`, + } as ProjectRuntime; +} + +function project(...runtimes: ProjectRuntime[]): Project { + return { name: "test-project", rootPath: "/workspace/project", managedBy: "CDK", runtimes }; +} + +function captureRunner(events: DevEvent[] = []) { + const inputs: DevServerInput[] = []; + const runner: DevRunner = { + run: async function* (input) { + inputs.push(input); + yield* events; + }, + }; + return { runner, inputs }; +} + +type HarnessOptions = { + project?: Project; + codeZip?: ReturnType; + container?: ReturnType; + checkPort?: PortChecker; + json?: boolean; + loadEnvironment?: DevProjectHandlerConfig["loadDevEnvironment"]; + forceExit?: () => never; +}; + +function harness(options: HarnessOptions = {}) { + const io = testIO(); + const codeZip = options.codeZip ?? captureRunner(); + const container = options.container ?? captureRunner(); + const environmentInputs: DevEnvironmentInput[] = []; + const handler = createDevProjectHandler({ + io: io.io, + runners: { CodeZip: codeZip.runner, Container: container.runner }, + loadDevEnvironment: + options.loadEnvironment ?? + (async (input) => { + environmentInputs.push(input); + return { env: { FROM_LOADER: "yes" } }; + }), + checkPort: options.checkPort ?? (async () => true), + forceExit: options.forceExit ?? (() => process.exit(130)), + }); + const ctx = ValueContext.EmptyContext() + .withValue(ProjectKey, options.project ?? project(runtime())) + .withValue(JsonKey, options.json ?? false) + .withValue(RegionKey, "us-west-2") + .withValue(JsonRendererKey, { + renderJson: (data) => io.io.stdout.write(`${JSON.stringify(data, null, 2)}\n`), + renderJsonLine: (data) => io.io.stdout.write(`${JSON.stringify(data)}\n`), + }); + + return { + codeZip, + container, + environmentInputs, + io, + run: (flags: { agent?: string; port?: number } = {}) => handler.handle(ctx, flags, {}), + }; +} + +describe("project dev selection and dispatch", () => { + test.each([ + [project(), {}, "This project has no runtimes"], + [ + project(runtime("orders"), runtime("support", "Container")), + {}, + "Use --agent to select one. Available runtimes: orders, support", + ], + [ + project(runtime("orders"), runtime("support", "Container")), + { agent: "missing" }, + "Runtime 'missing' was not found. Available runtimes: orders, support", + ], + ] as const)("rejects invalid runtime selection", async (configuredProject, flags, message) => { + await expect(harness({ project: configuredProject }).run(flags)).rejects.toThrow(message); + }); + + test("loads the environment and dispatches the selected runtime", async () => { + const subject = harness({ + project: project(runtime("orders"), runtime("support", "Container")), + }); + await subject.run({ agent: "support", port: 4567 }); + + expect(subject.codeZip.inputs).toHaveLength(0); + expect(subject.environmentInputs).toEqual([ + { + projectRoot: "/workspace/project", + runtime: expect.objectContaining({ name: "support" }), + region: "us-west-2", + }, + ]); + expect(subject.container.inputs[0]).toMatchObject({ + projectRoot: "/workspace/project", + port: 4567, + env: { FROM_LOADER: "yes" }, + runtime: { name: "support", build: "Container" }, + }); + }); + + test("announces an automatically selected port", async () => { + const checked: number[] = []; + const subject = harness({ + checkPort: async (port) => { + checked.push(port); + return port === 8081; + }, + }); + await subject.run(); + + expect(checked).toEqual([8080, 8081]); + expect(subject.codeZip.inputs[0]?.port).toBe(8081); + expect(subject.io.stderr()).toBe("Port 8080 is in use; using 8081."); + }); +}); + +test("project dev renders human and NDJSON output", async () => { + const events: DevEvent[] = [ + { type: "status", message: "Starting" }, + { type: "stdout", line: "agent output" }, + { type: "stderr", line: "agent warning" }, + ]; + + for (const json of [false, true]) { + const subject = harness({ codeZip: captureRunner(events), json }); + await subject.run(); + expect(subject.io.stdout()).toBe( + json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", + ); + expect(subject.io.stderr()).toBe(json ? "" : "Starting\nagent warning"); + } +}); + +function heldRunner() { + let start!: (input: DevServerInput) => void; + let release: (() => void) | undefined; + const started = new Promise((resolve) => (start = resolve)); + const runner: DevRunner = { + run: async function* (input) { + yield* []; + start(input); + await new Promise((resolve) => (release = resolve)); + input.signal.throwIfAborted(); + }, + }; + return { runner, inputs: [], started, release: () => release?.() }; +} + +describe("project dev interruption", () => { + test.each(["SIGINT", "SIGTERM"] as const)( + "%s aborts, reports exit 130, and removes its listener", + async (signal) => { + const codeZip = heldRunner(); + const before = process.listenerCount(signal); + const subject = harness({ codeZip }); + const pending = subject.run(); + const input = await codeZip.started; + + process.emit(signal, signal); + codeZip.release(); + + expect(input.signal.aborted).toBe(true); + await expect(pending).rejects.toMatchObject({ + name: "AbortError", + reported: true, + exitCode: 130, + }); + expect(subject.io.stderr()).toBe("Shutting down… (press Ctrl-C again to force)"); + expect(process.listenerCount(signal)).toBe(before); + }, + ); + + test("a second signal invokes the force-exit path", async () => { + const codeZip = heldRunner(); + const forceError = new Error("forced exit"); + const subject = harness({ + codeZip, + forceExit: () => { + throw forceError; + }, + }); + const pending = subject.run(); + await codeZip.started; + + process.emit("SIGINT", "SIGINT"); + expect(() => process.emit("SIGINT", "SIGINT")).toThrow(forceError); + codeZip.release(); + await pending.catch(() => undefined); + }); + + test("preserves an ordinary runner failure", async () => { + const failure = new InputValidationError("runner failed"); + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw failure; + }; + + await expect(harness({ codeZip }).run()).rejects.toBe(failure); + }); +}); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 310634651..2ec402cc5 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,11 +1,119 @@ -import { createHandler } from "../../../router"; -import { NotImplementedError } from "../../../errors"; +import z from "zod"; +import { resolveDevPort } from "../../../core/dev/port"; +import type { ProjectRuntime } from "../../../projectSchemas/runtime"; +import { CommandInterruptedError, InputValidationError } from "../../../errors"; +import type { AppIO, DevEnvironmentLoader, PortChecker } from "../../../io"; +import { createHandler, flag, ProjectKey } from "../../../router"; +import { JsonRendererKey, type JsonRenderer } from "../../../tui"; +import { JsonKey, RegionKey } from "../../keys"; +import type { Project } from "../types"; +import type { DevEvent, DevRunner } from "./types"; -export const createDevProjectHandler = () => +export type DevProjectHandlerConfig = { + io: AppIO; + runners: { CodeZip: DevRunner; Container: DevRunner }; + loadDevEnvironment: DevEnvironmentLoader; + checkPort: PortChecker; + forceExit: () => never; +}; + +function selectRuntime(project: Project, name?: string): ProjectRuntime { + if (project.runtimes.length === 0) { + throw new InputValidationError( + "This project has no runtimes. Add a runtime to agentcore/agentcore.json and retry.", + ); + } + const available = project.runtimes.map(({ name }) => name).join(", "); + + if (name) { + const runtime = project.runtimes.find((candidate) => candidate.name === name); + if (runtime) return runtime; + throw new InputValidationError( + `Runtime '${name}' was not found. Available runtimes: ${available}.`, + ); + } + + if (project.runtimes.length === 1) return project.runtimes[0]!; + throw new InputValidationError( + `Multiple runtimes found. Use --agent to select one. Available runtimes: ${available}.`, + ); +} + +function renderEvent(io: AppIO, event: DevEvent, json?: JsonRenderer): void { + if (json) { + json.renderJsonLine(event); + return; + } + + const output = event.type === "stdout" ? io.stdout : io.stderr; + output.write(`${event.type === "status" ? event.message : event.line}\n`); +} + +export const createDevProjectHandler = (config: DevProjectHandlerConfig) => createHandler({ name: "dev", description: "run the project locally for development", - handle: async () => { - throw new NotImplementedError("agentcore project dev is not implemented yet"); + flags: [ + flag("agent", "runtime to run", z.string().optional()), + flag( + "port", + "port for the development server", + z.coerce.number().int().min(1).max(65535).optional(), + ), + ], + handle: async (ctx, flags) => { + const controller = new AbortController(); + const json = ctx.require(JsonKey) ? ctx.require(JsonRendererKey) : undefined; + const interrupt = () => { + if (controller.signal.aborted) config.forceExit(); + config.io.stderr.write("Shutting down… (press Ctrl-C again to force)\n"); + controller.abort(); + }; + + const signals = ["SIGINT", "SIGTERM"] as const; + for (const signal of signals) process.on(signal, interrupt); + try { + const project = ctx.require(ProjectKey); + const runtime = selectRuntime(project, flags.agent); + const devPort = await resolveDevPort( + runtime.protocol, + flags.port, + config.checkPort, + controller.signal, + ); + if (devPort.port !== devPort.requestedPort) { + renderEvent( + config.io, + { + type: "status", + message: `Port ${devPort.requestedPort} is in use; using ${devPort.port}.`, + }, + json, + ); + } + + const { env } = await config.loadDevEnvironment({ + projectRoot: project.rootPath, + runtime, + region: ctx.require(RegionKey), + }); + controller.signal.throwIfAborted(); + + const runner = config.runners[runtime.build]; + for await (const event of runner.run({ + runtime, + projectRoot: project.rootPath, + port: devPort.port, + env, + signal: controller.signal, + })) { + renderEvent(config.io, event, json); + } + } catch (error) { + if (!controller.signal.aborted) throw error; + throw new CommandInterruptedError(error, true); + } finally { + for (const signal of signals) process.removeListener(signal, interrupt); + } }, }); diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 5b3e1a99b..42d090c8d 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,6 +1,8 @@ import { Router } from "../../router"; +import { checkPort, loadDevEnvironment, type AppIO } from "../../io"; +import { CodeZipDevRunner } from "../../core/dev/codezip"; +import { ContainerDevRunner } from "../../core/dev/container"; import { withProject } from "../../middleware"; -import type { AppIO } from "../../io"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; import { createRemoveProjectHandler } from "./remove"; @@ -23,7 +25,20 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { ); project.handler(createAddProjectHandler()); project.handler(createRemoveProjectHandler()); - project.handler(createDevProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager })( + createDevProjectHandler({ + io: config.io, + runners: { + CodeZip: new CodeZipDevRunner(), + Container: new ContainerDevRunner(), + }, + loadDevEnvironment, + checkPort, + forceExit: () => process.exit(130), + }), + ), + ); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); // withProject wraps only the commands that require an existing project, so diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 922d8ca2f..7bd911676 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -22,12 +22,17 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["add", "remove", "dev", "deploy", "status"])("project %s", (command) => { +describe.each(["add", "remove", "deploy", "status"])("project %s", (command) => { test("throws because it is not implemented yet", async () => { await expect(run([command])).rejects.toThrow(/not implemented/); }); }); +test("project dev requires an AgentCore project", async () => { + await inTempDirectory(); + await expect(run(["dev"])).rejects.toThrow(/No AgentCore project found/); +}); + const originalCwd = process.cwd(); const tempDirectories: string[] = []; diff --git a/src/handlers/runtime/invoke/index.tsx b/src/handlers/runtime/invoke/index.tsx index 0110273a3..c7f1d6a58 100644 --- a/src/handlers/runtime/invoke/index.tsx +++ b/src/handlers/runtime/invoke/index.tsx @@ -2,7 +2,7 @@ import z from "zod"; import { InputValidationError, InvalidEnvironmentError, - RuntimeInvokeInterruptedError, + CommandInterruptedError, } from "../../../errors"; import { createHandler, flag, PathKey } from "../../../router"; import type { AppIO } from "../../../io"; @@ -159,8 +159,8 @@ export const createInvokeRuntimeHandler = (core: Core, io: AppIO) => }); } catch (error) { if (controller.signal.aborted && (error as Error)?.name === "AbortError") { - if (error instanceof RuntimeInvokeInterruptedError) throw error; - throw new RuntimeInvokeInterruptedError(error); + if (error instanceof CommandInterruptedError) throw error; + throw new CommandInterruptedError(error); } throw error; } finally { diff --git a/src/handlers/runtime/invoke/response.ts b/src/handlers/runtime/invoke/response.ts index d28af16f6..0c5e0ca9c 100644 --- a/src/handlers/runtime/invoke/response.ts +++ b/src/handlers/runtime/invoke/response.ts @@ -1,6 +1,6 @@ import { createWriteStream } from "node:fs"; import { pipeline } from "node:stream/promises"; -import { RuntimeInvokeInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; +import { CommandInterruptedError, RuntimeInvokeResponseError } from "../../../errors"; import type { RuntimeInvokeResponse } from "../types"; interface RuntimeInvokeOutput { @@ -70,7 +70,7 @@ export async function writeRuntimeInvokeFile( function failure(error: unknown): never { const interrupted = (error as Error)?.name === "AbortError"; - if (interrupted) throw new RuntimeInvokeInterruptedError(error, true); + if (interrupted) throw new CommandInterruptedError(error, true); throw new RuntimeInvokeResponseError(RESPONSE_STREAM_FAILED, error); } diff --git a/src/io/devEnvironment.test.ts b/src/io/devEnvironment.test.ts new file mode 100644 index 000000000..f9027d2cd --- /dev/null +++ b/src/io/devEnvironment.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { join } from "node:path"; +import type { ProjectRuntime } from "../projectSchemas/runtime"; +import { createDevEnvironmentLoader } from "./devEnvironment"; + +const projectRoot = "/workspace/project"; + +const runtime = (envVars: { name: string; value: string }[] = []) => + ({ name: "orders", build: "Container", envVars }) as ProjectRuntime; + +const input = (envVars: { name: string; value: string }[] = []) => ({ + projectRoot, + runtime: runtime(envVars), + region: "us-east-1", +}); + +describe("createDevEnvironmentLoader", () => { + test("merges runtime, region, and .env.local while removing runner-owned keys", async () => { + const loader = createDevEnvironmentLoader({ + readFile: async () => ` +SHARED="local value" +AWS_REGION=local-region +PORT=9999 +FASTMCP_PORT=9998 +LOCAL_DEV=0 +MULTILINE="first +second" +`, + }); + + await expect( + loader( + input([ + { name: "SHARED", value: "runtime" }, + { name: "RUNTIME_ONLY", value: "yes" }, + { name: "PORT", value: "1234" }, + ]), + ), + ).resolves.toEqual({ + env: { + SHARED: "local value", + RUNTIME_ONLY: "yes", + AWS_REGION: "local-region", + MULTILINE: "first\nsecond", + }, + }); + }); + + test.each([ + ["ENOENT", undefined], + [ + "EACCES", + `Unable to read local environment file at ${join(projectRoot, "agentcore", ".env.local")}`, + ], + ] as const)("handles .env.local read error %s", async (code, expectedError) => { + const loader = createDevEnvironmentLoader({ + readFile: async () => { + throw Object.assign(new Error("read failed"), { code }); + }, + }); + + const pending = loader(input([{ name: "RUNTIME_ONLY", value: "yes" }])); + if (expectedError) { + await expect(pending).rejects.toThrow(expectedError); + } else { + await expect(pending).resolves.toEqual({ + env: { RUNTIME_ONLY: "yes", AWS_REGION: "us-east-1" }, + }); + } + }); +}); diff --git a/src/io/devEnvironment.ts b/src/io/devEnvironment.ts new file mode 100644 index 000000000..d63d4641a --- /dev/null +++ b/src/io/devEnvironment.ts @@ -0,0 +1,65 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { parseEnv } from "node:util"; +import type { ProjectRuntime } from "../projectSchemas/runtime"; +import { InputValidationError } from "../errors"; + +const RESERVED_ENV_KEYS = ["PORT", "FASTMCP_PORT", "LOCAL_DEV"] as const; + +export type DevEnvironmentInput = { + projectRoot: string; + runtime: ProjectRuntime; + region?: string; +}; + +export type DevEnvironment = { + env: Record; +}; + +export type DevEnvironmentLoader = (input: DevEnvironmentInput) => Promise; + +type DevEnvironmentLoaderConfig = { + readFile?: (path: string, encoding: BufferEncoding) => Promise; +}; + +async function localEnvironment( + projectRoot: string, + read: (path: string, encoding: BufferEncoding) => Promise, +): Promise> { + const path = join(projectRoot, "agentcore", ".env.local"); + let contents: string; + try { + contents = await read(path, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return {}; + throw new InputValidationError(`Unable to read local environment file at ${path}`, { + cause: error, + }); + } + + try { + return parseEnv(contents) as Record; + } catch (error) { + throw new InputValidationError(`Invalid local environment file at ${path}`, { cause: error }); + } +} + +export function createDevEnvironmentLoader( + config: DevEnvironmentLoaderConfig = {}, +): DevEnvironmentLoader { + const read = config.readFile ?? readFile; + + return async (input) => { + const env = Object.fromEntries( + (input.runtime.envVars ?? []).map(({ name, value }) => [name, value]), + ); + if (input.region) env.AWS_REGION = input.region; + + Object.assign(env, await localEnvironment(input.projectRoot, read)); + for (const key of RESERVED_ENV_KEYS) delete env[key]; + + return { env }; + }; +} + +export const loadDevEnvironment = createDevEnvironmentLoader(); diff --git a/src/io/index.ts b/src/io/index.ts index b4c9deee2..df3d9f664 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -28,3 +28,11 @@ export { export { SourceResolver, type SourceResolverConfig } from "./source"; export type { AppIO, ReadWriteJson } from "./types"; export { warn } from "./warn"; +export { + createDevEnvironmentLoader, + loadDevEnvironment, + type DevEnvironment, + type DevEnvironmentInput, + type DevEnvironmentLoader, +} from "./devEnvironment"; +export { checkPort, type PortChecker } from "./port"; diff --git a/src/io/port.test.ts b/src/io/port.test.ts new file mode 100644 index 000000000..f3ccf0d68 --- /dev/null +++ b/src/io/port.test.ts @@ -0,0 +1,29 @@ +import { expect, test } from "bun:test"; +import { createServer, type Server } from "node:net"; +import { checkPort } from "./port"; + +function listen(): Promise { + return new Promise((resolve, reject) => { + const server = createServer(); + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve(server)); + }); +} + +test("checkPort rejects an occupied loopback port and accepts it after release", async () => { + const server = await listen(); + const address = server.address(); + if (!address || typeof address === "string") throw new Error("expected a TCP address"); + const signal = new AbortController().signal; + + expect(await checkPort(address.port, signal)).toBe(false); + await new Promise((resolve) => server.close(() => resolve())); + expect(await checkPort(address.port, signal)).toBe(true); +}); + +test("checkPort respects an aborted signal", async () => { + const controller = new AbortController(); + controller.abort(); + + await expect(checkPort(49152, controller.signal)).rejects.toHaveProperty("name", "AbortError"); +}); diff --git a/src/io/port.ts b/src/io/port.ts new file mode 100644 index 000000000..897de2da5 --- /dev/null +++ b/src/io/port.ts @@ -0,0 +1,27 @@ +import { createServer } from "node:net"; + +export type PortChecker = (port: number, signal: AbortSignal) => Promise; + +function canBind(port: number, host: string, signal: AbortSignal): Promise { + signal.throwIfAborted(); + + return new Promise((resolve, reject) => { + const server = createServer().unref(); + server.once("error", () => resolve(false)); + server.once("close", () => { + if (signal.aborted) reject(signal.reason); + }); + server.listen({ port, host, exclusive: true, signal }, () => { + server.close(() => resolve(true)); + }); + }); +} + +/** Checks that a port can be bound by both loopback-only and all-interface servers. */ +export const checkPort: PortChecker = async (port, signal) => { + if (!(await canBind(port, "127.0.0.1", signal))) return false; + signal.throwIfAborted(); + const available = await canBind(port, "0.0.0.0", signal); + signal.throwIfAborted(); + return available; +}; diff --git a/src/middleware/withJsonRenderer.tsx b/src/middleware/withJsonRenderer.tsx index 8be45ef4c..6e6ef2463 100644 --- a/src/middleware/withJsonRenderer.tsx +++ b/src/middleware/withJsonRenderer.tsx @@ -11,6 +11,7 @@ import type { AppIO } from "../io"; export function withJsonRenderer(io: AppIO): Middleware { const renderer = { renderJson: (data: unknown) => renderJson(data, (line) => io.stdout.write(line + "\n")), + renderJsonLine: (data: unknown) => io.stdout.write(JSON.stringify(data) + "\n"), }; return (h) => ({ diff --git a/src/runnable/index.test.ts b/src/runnable/index.test.ts index acf43b090..a695ca09e 100644 --- a/src/runnable/index.test.ts +++ b/src/runnable/index.test.ts @@ -1,7 +1,7 @@ import { expect, spyOn, test } from "bun:test"; import { CommanderError } from "commander"; -import { AgentCoreCLIError, InputValidationError } from "../errors"; +import { AgentCoreCLIError, CommandInterruptedError, InputValidationError } from "../errors"; import { ExitCode, runRunnable, runWithExitCode, type Runnable } from "./index.tsx"; async function captureErrors(run: () => Promise) { @@ -89,6 +89,12 @@ test.each([ ExitCode.INTERRUPTED, ["AbortError: The operation was aborted"], ], + [ + "reported command interruption", + new CommandInterruptedError(undefined, true), + ExitCode.INTERRUPTED, + [], + ], [ "Commander parse failure", new CommanderError(1, "commander.invalidArgument", "invalid option"), diff --git a/src/testing/renderScreen.tsx b/src/testing/renderScreen.tsx index f638bb6cb..1cc7ae47a 100644 --- a/src/testing/renderScreen.tsx +++ b/src/testing/renderScreen.tsx @@ -43,7 +43,7 @@ function baseContext(core: TestCoreClient, endpointUrl?: string): Context { .withValue(EndpointKey, endpointUrl) .withValue(JsonKey, false) .withValue(DebugKey, false) - .withValue(JsonRendererKey, { renderJson: () => {} }); + .withValue(JsonRendererKey, { renderJson: () => {}, renderJsonLine: () => {} }); } // testQueryClient returns a QueryClient with retries and caching disabled so diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 8555a53f0..2ae966f6b 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -29,6 +29,7 @@ export function renderJson(data: unknown, writer: (line: string) => void = conso // of any direct dependency on a global output stream. export interface JsonRenderer { renderJson(data: unknown): void; + renderJsonLine(data: unknown): void; } // JsonRendererKey exposes the prewired JsonRenderer on the context. Installed by