From 987a5098de5b257bb69dbfe19bbd630259fb5eb6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 10 Aug 2026 14:17:25 -0400 Subject: [PATCH 1/7] feat(dev): add container dev runner --- src/core/dev/container.test.ts | 524 +++++++++++++++++++++++++++++++++ src/core/dev/container.ts | 202 +++++++++++++ 2 files changed, 726 insertions(+) create mode 100644 src/core/dev/container.test.ts create mode 100644 src/core/dev/container.ts diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts new file mode 100644 index 000000000..fda2e3005 --- /dev/null +++ b/src/core/dev/container.test.ts @@ -0,0 +1,524 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { InputValidationError } from "../../errors"; +import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; +import { + MissingToolError, + type ProcessEvent, + type ProcessStreamer, + type StreamProcessOptions, +} from "../../io"; +import type { ProjectRuntime } from "../project/schema"; +import { ContainerDevRunner } from "./container"; + +type ProcessCall = { + command: string[]; + options: StreamProcessOptions; +}; + +type StreamBehavior = ( + command: string[], + options: StreamProcessOptions, +) => AsyncIterable | Iterable; + +const tempDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +type RuntimeOverrides = Partial> & { + buildContextPath?: string; + dockerfile?: string; +}; + +function runtime(overrides: RuntimeOverrides = {}): ProjectRuntime { + return { + name: "Hello_World", + build: "Container", + entrypoint: "main.py", + codeLocation: "app/hello-world", + protocol: "HTTP", + ...overrides, + } as ProjectRuntime; +} + +async function projectRoot(projectRuntime: ProjectRuntime = runtime()): Promise { + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + tempDirectories.push(root); + const context = join(root, projectRuntime.buildContextPath ?? projectRuntime.codeLocation); + await mkdir(context, { recursive: true }); + await writeFile(join(context, projectRuntime.dockerfile ?? "Dockerfile"), "FROM scratch\n"); + return root; +} + +function harness( + config: { + available?: (tool: string, probeArgs?: string[]) => Promise; + stream?: StreamBehavior; + } = {}, +) { + const calls: ProcessCall[] = []; + const fakeStreamProcess: ProcessStreamer = async function* (command, options) { + calls.push({ command, options }); + if (config.stream) yield* config.stream(command, options); + }; + return { + calls, + runner: new ContainerDevRunner({ + streamProcess: fakeStreamProcess, + toolAvailable: + config.available ?? + (async (tool) => { + return tool === "docker"; + }), + }), + }; +} + +function input( + root: string, + projectRuntime: ProjectRuntime, + signal = new AbortController().signal, +): DevServerInput { + return { + runtime: projectRuntime, + projectRoot: root, + port: 3000, + env: { API_KEY: "super-secret" }, + signal, + }; +} + +async function collect(events: AsyncIterable): Promise { + const collected: DevEvent[] = []; + for await (const event of events) collected.push(event); + return collected; +} + +async function* rejectedEvents(error: unknown): AsyncGenerator { + yield* []; + throw error; +} + +function projectIdentifier(projectRoot: string): string { + return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 12); +} + +function imageTag(projectRoot: string): string { + return `agentcore-dev/hello_world-${projectIdentifier(projectRoot)}`; +} + +function containerName(projectRoot: string): string { + return `agentcore-dev-hello_world-${projectIdentifier(projectRoot)}`; +} + +function commandCall(calls: ProcessCall[], operation: "build" | "run"): ProcessCall { + const call = calls.find(({ command }) => command[1] === operation); + if (!call) throw new Error(`${operation} command was not called`); + return call; +} + +describe("ContainerDevRunner", () => { + test("builds with a widened context and keeps build arg values out of argv", async () => { + const projectRuntime = runtime({ + buildContextPath: ".", + dockerfile: "docker/Dockerfile", + customDockerBuildArgs: { AGENT_NAME: "hello-world", TARGET: "development" }, + }); + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + tempDirectories.push(root); + await mkdir(join(root, "docker"), { recursive: true }); + await writeFile(join(root, "docker", "Dockerfile"), "FROM scratch\n"); + const { calls, runner } = harness(); + + await collect(runner.run(input(root, projectRuntime))); + + const build = commandCall(calls, "build"); + expect(build.command).toEqual([ + "docker", + "build", + "-f", + "docker/Dockerfile", + "-t", + imageTag(root), + "--build-arg", + "AGENT_NAME", + "--build-arg", + "TARGET", + ".", + ]); + expect(build.options.cwd).toBe(root); + expect(build.options.env).toMatchObject({ + AGENT_NAME: "hello-world", + TARGET: "development", + }); + expect(build.command.join(" ")).not.toContain("hello-world"); + expect(build.command.join(" ")).not.toContain("development"); + + const dockerignore = await readFile(join(root, ".dockerignore"), "utf8"); + for (const pattern of [".env", "**/.env", "**/node_modules", "agentcore/"]) { + expect(dockerignore).toContain(pattern); + } + }); + + test.each([ + ["HTTP", 8080, "HTTP"], + ["MCP", 8000, "MCP"], + ["A2A", 9000, "A2A"], + ["AGUI", 8080, "AGUI"], + ["the default protocol", 8080, undefined], + ] as const)("maps %s to container port %d", async (_label, containerPort, protocol) => { + const projectRuntime = runtime({ protocol }); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness(); + + await collect(runner.run(input(root, projectRuntime))); + + const run = commandCall(calls, "run"); + expect(run.command).toEqual([ + "docker", + "run", + "--rm", + "--name", + containerName(root), + "-p", + `127.0.0.1:3000:${containerPort}`, + "-e", + "API_KEY", + "-e", + "PORT", + "-e", + "LOCAL_DEV", + ...(protocol === "MCP" ? ["-e", "FASTMCP_PORT"] : []), + imageTag(root), + ]); + expect(run.options.env).toMatchObject({ + API_KEY: "super-secret", + PORT: String(containerPort), + LOCAL_DEV: "1", + }); + expect(run.options.env?.FASTMCP_PORT).toBe(protocol === "MCP" ? "8000" : undefined); + expect(run.command.join(" ")).not.toContain("super-secret"); + }); + + test("preserves an existing build context .dockerignore", async () => { + const projectRuntime = runtime({ buildContextPath: "." }); + const root = await projectRoot(projectRuntime); + const dockerignore = join(root, ".dockerignore"); + await writeFile(dockerignore, "# user owned\ncustom-pattern\n"); + const { runner } = harness(); + + await collect(runner.run(input(root, projectRuntime))); + + expect(await readFile(dockerignore, "utf8")).toBe("# user owned\ncustom-pattern\n"); + }); + + test("scopes image and container names to the project root", async () => { + const projectRuntime = runtime(); + const firstRoot = await projectRoot(projectRuntime); + const secondRoot = await projectRoot(projectRuntime); + const first = harness(); + const second = harness(); + + await collect(first.runner.run(input(firstRoot, projectRuntime))); + await collect(second.runner.run(input(secondRoot, projectRuntime))); + + expect(commandCall(first.calls, "build").command).toContain(imageTag(firstRoot)); + expect(commandCall(second.calls, "build").command).toContain(imageTag(secondRoot)); + expect(imageTag(firstRoot)).not.toBe(imageTag(secondRoot)); + expect(first.calls[0]?.command).toContain(containerName(firstRoot)); + expect(second.calls[0]?.command).toContain(containerName(secondRoot)); + expect(containerName(firstRoot)).not.toBe(containerName(secondRoot)); + }); + + test("selects the first tool that supports container builds", async () => { + const probes: Array<[string, string[] | undefined]> = []; + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + available: async (tool, probeArgs) => { + probes.push([tool, probeArgs]); + return tool === "podman"; + }, + }); + + await collect(runner.run(input(root, projectRuntime))); + + expect(probes).toEqual([ + ["docker", undefined], + ["podman", undefined], + ["podman", ["build", "--help"]], + ]); + expect(commandCall(calls, "build").command[0]).toBe("podman"); + expect(commandCall(calls, "run").command[0]).toBe("podman"); + }); + + test("skips a version shim that cannot build", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + available: async (tool, probeArgs) => { + if (tool === "docker") return probeArgs === undefined; + return tool === "podman"; + }, + }); + + await collect(runner.run(input(root, projectRuntime))); + + expect(commandCall(calls, "build").command[0]).toBe("podman"); + }); + + test("falls back to finch when docker and podman are unavailable", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + available: async (tool) => tool === "finch", + }); + + await collect(runner.run(input(root, projectRuntime))); + + expect(commandCall(calls, "build").command[0]).toBe("finch"); + }); + + test("throws a useful error when no container runtime is available", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { runner } = harness({ available: async () => false }); + + const promise = collect(runner.run(input(root, projectRuntime))); + + await expect(promise).rejects.toBeInstanceOf(MissingToolError); + await expect(promise).rejects.toThrow(/Docker.*Podman.*Finch/); + }); + + test("does not probe tools or mutate the project when already aborted", async () => { + const projectRuntime = runtime({ buildContextPath: "." }); + const root = await projectRoot(projectRuntime); + const controller = new AbortController(); + controller.abort(); + const probes: string[] = []; + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + await expect( + collect(runner.run(input(root, projectRuntime, controller.signal))), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(probes).toHaveLength(0); + expect(calls).toHaveLength(0); + await expect(readFile(join(root, ".dockerignore"), "utf8")).rejects.toThrow(); + }); + + test("stops after tool detection when aborted during probing", async () => { + const projectRuntime = runtime({ buildContextPath: "." }); + const root = await projectRoot(projectRuntime); + const controller = new AbortController(); + const probes: string[] = []; + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + controller.abort(); + return false; + }, + }); + + await expect( + collect(runner.run(input(root, projectRuntime, controller.signal))), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(probes).toEqual(["docker"]); + expect(calls).toHaveLength(0); + await expect(readFile(join(root, ".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); + await mkdir(join(root, "app"), { recursive: true }); + await writeFile(join(root, "app", "hello-world"), "not a directory"); + const probes: string[] = []; + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + const promise = collect(runner.run(input(root, runtime()))); + + await expect(promise).rejects.toBeInstanceOf(InputValidationError); + await expect(promise).rejects.toThrow(/build context directory not found/); + expect(probes).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + test("rejects a Dockerfile that is not a file", async () => { + const projectRuntime = runtime(); + const root = await mkdtemp(join(tmpdir(), "agentcore-container-")); + tempDirectories.push(root); + await mkdir(join(root, projectRuntime.codeLocation, "Dockerfile"), { recursive: true }); + const probes: string[] = []; + const { calls, runner } = harness({ + available: async (tool) => { + probes.push(tool); + return true; + }, + }); + + const promise = collect(runner.run(input(root, projectRuntime))); + + await expect(promise).rejects.toBeInstanceOf(InputValidationError); + await expect(promise).rejects.toThrow(/Dockerfile not found/); + expect(probes).toHaveLength(0); + expect(calls).toHaveLength(0); + }); + + test("removes stale containers and cleans up after a normal exit", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness(); + const runInput = input(root, projectRuntime); + + await collect(runner.run(runInput)); + + expect(calls.map(({ command }) => command.slice(0, 3))).toEqual([ + ["docker", "rm", "-f"], + ["docker", "build", "-f"], + ["docker", "run", "--rm"], + ["docker", "rm", "-f"], + ]); + expect(calls[0]?.command).toEqual(["docker", "rm", "-f", containerName(root)]); + expect(calls[3]?.command).toEqual(calls[0]!.command); + expect(calls[0]?.options.signal).not.toBe(runInput.signal); + expect(calls[1]?.options.signal).toBe(runInput.signal); + expect(calls[2]?.options.signal).toBe(runInput.signal); + expect(calls[3]?.options.signal).not.toBe(runInput.signal); + }); + + test("cleans up when the container process fails", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + stream: (command) => { + return command[1] === "run" ? rejectedEvents(new Error("container failed")) : []; + }, + }); + + await expect(collect(runner.run(input(root, projectRuntime)))).rejects.toThrow( + "container failed", + ); + + expect(calls.filter(({ command }) => command[1] === "rm")).toHaveLength(2); + }); + + test("cleans up when container execution is aborted", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const controller = new AbortController(); + const { calls, runner } = harness({ + stream: (command, options) => { + if (command[1] === "run") { + controller.abort(); + return rejectedEvents(options.signal?.reason); + } + return []; + }, + }); + + await expect( + collect(runner.run(input(root, projectRuntime, controller.signal))), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(calls.filter(({ command }) => command[1] === "rm")).toHaveLength(2); + }); + + test("cleans up when the consumer stops iterating", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + let runIteratorClosed = false; + const { calls, runner } = harness({ + stream: (command) => { + if (command[1] !== "run") return []; + return (async function* () { + try { + yield { type: "stdout", line: "container ready" } as const; + } finally { + runIteratorClosed = true; + } + })(); + }, + }); + const iterator = runner.run(input(root, projectRuntime)); + + expect(await iterator.next()).toMatchObject({ + value: { type: "status", message: "Building image with docker" }, + }); + expect(await iterator.next()).toMatchObject({ + value: { type: "status", message: "Starting container" }, + }); + expect(await iterator.next()).toMatchObject({ + value: { type: "stdout", line: "container ready" }, + }); + await iterator.return(undefined); + + expect(runIteratorClosed).toBe(true); + expect(calls.filter(({ command }) => command[1] === "rm")).toHaveLength(2); + }); + + test("ignores stale and final cleanup failures", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + stream: (command) => { + return command[1] === "rm" ? rejectedEvents(new Error("container missing")) : []; + }, + }); + + await expect(collect(runner.run(input(root, projectRuntime)))).resolves.toBeDefined(); + expect(commandCall(calls, "run")).toBeDefined(); + expect(calls.filter(({ command }) => command[1] === "rm")).toHaveLength(2); + }); + + test("does not run a container after a failed build", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + stream: (command) => { + return command[1] === "build" ? rejectedEvents(new Error("build failed")) : []; + }, + }); + + await expect(collect(runner.run(input(root, projectRuntime)))).rejects.toThrow("build failed"); + expect(calls.some(({ command }) => command[1] === "run")).toBe(false); + }); + + test("interleaves status, build output, and run output", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { runner } = harness({ + stream: async function* (command) { + if (command[1] === "build") yield { type: "stdout", line: "build output" }; + if (command[1] === "run") yield { type: "stderr", line: "run output" }; + }, + }); + + const events = await collect(runner.run(input(root, projectRuntime))); + + expect(events).toEqual([ + { type: "status", message: "Building image with docker" }, + { type: "stdout", line: "build output" }, + { type: "status", message: "Starting container" }, + { type: "stderr", line: "run output" }, + ]); + }); +}); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts new file mode 100644 index 000000000..b2f0c6333 --- /dev/null +++ b/src/core/dev/container.ts @@ -0,0 +1,202 @@ +import { createHash } from "node:crypto"; +import { existsSync, statSync, writeFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { InputValidationError } from "../../errors"; +import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; +import { + MissingToolError, + streamProcess, + toolAvailable, + type ProcessStreamer, + type StreamProcessOptions, +} from "../../io"; + +const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const; +const CLEANUP_TIMEOUT_MS = 2_000; +const DOCKERFILE_NAME = "Dockerfile"; +const CONTAINER_RUNTIME_INSTALL_HINT = + "Install Docker (https://docs.docker.com/get-docker/), Podman (https://podman.io/), " + + "or Finch (https://runfinch.com/)."; + +const BUILD_CONTEXT_DOCKERIGNORE = `# Generated by agentcore because buildContextPath widens the Docker build context. +.env +.env.* +**/.env +**/.env.* +.git +**/.git +.venv +**/.venv +node_modules +**/node_modules +__pycache__ +**/__pycache__ +.pytest_cache +**/.pytest_cache +.DS_Store +**/.DS_Store +agentcore/ +`; + +type ContainerTool = (typeof CONTAINER_TOOLS)[number]; +type ToolAvailable = typeof toolAvailable; + +type ContainerDevRunnerConfig = { + streamProcess?: ProcessStreamer; + toolAvailable?: ToolAvailable; +}; + +export class ContainerDevRunner implements DevRunner { + private readonly streamProcess: ProcessStreamer; + private readonly toolAvailable: ToolAvailable; + + constructor(config: ContainerDevRunnerConfig = {}) { + this.streamProcess = config.streamProcess ?? streamProcess; + this.toolAvailable = config.toolAvailable ?? toolAvailable; + } + + public async *run(input: DevServerInput): AsyncGenerator { + input.signal.throwIfAborted(); + const context = join( + input.projectRoot, + input.runtime.buildContextPath ?? input.runtime.codeLocation, + ); + if (!isDirectory(context)) { + throw new InputValidationError(`container build context directory not found: ${context}`); + } + + const dockerfile = input.runtime.dockerfile ?? DOCKERFILE_NAME; + const dockerfilePath = join(context, dockerfile); + if (!isFile(dockerfilePath)) { + throw new InputValidationError(`container Dockerfile not found: ${dockerfilePath}`); + } + + const tool = await this.resolveContainerTool(input.signal); + input.signal.throwIfAborted(); + if (input.runtime.buildContextPath) { + const dockerignore = ensureBuildContextDockerignore(context); + if (dockerignore) { + yield { type: "status", message: `Created protective ${dockerignore}` }; + } + } + + const runtimeName = input.runtime.name.toLowerCase(); + const projectId = projectIdentifier(input.projectRoot); + const imageTag = `agentcore-dev/${runtimeName}-${projectId}`; + const containerName = `agentcore-dev-${runtimeName}-${projectId}`; + input.signal.throwIfAborted(); + await this.removeContainer(tool, containerName, context); + + const buildArgs = input.runtime.customDockerBuildArgs ?? {}; + const buildArgFlags = Object.keys(buildArgs).flatMap((key) => ["--build-arg", key]); + const buildOptions: StreamProcessOptions = { + cwd: context, + env: { ...process.env, ...buildArgs }, + signal: input.signal, + }; + + yield { type: "status", message: `Building image with ${tool}` }; + yield* this.streamProcess( + [tool, "build", "-f", dockerfile, "-t", imageTag, ...buildArgFlags, "."], + buildOptions, + ); + + const containerPort = portForProtocol(input.runtime.protocol); + const forwardedEnv: Record = { + ...input.env, + PORT: String(containerPort), + LOCAL_DEV: "1", + }; + if (input.runtime.protocol === "MCP") { + forwardedEnv.FASTMCP_PORT = String(containerPort); + } + const envNameFlags = Object.keys(forwardedEnv).flatMap((key) => ["-e", key]); + + yield { type: "status", message: "Starting container" }; + try { + yield* this.streamProcess( + [ + tool, + "run", + "--rm", + "--name", + containerName, + "-p", + `127.0.0.1:${input.port}:${containerPort}`, + ...envNameFlags, + imageTag, + ], + { + cwd: context, + env: { ...process.env, ...forwardedEnv }, + signal: input.signal, + }, + ); + } finally { + await this.removeContainer(tool, containerName, context); + } + } + + private async resolveContainerTool(signal: AbortSignal): Promise { + for (const tool of CONTAINER_TOOLS) { + const hasVersion = await this.toolAvailable(tool); + signal.throwIfAborted(); + if (!hasVersion) continue; + + const canBuild = await this.toolAvailable(tool, ["build", "--help"]); + signal.throwIfAborted(); + if (canBuild) return tool; + } + throw new MissingToolError("container runtime", CONTAINER_RUNTIME_INSTALL_HINT); + } + + private async removeContainer( + tool: ContainerTool, + containerName: string, + cwd: string, + ): Promise { + try { + for await (const _event of this.streamProcess([tool, "rm", "-f", containerName], { + cwd, + signal: AbortSignal.timeout(CLEANUP_TIMEOUT_MS), + })) { + // Best-effort cleanup intentionally discards command output. + } + } catch { + // A missing container and an unavailable daemon are both safe to ignore here. + } + } +} + +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(); + } catch { + return false; + } +} + +function isFile(path: string): boolean { + try { + return statSync(path).isFile(); + } catch { + return false; + } +} + +function ensureBuildContextDockerignore(context: string): string | undefined { + const dockerignore = join(context, ".dockerignore"); + if (existsSync(dockerignore)) return undefined; + writeFileSync(dockerignore, BUILD_CONTEXT_DOCKERIGNORE); + return dockerignore; +} + +function projectIdentifier(projectRoot: string): string { + return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 12); +} From 67438879d7604ec395115de7b6d8f08119ae76c6 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Tue, 11 Aug 2026 16:42:32 -0400 Subject: [PATCH 2/7] fix(dev): address container runner review --- src/core/dev/container.test.ts | 29 +++++++++++++++++++++++++---- src/core/dev/container.ts | 8 ++++---- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index fda2e3005..15b078b17 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -106,16 +106,16 @@ async function* rejectedEvents(error: unknown): AsyncGenerator { await expect(readFile(join(root, ".dockerignore"), "utf8")).rejects.toThrow(); }); + test("does not build when aborted during stale container cleanup", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const controller = new AbortController(); + const { calls, runner } = harness({ + stream: async function* (command) { + if (command[1] === "rm") { + await Promise.resolve(); + controller.abort(); + } + yield* []; + }, + }); + + await expect( + collect(runner.run(input(root, projectRuntime, controller.signal))), + ).rejects.toMatchObject({ name: "AbortError" }); + + expect(calls.map(({ command }) => command[1])).toEqual(["rm"]); + }); + 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 b2f0c6333..c50720c9e 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -81,11 +81,11 @@ export class ContainerDevRunner implements DevRunner { } const runtimeName = input.runtime.name.toLowerCase(); - const projectId = projectIdentifier(input.projectRoot); + const projectId = hashString(resolve(input.projectRoot)); const imageTag = `agentcore-dev/${runtimeName}-${projectId}`; const containerName = `agentcore-dev-${runtimeName}-${projectId}`; - input.signal.throwIfAborted(); await this.removeContainer(tool, containerName, context); + input.signal.throwIfAborted(); const buildArgs = input.runtime.customDockerBuildArgs ?? {}; const buildArgFlags = Object.keys(buildArgs).flatMap((key) => ["--build-arg", key]); @@ -197,6 +197,6 @@ function ensureBuildContextDockerignore(context: string): string | undefined { return dockerignore; } -function projectIdentifier(projectRoot: string): string { - return createHash("sha256").update(resolve(projectRoot)).digest("hex").slice(0, 12); +function hashString(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); } From 113dcdee7153789e21ce0f6e78f1df4f929d391e Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 09:04:46 -0400 Subject: [PATCH 3/7] fix(dev): address container runtime feedback --- src/core/dev/container.test.ts | 89 ++++++++++++++++++++++++++-------- src/core/dev/container.ts | 76 ++++++++++++++++++++++------- src/io/exec.test.ts | 14 ++++++ src/io/exec.ts | 11 +++-- 4 files changed, 147 insertions(+), 43 deletions(-) diff --git a/src/core/dev/container.test.ts b/src/core/dev/container.test.ts index 15b078b17..3767c88aa 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { InputValidationError } from "../../errors"; +import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; import { MissingToolError, @@ -125,7 +125,7 @@ function commandCall(calls: ProcessCall[], operation: "build" | "run"): ProcessC } describe("ContainerDevRunner", () => { - test("builds with a widened context and keeps build arg values out of argv", async () => { + test("builds with a widened context and redacts build arg values from errors", async () => { const projectRuntime = runtime({ buildContextPath: ".", dockerfile: "docker/Dockerfile", @@ -148,18 +148,17 @@ describe("ContainerDevRunner", () => { "-t", imageTag(root), "--build-arg", - "AGENT_NAME", + "AGENT_NAME=hello-world", "--build-arg", - "TARGET", + "TARGET=development", ".", ]); expect(build.options.cwd).toBe(root); - expect(build.options.env).toMatchObject({ - AGENT_NAME: "hello-world", - TARGET: "development", - }); - expect(build.command.join(" ")).not.toContain("hello-world"); - expect(build.command.join(" ")).not.toContain("development"); + expect(build.options.env).toBe(process.env); + expect(build.options.redactedCommand).toContain("AGENT_NAME="); + expect(build.options.redactedCommand).toContain("TARGET="); + expect(build.options.redactedCommand?.join(" ")).not.toContain("hello-world"); + expect(build.options.redactedCommand?.join(" ")).not.toContain("development"); const dockerignore = await readFile(join(root, ".dockerignore"), "utf8"); for (const pattern of [".env", "**/.env", "**/node_modules", "agentcore/"]) { @@ -190,21 +189,17 @@ describe("ContainerDevRunner", () => { "-p", `127.0.0.1:3000:${containerPort}`, "-e", - "API_KEY", + "API_KEY=super-secret", "-e", - "PORT", + `PORT=${containerPort}`, "-e", - "LOCAL_DEV", - ...(protocol === "MCP" ? ["-e", "FASTMCP_PORT"] : []), + "LOCAL_DEV=1", + ...(protocol === "MCP" ? ["-e", "FASTMCP_PORT=8000"] : []), imageTag(root), ]); - expect(run.options.env).toMatchObject({ - API_KEY: "super-secret", - PORT: String(containerPort), - LOCAL_DEV: "1", - }); - expect(run.options.env?.FASTMCP_PORT).toBe(protocol === "MCP" ? "8000" : undefined); - expect(run.command.join(" ")).not.toContain("super-secret"); + expect(run.options.env).toBe(process.env); + expect(run.options.redactedCommand).toContain("API_KEY="); + expect(run.options.redactedCommand?.join(" ")).not.toContain("super-secret"); }); test("preserves an existing build context .dockerignore", async () => { @@ -237,6 +232,33 @@ describe("ContainerDevRunner", () => { expect(containerName(firstRoot)).not.toBe(containerName(secondRoot)); }); + test("limits image names to two consecutive underscores", async () => { + const projectRuntime = runtime({ name: "Hello___World" }); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness(); + + await collect(runner.run(input(root, projectRuntime))); + + expect(commandCall(calls, "build").command).toContain( + `agentcore-dev/hello__world-${hashString(resolve(root))}`, + ); + }); + + test("keeps app variables out of the container CLI environment", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness(); + const runInput = input(root, projectRuntime); + runInput.env = { ...runInput.env, DOCKER_HOST: "tcp://application-value" }; + + await collect(runner.run(runInput)); + + const run = commandCall(calls, "run"); + expect(run.command).toContain("DOCKER_HOST=tcp://application-value"); + expect(run.options.env).toBe(process.env); + expect(run.options.redactedCommand).toContain("DOCKER_HOST="); + }); + test("selects the first tool that supports container builds", async () => { const probes: Array<[string, string[] | undefined]> = []; const projectRuntime = runtime(); @@ -286,6 +308,31 @@ describe("ContainerDevRunner", () => { expect(commandCall(calls, "build").command[0]).toBe("finch"); }); + test("passes explicit build arg values to finch", async () => { + const projectRuntime = runtime({ customDockerBuildArgs: { AGENT_NAME: "hello-world" } }); + const root = await projectRoot(projectRuntime); + const { calls, runner } = harness({ + available: async (tool) => tool === "finch", + }); + + await collect(runner.run(input(root, projectRuntime))); + + expect(commandCall(calls, "build").command).toContain("AGENT_NAME=hello-world"); + }); + + test("suggests initializing the Finch VM when its build probe fails", async () => { + const projectRuntime = runtime(); + const root = await projectRoot(projectRuntime); + const { runner } = harness({ + available: async (tool, probeArgs) => tool === "finch" && probeArgs === undefined, + }); + + const promise = collect(runner.run(input(root, projectRuntime))); + + await expect(promise).rejects.toBeInstanceOf(InvalidEnvironmentError); + await expect(promise).rejects.toThrow("finch vm init"); + }); + test("throws a useful error when no container runtime is available", async () => { const projectRuntime = runtime(); const root = await projectRoot(projectRuntime); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts index c50720c9e..720d6e8dd 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import { existsSync, statSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { InputValidationError } from "../../errors"; +import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; import { MissingToolError, @@ -82,24 +82,39 @@ export class ContainerDevRunner implements DevRunner { const runtimeName = input.runtime.name.toLowerCase(); const projectId = hashString(resolve(input.projectRoot)); - const imageTag = `agentcore-dev/${runtimeName}-${projectId}`; + const imageTag = `agentcore-dev/${sanitizeImageNameComponent(runtimeName)}-${projectId}`; const containerName = `agentcore-dev-${runtimeName}-${projectId}`; await this.removeContainer(tool, containerName, context); input.signal.throwIfAborted(); const buildArgs = input.runtime.customDockerBuildArgs ?? {}; - const buildArgFlags = Object.keys(buildArgs).flatMap((key) => ["--build-arg", key]); + const buildArgFlags = Object.entries(buildArgs).flatMap(([key, value]) => [ + "--build-arg", + `${key}=${value}`, + ]); + const redactedBuildArgFlags = Object.keys(buildArgs).flatMap((key) => [ + "--build-arg", + `${key}=`, + ]); + const buildCommand = [tool, "build", "-f", dockerfile, "-t", imageTag, ...buildArgFlags, "."]; const buildOptions: StreamProcessOptions = { cwd: context, - env: { ...process.env, ...buildArgs }, + env: process.env, + redactedCommand: [ + tool, + "build", + "-f", + dockerfile, + "-t", + imageTag, + ...redactedBuildArgFlags, + ".", + ], signal: input.signal, }; yield { type: "status", message: `Building image with ${tool}` }; - yield* this.streamProcess( - [tool, "build", "-f", dockerfile, "-t", imageTag, ...buildArgFlags, "."], - buildOptions, - ); + yield* this.streamProcess(buildCommand, buildOptions); const containerPort = portForProtocol(input.runtime.protocol); const forwardedEnv: Record = { @@ -110,12 +125,32 @@ export class ContainerDevRunner implements DevRunner { if (input.runtime.protocol === "MCP") { forwardedEnv.FASTMCP_PORT = String(containerPort); } - const envNameFlags = Object.keys(forwardedEnv).flatMap((key) => ["-e", key]); + const envFlags = Object.entries(forwardedEnv).flatMap(([key, value]) => [ + "-e", + `${key}=${value}`, + ]); + const redactedEnvFlags = Object.keys(forwardedEnv).flatMap((key) => [ + "-e", + `${key}=`, + ]); + const runCommand = [ + tool, + "run", + "--rm", + "--name", + containerName, + "-p", + `127.0.0.1:${input.port}:${containerPort}`, + ...envFlags, + imageTag, + ]; yield { type: "status", message: "Starting container" }; try { - yield* this.streamProcess( - [ + yield* this.streamProcess(runCommand, { + cwd: context, + env: process.env, + redactedCommand: [ tool, "run", "--rm", @@ -123,15 +158,11 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, - ...envNameFlags, + ...redactedEnvFlags, imageTag, ], - { - cwd: context, - env: { ...process.env, ...forwardedEnv }, - signal: input.signal, - }, - ); + signal: input.signal, + }); } finally { await this.removeContainer(tool, containerName, context); } @@ -146,6 +177,11 @@ export class ContainerDevRunner implements DevRunner { const canBuild = await this.toolAvailable(tool, ["build", "--help"]); signal.throwIfAborted(); if (canBuild) return tool; + if (tool === "finch") { + throw new InvalidEnvironmentError( + "Finch is installed but its VM is not initialized. Run 'finch vm init' and retry.", + ); + } } throw new MissingToolError("container runtime", CONTAINER_RUNTIME_INSTALL_HINT); } @@ -200,3 +236,7 @@ function ensureBuildContextDockerignore(context: string): string | undefined { function hashString(value: string): string { return createHash("sha256").update(value).digest("hex").slice(0, 12); } + +function sanitizeImageNameComponent(value: string): string { + return value.replace(/_{3,}/g, "__"); +} diff --git a/src/io/exec.test.ts b/src/io/exec.test.ts index a4b9cc433..d20a8a090 100644 --- a/src/io/exec.test.ts +++ b/src/io/exec.test.ts @@ -111,6 +111,20 @@ describe("streamProcess", () => { await expect(iterator.next()).rejects.toThrow(/exit code 3/); }); + test("redacts sensitive command arguments from process errors", async () => { + const failing = await script("stream-redacted-fail.js", "process.exit(3)"); + const iterator = streamProcess(["node", failing, "super-secret"], { + cwd: process.cwd(), + redactedCommand: ["node", failing, ""], + }); + + const error = await iterator.next().catch((caught: unknown) => caught); + + expect(error).toBeInstanceOf(ProcessFailedError); + expect(String(error)).toContain(""); + expect(String(error)).not.toContain("super-secret"); + }); + test("throws ProcessFailedError when the executable cannot spawn", async () => { await expect( collect(streamProcess(["definitely-not-a-real-tool-xyz"], { cwd: process.cwd() })), diff --git a/src/io/exec.ts b/src/io/exec.ts index 1a05ac6d3..94ad19f59 100644 --- a/src/io/exec.ts +++ b/src/io/exec.ts @@ -66,6 +66,8 @@ export type StreamProcessOptions = { cwd: string; env?: NodeJS.ProcessEnv; signal?: AbortSignal; + /** Command rendered in errors when the actual arguments contain sensitive values. */ + redactedCommand?: string[]; /** Required on Windows for command scripts such as npm.cmd. */ shell?: boolean; }; @@ -115,8 +117,9 @@ export async function* streamProcess( options: StreamProcessOptions, ): AsyncGenerator { const [executable, ...args] = command; + const errorCommand = options.redactedCommand ?? command; if (!executable) { - throw new ProcessFailedError(command, options.cwd, null, "command is empty"); + throw new ProcessFailedError(errorCommand, options.cwd, null, "command is empty"); } if (options.signal?.aborted) throw abortReason(options.signal); @@ -130,7 +133,7 @@ export async function* streamProcess( detached: !useShell, }); } catch (error) { - throw new ProcessFailedError(command, options.cwd, null, String(error)); + throw new ProcessFailedError(errorCommand, options.cwd, null, String(error)); } const events: ProcessEvent[] = []; @@ -203,12 +206,12 @@ export async function* streamProcess( if (options.signal?.aborted) throw abortReason(options.signal); if (spawnError) { - throw new ProcessFailedError(command, options.cwd, null, String(spawnError)); + throw new ProcessFailedError(errorCommand, options.cwd, null, String(spawnError)); } if (exitCode !== 0) { const signalMessage = exitSignal ? `terminated by ${exitSignal}` : ""; throw new ProcessFailedError( - command, + errorCommand, options.cwd, exitCode, [...recentOutput, signalMessage].filter(Boolean).join("\n"), From c55046e5ab5be7f85bb18729d8203bde95a5fa2b Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Mon, 10 Aug 2026 16:40:38 -0400 Subject: [PATCH 4/7] feat(project): wire dev handler --- package.json | 3 + .../templates/shared/env.local.template | 6 +- src/core/dev/container.test.ts | 40 ++++ src/core/dev/container.ts | 52 +++- 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 | 20 +- src/handlers/project/project.test.ts | 9 +- 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/index.tsx | 1 + src/middleware/withJsonRenderer.tsx | 1 + src/middleware/withProject.test.ts | 4 +- src/middleware/withProject.tsx | 6 +- src/runnable/index.test.ts | 8 +- src/testing/renderScreen.tsx | 2 +- src/tui/index.tsx | 1 + 26 files changed, 768 insertions(+), 33 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..05553d9f8 100644 --- a/src/core/dev/container.test.ts +++ b/src/core/dev/container.test.ts @@ -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); diff --git a/src/core/dev/container.ts b/src/core/dev/container.ts index 720d6e8dd..b2b00cfde 100644 --- a/src/core/dev/container.ts +++ b/src/core/dev/container.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { existsSync, statSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; import { join, resolve } from "node:path"; import { InputValidationError, InvalidEnvironmentError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; @@ -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 { @@ -71,6 +87,18 @@ export class ContainerDevRunner implements DevRunner { 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 +144,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 +177,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...envFlags, imageTag, ]; @@ -158,6 +195,7 @@ export class ContainerDevRunner implements DevRunner { containerName, "-p", `127.0.0.1:${input.port}:${containerPort}`, + ...awsMount, ...redactedEnvFlags, imageTag, ], @@ -204,12 +242,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..a8f844c76 --- /dev/null +++ b/src/core/dev/port.ts @@ -0,0 +1,45 @@ +import { InputValidationError } from "../../errors"; +import type { ProjectRuntime } from "../project/schema"; +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 27c6edc69..69e386277 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 ed44888ab..e9daf0651 100644 --- a/src/errors/index.tsx +++ b/src/errors/index.tsx @@ -1,5 +1,6 @@ export { AgentCoreCLIError, + CommandInterruptedError, DeserializationError, EmbeddedAssetNotFoundError, FileWriteError, @@ -10,7 +11,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..643544b52 --- /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 "../../../core/project/schema"; +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", 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..530166abd 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 "../../../core/project/schema"; +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 6c681a32d..6818614a9 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -1,5 +1,8 @@ import { Router } from "../../router"; -import type { AppIO } from "../../io"; +import { checkPort, loadDevEnvironment, type AppIO } from "../../io"; +import { CodeZipDevRunner } from "../../core/dev/codezip"; +import { ContainerDevRunner } from "../../core/dev/container"; +import { withProject } from "../../middleware"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; import { createRemoveProjectHandler } from "./remove"; @@ -22,7 +25,20 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { ); project.handler(createAddProjectHandler()); project.handler(createRemoveProjectHandler()); - project.handler(createDevProjectHandler()); + project.handler( + withProject({ projectManager: config.projectManager, cwd: process.cwd() })( + createDevProjectHandler({ + io: config.io, + runners: { + CodeZip: new CodeZipDevRunner(), + Container: new ContainerDevRunner(), + }, + loadDevEnvironment, + checkPort, + forceExit: () => process.exit(130), + }), + ), + ); project.handler(createDeployProjectHandler()); project.handler(createStatusProjectHandler()); project.handler(createBuildProjectHandler()); diff --git a/src/handlers/project/project.test.ts b/src/handlers/project/project.test.ts index 8ca6f537a..58d50253f 100644 --- a/src/handlers/project/project.test.ts +++ b/src/handlers/project/project.test.ts @@ -22,12 +22,19 @@ async function run(args: string[]) { return { io, core }; } -describe.each(["add", "remove", "dev", "deploy", "status", "build"])("project %s", (command) => { +describe.each(["add", "remove", "deploy", "status", "build"])("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. Run 'agentcore project create' or cd into a project.", + ); +}); + 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..17b30046b --- /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 "../core/project/schema"; +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..331aef982 --- /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 "../core/project/schema"; +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 18ee010ab..3e7993415 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -21,3 +21,11 @@ export { FsReadWriteJson } from "./json"; 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/index.tsx b/src/middleware/index.tsx index e77355898..4838ad322 100644 --- a/src/middleware/index.tsx +++ b/src/middleware/index.tsx @@ -3,3 +3,4 @@ export { withTuiOnEmptyFlagsAndArgs } from "./withTuiOnEmptyFlagsAndArgs"; export { withJsonRenderer } from "./withJsonRenderer"; export { withLogging } from "./withLogging"; export { withGlobalConfigAccessor } from "./withGlobalConfigAccessor"; +export { withProject } from "./withProject"; 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/middleware/withProject.test.ts b/src/middleware/withProject.test.ts index 3d904ebf3..8ba7f2832 100644 --- a/src/middleware/withProject.test.ts +++ b/src/middleware/withProject.test.ts @@ -18,6 +18,8 @@ describe("withProject", () => { }), ); - await expect(app.route(["node", "app", "check"])).rejects.toThrow(/no AgentCore project found/); + await expect(app.route(["node", "app", "check"])).rejects.toThrow( + "No AgentCore project found. Run 'agentcore project create' or cd into a project.", + ); }); }); diff --git a/src/middleware/withProject.tsx b/src/middleware/withProject.tsx index 3a59d1e36..6923d3614 100644 --- a/src/middleware/withProject.tsx +++ b/src/middleware/withProject.tsx @@ -1,5 +1,5 @@ import type { Project, ProjectManager } from "../handlers/project/types"; -import { InputValidationError } from "../errors/errors"; +import { InputValidationError } from "../errors"; import { ProjectKey, type Middleware } from "../router"; interface WithProjectConfig { @@ -25,7 +25,9 @@ export function withProject(config: WithProjectConfig): Middleware { handle: async (ctx, flags, args) => { const project = await config.projectManager.resolve({ filePath: config.cwd }); if (!project) { - throw new InputValidationError(`no AgentCore project found at ${config.cwd}`); + throw new InputValidationError( + "No AgentCore project found. Run 'agentcore project create' or cd into a project.", + ); } await h.handle(ctx.withValue(ProjectKey, project), flags, args); }, 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 From 68430ceaeb0279751d5d6cf8f9005fc62aff78b3 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:40:25 -0400 Subject: [PATCH 5/7] feat(dev): collect local OTEL traces in project dev Adds an in-process OTLP/HTTP collector (protobuf + JSON ingest, JSONL persistence per trace) started by the dev handler unless --no-traces or instrumentation.enableOtel is false. Spawned agents receive OTEL env pointing at the collector; container runtimes get a host.docker.internal endpoint, and Python CodeZip agents get sitecustomize-based auto-instrumentation so uvicorn --reload workers stay traced. --- bun.lock | 41 +++ package.json | 1 + .../templates/shared/env.local.template | 6 +- src/core/dev/codezip.test.ts | 81 ++++- src/core/dev/codezip.ts | 53 +++- src/core/dev/otel/collector.test.ts | 167 +++++++++++ src/core/dev/otel/collector.ts | 144 +++++++++ src/core/dev/otel/store.test.ts | 120 ++++++++ src/core/dev/otel/store.ts | 122 ++++++++ src/core/dev/otel/transforms.test.ts | 217 ++++++++++++++ src/core/dev/otel/transforms.ts | 276 ++++++++++++++++++ src/core/dev/otel/types.ts | 65 +++++ src/handlers/project/dev/index.test.ts | 90 +++++- src/handlers/project/dev/index.ts | 38 ++- src/handlers/project/dev/types.ts | 13 + src/handlers/project/index.ts | 2 + src/io/httpServer.test.ts | 63 ++++ src/io/httpServer.ts | 119 ++++++++ src/io/index.ts | 8 + src/router/flags.tsx | 8 +- src/router/router.test.ts | 21 ++ 21 files changed, 1637 insertions(+), 18 deletions(-) create mode 100644 src/core/dev/otel/collector.test.ts create mode 100644 src/core/dev/otel/collector.ts create mode 100644 src/core/dev/otel/store.test.ts create mode 100644 src/core/dev/otel/store.ts create mode 100644 src/core/dev/otel/transforms.test.ts create mode 100644 src/core/dev/otel/transforms.ts create mode 100644 src/core/dev/otel/types.ts create mode 100644 src/io/httpServer.test.ts create mode 100644 src/io/httpServer.ts diff --git a/bun.lock b/bun.lock index 28dc8f9c7..34874fa2f 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", "cli-truncate": "^6.1.1", @@ -84,6 +85,24 @@ "@dabh/diagnostics": ["@dabh/diagnostics@2.0.8", "", { "dependencies": { "@so-ric/colorspace": "^1.1.6", "enabled": "2.0.x", "kuler": "^2.0.0" } }, "sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + + "@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-zRM5/Qj6G84Ej3F1yt33xBVY/3tnMxtL1fiDIxYbDWYaZ/eudVw3/PBiZ8G7JwUxXxjW8gU4g6LnOyfGKYHYgw=="], + + "@opentelemetry/core": ["@opentelemetry/core@2.6.0", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, "sha512-HLM1v2cbZ4TgYN6KEOj+Bbj8rAKriOdkF9Ed3tG25FoprSiQl7kYc+RRT6fUZGOvx0oMi5U67GoFdT+XUn8zEg=="], + + "@opentelemetry/otlp-transformer": ["@opentelemetry/otlp-transformer@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/sdk-logs": "0.213.0", "@opentelemetry/sdk-metrics": "2.6.0", "@opentelemetry/sdk-trace-base": "2.6.0", "protobufjs": "^7.0.0" }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-RSuAlxFFPjeK4d5Y6ps8L2WhaQI6CXWllIjvo5nkAlBpmq2XdYWEBGiAbOF4nDs8CX4QblJDv5BbMUft3sEfDw=="], + + "@opentelemetry/resources": ["@opentelemetry/resources@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-D4y/+OGe3JSuYUCBxtH5T9DSAWNcvCb/nQWIga8HNtXTVPQn59j0nTBAgaAXxUVBDl40mG3Tc76b46wPlZaiJQ=="], + + "@opentelemetry/sdk-logs": ["@opentelemetry/sdk-logs@0.213.0", "", { "dependencies": { "@opentelemetry/api-logs": "0.213.0", "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.4.0 <1.10.0" } }, "sha512-00xlU3GZXo3kXKve4DLdrAL0NAFUaZ9appU/mn00S/5kSUdAvyYsORaDUfR04Mp2CLagAOhrzfUvYozY/EZX2g=="], + + "@opentelemetry/sdk-metrics": ["@opentelemetry/sdk-metrics@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.9.0 <1.10.0" } }, "sha512-CicxWZxX6z35HR83jl+PLgtFgUrKRQ9LCXyxgenMnz5A1lgYWfAog7VtdOvGkJYyQgMNPhXQwkYrDLujk7z1Iw=="], + + "@opentelemetry/sdk-trace-base": ["@opentelemetry/sdk-trace-base@2.6.0", "", { "dependencies": { "@opentelemetry/core": "2.6.0", "@opentelemetry/resources": "2.6.0", "@opentelemetry/semantic-conventions": "^1.29.0" }, "peerDependencies": { "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, "sha512-g/OZVkqlxllgFM7qMKqbPV9c1DUPhQ7d4n3pgZFcrnrNft9eJXZM2TNHTPYREJBrtNdRytYyvwjgL5geDKl3EQ=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.74.0", "", { "os": "android", "cpu": "arm" }, "sha512-+gHd12muVI9ZLBaWLPkHt3Fj7jihFjgQ1MGtBaRL8vWrWrI0P7dLUty/cHrHS0oqPYIRgQUJsPu2CExQuMcwNw=="], "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.74.0", "", { "os": "android", "cpu": "arm64" }, "sha512-xjKdoMB+H+RCOByv/7l7nfIGW9mlOisqYdcyC75UqYuQecLpReAeEYUf2CNeDEI3KtmUgxpRw/+c63y4AeF/Bw=="], @@ -122,6 +141,24 @@ "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.74.0", "", { "os": "win32", "cpu": "x64" }, "sha512-VTC9IYTIMrVUk/i6Ms1ohzzDKZFkWn0KU2OBbPBzgmVZ2V30165T/zK4LztTr0Xgp9fZ1qQZ1rsZAu/rEmySlA=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + "@smithy/core": ["@smithy/core@3.29.8", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-rpCbCV+TimOBi3VLNBMmtTvgfOWcFIEAru3+TFlG87SL2F+te4jOnnNR+cf3uR4eJ5Qf4LnT80fqnBKgPRS6zA=="], "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.13", "", { "dependencies": { "@smithy/core": "^3.29.8", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-X+2HNZhWi5i3rJsCas0LPf6fTQUaKyJ40zd8aTO/bwpRfpU3biYaqLr7C1WMibL7PVKJalpi1PyybjGPNoHC8Q=="], @@ -240,6 +277,8 @@ "logform": ["logform@2.7.0", "", { "dependencies": { "@colors/colors": "1.6.0", "@types/triple-beam": "^1.3.2", "fecha": "^4.2.0", "ms": "^2.1.1", "safe-stable-stringify": "^2.3.1", "triple-beam": "^1.3.0" } }, "sha512-TFYA4jnP7PVbmlBIfhlSe+WKxs9dklXMTEGcBCIvLhE/Tn3H6Gk1norupVW7m5Cnd4bLcr08AytbyV/xj7f/kQ=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "mimic-fn": ["mimic-fn@2.1.0", "", {}, "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="], "mimic-function": ["mimic-function@5.0.1", "", {}, "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA=="], @@ -266,6 +305,8 @@ "prettier": ["prettier@3.9.5", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg=="], + "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="], + "react": ["react@19.2.7", "", {}, "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ=="], "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], diff --git a/package.json b/package.json index 603ae810a..31e1cc034 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "@aws-sdk/client-bedrock-agentcore-control": "^3.1102.0", "@aws-sdk/client-cloudwatch-logs": "^3.1092.0", "@aws-sdk/client-iam": "^3.1080.0", + "@opentelemetry/otlp-transformer": "0.213.0", "@smithy/core": "3.29.3", "@tanstack/react-query": "^5.101.2", "cli-truncate": "^6.1.1", diff --git a/src/assets/templates/shared/env.local.template b/src/assets/templates/shared/env.local.template index cc9e703e7..fb931a12b 100644 --- a/src/assets/templates/shared/env.local.template +++ b/src/assets/templates/shared/env.local.template @@ -1,7 +1,11 @@ # Environment variables for local development. # `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. +# CLI owns. While trace collection is on (the default), the CLI also owns the +# OTEL_* and AGENT_OBSERVABILITY_ENABLED variables so traces reach its local +# collector — pass --no-traces (or set instrumentation.enableOtel to false in +# agentcore.json) to disable collection and set your own. +# This file is gitignored — keep secrets out of version control. # # Example: # MY_API_KEY=... diff --git a/src/core/dev/codezip.test.ts b/src/core/dev/codezip.test.ts index cfc1e1bea..93c283cbd 100644 --- a/src/core/dev/codezip.test.ts +++ b/src/core/dev/codezip.test.ts @@ -1,10 +1,10 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { ProjectRuntime } from "../project/schema"; import type { DevEvent, DevServerInput } from "../../handlers/project/dev/types"; -import type { ProcessEvent, ProcessStreamer, StreamProcessOptions } from "../../io"; +import type { ProcessEvent, ProcessRunner, ProcessStreamer, StreamProcessOptions } from "../../io"; import { CodeZipDevRunner } from "./codezip"; type ProcessCall = { @@ -43,15 +43,22 @@ async function projectRoot(withNodeModules = false): Promise { return root; } -function harness(output: ProcessEvent[] = []) { +function harness(output: ProcessEvent[] = [], probe: { dir?: string; fail?: boolean } = {}) { const calls: ProcessCall[] = []; + const probeCalls: string[][] = []; const fakeStreamProcess: ProcessStreamer = async function* (command, options) { calls.push({ command, options }); yield* output; }; + const fakeRunProcess: ProcessRunner = async (command, options) => { + probeCalls.push(command); + if (probe.fail) throw new Error("probe failed"); + options.onOutput?.(`${probe.dir ?? ""}\n`); + }; return { calls, - runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess }), + probeCalls, + runner: new CodeZipDevRunner({ streamProcess: fakeStreamProcess, runProcess: fakeRunProcess }), }; } @@ -153,3 +160,69 @@ describe("CodeZipDevRunner", () => { ]); }); }); + +describe("CodeZipDevRunner OTEL instrumentation", () => { + async function sitecustomizeDir(): Promise { + const directory = await mkdtemp(join(tmpdir(), "otel-site-")); + tempDirectories.push(directory); + await writeFile(join(directory, "sitecustomize.py"), ""); + return directory; + } + + function otelInput(root: string, extraEnv: Record = {}): DevServerInput { + const base = input(root, runtime()); + return { + ...base, + env: { ...base.env, OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318", ...extraEnv }, + }; + } + + test("prepends the sitecustomize directory to PYTHONPATH when instrumentation is installed", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, probeCalls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root))); + + expect(probeCalls[0]?.slice(0, 4)).toEqual(["uv", "run", "python", "-c"]); + expect(calls[0]?.options.env?.PYTHONPATH).toBe(directory); + }); + + test("preserves an existing PYTHONPATH", async () => { + const root = await projectRoot(); + const directory = await sitecustomizeDir(); + const { calls, runner } = harness([], { dir: directory }); + + await collect(runner.run(otelInput(root, { PYTHONPATH: "/existing" }))); + + expect(calls[0]?.options.env?.PYTHONPATH).toBe(`${directory}:/existing`); + }); + + test("does not probe without an OTEL endpoint or for Node entrypoints", async () => { + const root = await projectRoot(true); + const { probeCalls, runner } = harness(); + + await collect(runner.run(input(root, runtime()))); + await collect(runner.run({ ...otelInput(root), runtime: runtime({ entrypoint: "index.js" }) })); + + expect(probeCalls).toEqual([]); + }); + + test.each([ + ["probe failure", { fail: true }], + ["missing sitecustomize.py", { dir: "/nonexistent" }], + ] as const)("warns and starts untraced on %s", async (_case, probe) => { + const root = await projectRoot(); + const { calls, probeCalls, runner } = harness([], probe); + + const events = await collect(runner.run(otelInput(root))); + + expect(probeCalls).toHaveLength(1); + expect(calls).toHaveLength(1); + expect(calls[0]?.options.env?.PYTHONPATH).toBeUndefined(); + expect(events).toContainEqual({ + type: "status", + message: expect.stringContaining("traces will not be collected"), + }); + }); +}); diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index 0b2314cf0..bf4fe759e 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -1,18 +1,27 @@ import { existsSync } from "node:fs"; -import { join } from "node:path"; +import { delimiter, join } from "node:path"; import { InputValidationError } from "../../errors"; import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types"; -import { streamProcess, type ProcessStreamer, type StreamProcessOptions } from "../../io"; +import { + runProcess, + streamProcess, + type ProcessRunner, + type ProcessStreamer, + type StreamProcessOptions, +} from "../../io"; type CodeZipDevRunnerConfig = { streamProcess?: ProcessStreamer; + runProcess?: ProcessRunner; }; export class CodeZipDevRunner implements DevRunner { private readonly streamProcess: ProcessStreamer; + private readonly runProcess: ProcessRunner; constructor(config: CodeZipDevRunnerConfig = {}) { this.streamProcess = config.streamProcess ?? streamProcess; + this.runProcess = config.runProcess ?? runProcess; } public async *run(input: DevServerInput): AsyncGenerator { @@ -33,8 +42,48 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: "Starting development server" }; const serverProcess = commandForRuntime(entrypoint!, directory, input); + if (entrypoint!.endsWith(".py") && input.env?.OTEL_EXPORTER_OTLP_ENDPOINT) { + const sitecustomizeDir = await this.findOtelSitecustomizeDir(directory); + if (sitecustomizeDir) { + const existing = serverProcess.options.env?.PYTHONPATH; + serverProcess.options.env = { + ...serverProcess.options.env, + PYTHONPATH: existing ? `${sitecustomizeDir}${delimiter}${existing}` : sitecustomizeDir, + }; + } else { + yield { + type: "status", + message: + "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add opentelemetry-distro to enable them.", + }; + } + } yield* this.streamProcess(serverProcess.command, serverProcess.options); } + + /** + * Locate the auto-instrumentation sitecustomize.py directory inside the agent's + * uv environment. Prepending it to PYTHONPATH instruments every Python process — + * an `opentelemetry-instrument` wrapper would only instrument uvicorn's reloader + * parent, leaving the re-spawned worker processes untraced. + */ + private async findOtelSitecustomizeDir(directory: string): Promise { + const output: string[] = []; + const probe = + "import opentelemetry.instrumentation.auto_instrumentation as m; import os; print(os.path.dirname(m.__file__))"; + try { + await this.runProcess(["uv", "run", "python", "-c", probe], { + cwd: directory, + onOutput: (chunk) => output.push(chunk), + }); + } catch { + return undefined; + } + const sitecustomizeDir = output.join("").trim().split("\n").at(-1)?.trim(); + if (!sitecustomizeDir || !existsSync(join(sitecustomizeDir, "sitecustomize.py"))) + return undefined; + return sitecustomizeDir; + } } function commandForRuntime( diff --git a/src/core/dev/otel/collector.test.ts b/src/core/dev/otel/collector.test.ts new file mode 100644 index 000000000..fb0f49b6d --- /dev/null +++ b/src/core/dev/otel/collector.test.ts @@ -0,0 +1,167 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + ExportLogsServiceRequest, + ExportTraceServiceRequest, + type OtelCollector, + startOtelCollector, +} from "./collector"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; + +function protobufTracePayload(): Uint8Array { + const message = ExportTraceServiceRequest.fromObject({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + spanId: Buffer.from("0123456789abcdef", "hex"), + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + return ExportTraceServiceRequest.encode(message).finish(); +} + +function protobufLogsPayload(): Uint8Array { + const message = ExportLogsServiceRequest.fromObject({ + resourceLogs: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "proto-agent" } }] }, + scopeLogs: [ + { + scope: { name: "test" }, + logRecords: [ + { + traceId: Buffer.from(TRACE_ID_HEX, "hex"), + timeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + body: { stringValue: "a log line" }, + }, + ], + }, + ], + }, + ], + }); + return ExportLogsServiceRequest.encode(message).finish(); +} + +let directory: string; +let collector: OtelCollector; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "otel-collector-")); + collector = await startOtelCollector({ tracesDirectory: directory }); +}); + +afterEach(async () => { + await collector.close(); + await rm(directory, { recursive: true, force: true }); +}); + +function post( + path: string, + body: string | Uint8Array, + contentType = "application/x-protobuf", +): Promise { + return fetch(`http://127.0.0.1:${collector.port}${path}`, { + method: "POST", + headers: { "Content-Type": contentType }, + body, + }); +} + +describe("startOtelCollector", () => { + test("ingests protobuf trace exports and serves them back through the store", async () => { + const response = await post("/v1/traces", protobufTracePayload()); + expect(response.status).toBe(200); + + const traces = await collector.store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_ID_HEX); + }); + + test("ingests protobuf log exports into the same trace", async () => { + await post("/v1/traces", protobufTracePayload()); + const response = await post("/v1/logs", protobufLogsPayload()); + expect(response.status).toBe(200); + + const detail = await collector.store.get(TRACE_ID_HEX); + expect(detail?.resourceLogs).toBeDefined(); + }); + + test("ingests JSON trace exports", async () => { + const body = JSON.stringify({ + resourceSpans: [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "json-agent" } }] }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId: TRACE_ID_HEX, + spanId: "0123456789abcdef", + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }); + const response = await post("/v1/traces", body, "application/json"); + expect(response.status).toBe(200); + expect((await collector.store.list()).map((trace) => trace.traceId)).toEqual([TRACE_ID_HEX]); + }); + + test("rejects malformed payloads with 400", async () => { + expect((await post("/v1/traces", "not json", "application/json")).status).toBe(400); + expect((await post("/v1/traces", Buffer.from([0xff, 0xff, 0xff]))).status).toBe(400); + expect(await collector.store.list()).toEqual([]); + }); + + test("health check responds ok and unknown routes 404", async () => { + const health = await fetch(`http://127.0.0.1:${collector.port}/`); + expect(await health.json()).toEqual({ status: "ok" }); + expect( + (await fetch(`http://127.0.0.1:${collector.port}/v1/metrics`, { method: "POST" })).status, + ).toBe(404); + }); + + test("envVars point the SDK at the collector", () => { + expect(collector.envVars.OTEL_EXPORTER_OTLP_ENDPOINT).toBe( + `http://127.0.0.1:${collector.port}`, + ); + expect(collector.envVars.OTEL_EXPORTER_OTLP_PROTOCOL).toBe("http/protobuf"); + expect(collector.envVars.OTEL_METRICS_EXPORTER).toBe("none"); + }); + + test("abort signal closes the receiver", async () => { + const controller = new AbortController(); + const aborted = await startOtelCollector({ + tracesDirectory: directory, + signal: controller.signal, + }); + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${aborted.port}/`)).rejects.toThrow(); + }); +}); diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts new file mode 100644 index 000000000..0e1a2d422 --- /dev/null +++ b/src/core/dev/otel/collector.ts @@ -0,0 +1,144 @@ +// Decodes OTLP/HTTP protobuf payloads (the only protocol Python and Node OTEL +// SDKs export over HTTP) with the generated types from @opentelemetry/otlp-transformer. +// The version is pinned: newer releases dropped the generated request decoders. +import root from "@opentelemetry/otlp-transformer/build/src/generated/root"; +import { + type HttpRequest, + type HttpResponse, + type HttpServerStarter, + startHttpServer, +} from "../../../io"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +/** The slice of a generated protobufjs message type the collector (and its tests) use. */ +export interface OtlpMessageType { + decode(data: Uint8Array): unknown; + fromObject(object: object): unknown; + encode(message: unknown): { finish(): Uint8Array }; +} + +// The generated root's declaration file types it as an opaque protobufjs Root, +// so the real static-message shape is asserted once, here. +const { trace, logs } = ( + root as unknown as { + opentelemetry: { + proto: { + collector: { + trace: { v1: { ExportTraceServiceRequest: OtlpMessageType } }; + logs: { v1: { ExportLogsServiceRequest: OtlpMessageType } }; + }; + }; + }; + } +).opentelemetry.proto.collector; + +export const ExportTraceServiceRequest = trace.v1.ExportTraceServiceRequest; +export const ExportLogsServiceRequest = logs.v1.ExportLogsServiceRequest; +type OtlpDecoder = Pick; + +export interface OtelCollector { + /** The loopback port the OTLP/HTTP receiver listens on. */ + port: number; + /** Reads the traces this collector persists. */ + store: TraceStore; + /** Environment variables that point an agent's OTEL SDK at this collector. */ + envVars: Record; + /** Stops the receiver. Also invoked by the start signal, if one was given. */ + close(): Promise; +} + +export interface StartOtelCollectorOptions { + /** Directory to persist OTLP JSON Lines files into. */ + tracesDirectory: string; + /** Closes the collector when aborted. */ + signal?: AbortSignal; + startServer?: HttpServerStarter; +} + +/** + * Starts an in-process OTLP/HTTP receiver for dev mode on an OS-assigned + * loopback port. Accepts `POST /v1/traces` and `POST /v1/logs` in protobuf or + * JSON encoding and appends the raw payloads to a TraceStore. + */ +export async function startOtelCollector( + options: StartOtelCollectorOptions, +): Promise { + const store = new TraceStore(options.tracesDirectory); + const startServer = options.startServer ?? startHttpServer; + const server = await startServer((request) => route(request, store), { signal: options.signal }); + + return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; +} + +async function route(request: HttpRequest, store: TraceStore): Promise { + if (request.method === "POST" && request.url === "/v1/traces") { + return ingest(request, store, ExportTraceServiceRequest); + } + if (request.method === "POST" && request.url === "/v1/logs") { + return ingest(request, store, ExportLogsServiceRequest); + } + if (request.method === "GET" && request.url === "/") { + return json(200, { status: "ok" }); + } + return { status: 404 }; +} + +async function ingest( + request: HttpRequest, + store: TraceStore, + decoder: OtlpDecoder, +): Promise { + let payload: OtlpPayload; + try { + payload = decodePayload(request.body, String(request.headers["content-type"] ?? ""), decoder); + } catch { + return json(400, { error: "Invalid OTLP payload" }); + } + await store.append(payload); + return json(200, {}); +} + +/** + * Decode an OTLP payload. The JSON round-trip on the protobuf path converts the + * message to plain objects (protobufjs renders Long as string and bytes as base64). + */ +function decodePayload(body: Buffer, contentType: string, decoder: OtlpDecoder): OtlpPayload { + if (contentType.includes("application/json")) { + return JSON.parse(body.toString()) as OtlpPayload; + } + return JSON.parse(JSON.stringify(decoder.decode(new Uint8Array(body)))) as OtlpPayload; +} + +/** Environment for a spawned agent so its OTEL SDK exports to the collector at `port`. */ +export function otelEnvVars(port: number): Record { + return { + OTEL_EXPORTER_OTLP_ENDPOINT: `http://127.0.0.1:${port}`, + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + OTEL_METRICS_EXPORTER: "none", + AGENT_OBSERVABILITY_ENABLED: "true", + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT: "true", + OTEL_PYTHON_LOGGING_AUTO_INSTRUMENTATION_ENABLED: "true", + }; +} + +/** + * Rewrite a loopback OTLP endpoint so a containerized agent can reach the + * collector on the host. host.docker.internal resolves on Docker Desktop, + * Finch, and Podman; bare-metal Linux Docker would additionally need + * `--add-host=host.docker.internal:host-gateway` (matches the reference CLI). + */ +export function rewriteOtelEndpointForContainer( + env: Record, +): Record { + const endpoint = env.OTEL_EXPORTER_OTLP_ENDPOINT; + if (!endpoint) return env; + return { + ...env, + OTEL_EXPORTER_OTLP_ENDPOINT: endpoint.replace(/127\.0\.0\.1|localhost/, "host.docker.internal"), + }; +} + +function json(status: number, body: unknown): HttpResponse { + return { status, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }; +} diff --git a/src/core/dev/otel/store.test.ts b/src/core/dev/otel/store.test.ts new file mode 100644 index 000000000..59c5bd694 --- /dev/null +++ b/src/core/dev/otel/store.test.ts @@ -0,0 +1,120 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { TraceStore } from "./store"; +import type { OtlpPayload } from "./types"; + +const TRACE_A = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +const TRACE_B = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + +function payload( + traceId: string, + options: { serviceName?: string; startNano?: string; name?: string } = {}, +): OtlpPayload { + return { + resourceSpans: [ + { + resource: { + attributes: [ + { key: "service.name", value: { stringValue: options.serviceName ?? "agent-1" } }, + ], + }, + scopeSpans: [ + { + scope: { name: "test" }, + spans: [ + { + traceId, + spanId: "0123456789abcdef", + name: options.name ?? "invoke_agent strands", + kind: 1, + startTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + endTimeUnixNano: options.startNano ?? `${BigInt(Date.now()) * 1_000_000n}`, + }, + ], + }, + ], + }, + ], + }; +} + +let directory: string; +let store: TraceStore; + +beforeEach(async () => { + directory = await mkdtemp(join(tmpdir(), "trace-store-")); + store = new TraceStore(directory); +}); + +afterEach(async () => { + await rm(directory, { recursive: true, force: true }); +}); + +describe("TraceStore", () => { + test("append then list returns the trace with metadata", async () => { + await store.append(payload(TRACE_A)); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.traceId).toBe(TRACE_A); + expect(traces[0]!.spanCount).toBe("1"); + expect(traces[0]!.resourceSpans).toBeDefined(); + }); + + test("appends to the same trace accumulate spans", async () => { + await store.append(payload(TRACE_A)); + await store.append(payload(TRACE_A, { name: "tool_use" })); + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("2"); + }); + + test("payloads without a trace id are dropped", async () => { + await store.append({ resourceSpans: [] }); + expect(await store.list()).toEqual([]); + }); + + test("list filters by service name", async () => { + await store.append(payload(TRACE_A, { serviceName: "agent-1" })); + await store.append(payload(TRACE_B, { serviceName: "agent-2" })); + const traces = await store.list({ serviceName: "agent-2" }); + expect(traces.map((trace) => trace.traceId)).toEqual([TRACE_B]); + }); + + test("list filters by time window and sorts newest first", async () => { + const oldNano = `${BigInt(Date.now() - 24 * 60 * 60 * 1000) * 1_000_000n}`; + await store.append(payload(TRACE_A, { startNano: oldNano })); + await store.append(payload(TRACE_B)); + + expect((await store.list()).map((trace) => trace.traceId)).toEqual([TRACE_B]); + + const all = await store.list({ startTime: 0 }); + expect(all.map((trace) => trace.traceId)).toEqual([TRACE_B, TRACE_A]); + }); + + test("get returns the trace detail or undefined for unknown ids", async () => { + await store.append(payload(TRACE_A)); + const detail = await store.get(TRACE_A); + expect(detail?.resourceSpans).toBeDefined(); + expect(await store.get(TRACE_B)).toBeUndefined(); + }); + + test("skips malformed lines and files without failing", async () => { + await store.append(payload(TRACE_A)); + await writeFile(join(directory, `agent-1-${TRACE_A}.otlp.jsonl`), "{not json}\n", { + flag: "a", + }); + await writeFile(join(directory, "garbage.otlp.jsonl"), "also not json\n"); + + const traces = await store.list(); + expect(traces).toHaveLength(1); + expect(traces[0]!.spanCount).toBe("1"); + }); + + test("list on a directory that does not exist returns empty", async () => { + const empty = new TraceStore(join(directory, "missing")); + expect(await empty.list()).toEqual([]); + expect(await empty.get(TRACE_A)).toBeUndefined(); + }); +}); diff --git a/src/core/dev/otel/store.ts b/src/core/dev/otel/store.ts new file mode 100644 index 000000000..2df3ba643 --- /dev/null +++ b/src/core/dev/otel/store.ts @@ -0,0 +1,122 @@ +import { appendFile, mkdir, readFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { buildTraceDetail, extractFirstTraceInfo, extractTraceMeta } from "./transforms"; +import type { OtlpPayload, OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const OTLP_EXT = ".otlp.jsonl"; +const DEFAULT_LIST_WINDOW_MS = 12 * 60 * 60 * 1000; + +export interface TraceSummary { + traceId: string; + timestamp: string; + sessionId?: string; + spanCount: string; + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface TraceDetail { + resourceSpans?: unknown[]; + resourceLogs?: unknown[]; +} + +export interface ListTracesOptions { + serviceName?: string; + startTime?: number; + endTime?: number; +} + +/** + * Append-only local trace storage: one JSON Lines file per trace under the store + * directory, each line a raw OTLP export payload. No in-memory state — reads go + * to disk on demand, which is fine because the inspector only fetches traces on + * user actions. Malformed files and lines are skipped, never fatal. + */ +export class TraceStore { + constructor(private readonly directory: string) {} + + /** Append one OTLP export payload to its trace's file. Payloads without a trace id are dropped. */ + public async append(payload: OtlpPayload): Promise { + const { traceId, serviceName } = extractFirstTraceInfo(payload); + if (!traceId) return; + + await mkdir(this.directory, { recursive: true }); + const fileName = `${sanitize(serviceName ?? "dev")}-${sanitize(traceId)}${OTLP_EXT}`; + await appendFile(join(this.directory, fileName), JSON.stringify(payload) + "\n"); + } + + /** List traces newest-first, filtered by service name and time range (default: last 12 hours). */ + public async list(options: ListTracesOptions = {}): Promise { + const now = Date.now(); + const start = options.startTime ?? now - DEFAULT_LIST_WINDOW_MS; + const end = options.endTime ?? now; + + const summaries: TraceSummary[] = []; + for (const file of await this.traceFiles()) { + const trace = await this.readTraceFile(file); + if (!trace) continue; + + const meta = extractTraceMeta(trace.resourceSpans, trace.resourceLogs); + if (!meta.traceId) continue; + if (meta.lastSeen < start || meta.firstSeen > end) continue; + if (options.serviceName && meta.serviceName !== options.serviceName) continue; + + summaries.push({ + traceId: meta.traceId, + timestamp: new Date(meta.lastSeen).toISOString(), + sessionId: meta.sessionId, + spanCount: String(meta.spanCount), + ...buildTraceDetail(trace.resourceSpans, trace.resourceLogs), + }); + } + + return summaries.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + } + + /** All spans and logs for one trace, or undefined when the trace is unknown. */ + public async get(traceId: string): Promise { + const match = (await this.traceFiles()).find((file) => file.includes(sanitize(traceId))); + if (!match) return undefined; + + const trace = await this.readTraceFile(match); + if (!trace) return undefined; + return buildTraceDetail(trace.resourceSpans, trace.resourceLogs); + } + + private async traceFiles(): Promise { + try { + return (await readdir(this.directory)).filter((file) => file.endsWith(OTLP_EXT)); + } catch { + return []; + } + } + + private async readTraceFile( + fileName: string, + ): Promise<{ resourceSpans: OtlpResourceSpan[]; resourceLogs: OtlpResourceLog[] } | undefined> { + let content: string; + try { + content = await readFile(join(this.directory, fileName), "utf8"); + } catch { + return undefined; + } + + const resourceSpans: OtlpResourceSpan[] = []; + const resourceLogs: OtlpResourceLog[] = []; + for (const line of content.split("\n")) { + if (!line.trim()) continue; + try { + const payload = JSON.parse(line) as OtlpPayload; + if (payload.resourceSpans) resourceSpans.push(...payload.resourceSpans); + if (payload.resourceLogs) resourceLogs.push(...payload.resourceLogs); + } catch { + // Skip malformed lines — a partially written line must not break reads. + } + } + return { resourceSpans, resourceLogs }; + } +} + +function sanitize(value: string): string { + return value.replace(/[^a-zA-Z0-9_-]/g, "_"); +} diff --git a/src/core/dev/otel/transforms.test.ts b/src/core/dev/otel/transforms.test.ts new file mode 100644 index 000000000..7b68ce4c8 --- /dev/null +++ b/src/core/dev/otel/transforms.test.ts @@ -0,0 +1,217 @@ +import { describe, expect, test } from "bun:test"; +import { + buildTraceDetail, + extractAnyValue, + extractFirstTraceInfo, + extractTraceMeta, + flattenAttributes, + hexFromB64OrString, + nanoToMs, +} from "./transforms"; +import type { OtlpResourceLog, OtlpResourceSpan } from "./types"; + +const TRACE_ID_HEX = "0123456789abcdef0123456789abcdef"; +const TRACE_ID_B64 = Buffer.from(TRACE_ID_HEX, "hex").toString("base64"); +const SPAN_ID_HEX = "0123456789abcdef"; + +function resourceSpan(overrides: { serviceName?: string; spans: object[] }): OtlpResourceSpan { + return { + resource: overrides.serviceName + ? { attributes: [{ key: "service.name", value: { stringValue: overrides.serviceName } }] } + : undefined, + scopeSpans: [{ scope: { name: "test-scope" }, spans: overrides.spans }], + }; +} + +const agentSpan = { + traceId: TRACE_ID_B64, + spanId: SPAN_ID_HEX, + name: "invoke_agent strands", + kind: 1, + startTimeUnixNano: "1700000000000000000", + endTimeUnixNano: "1700000001500000000", + attributes: [ + { key: "gen_ai.prompt", value: { stringValue: "hello" } }, + { key: "session.id", value: { stringValue: "session-1" } }, + ], +}; + +describe("extractTraceMeta", () => { + test("collects trace id, time bounds, session, service, and span count", () => { + const meta = extractTraceMeta( + [resourceSpan({ serviceName: "my-agent", spans: [agentSpan] })], + [], + ); + expect(meta).toEqual({ + traceId: TRACE_ID_HEX, + firstSeen: 1700000000000, + lastSeen: 1700000001500, + sessionId: "session-1", + serviceName: "my-agent", + spanCount: 1, + }); + }); + + test("counts log records and falls back to observed time", () => { + const logs: OtlpResourceLog[] = [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "log-agent" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [{ traceId: TRACE_ID_HEX, observedTimeUnixNano: "1700000002000000000" }], + }, + ], + }, + ]; + const meta = extractTraceMeta([], logs); + expect(meta.traceId).toBe(TRACE_ID_HEX); + expect(meta.serviceName).toBe("log-agent"); + expect(meta.spanCount).toBe(1); + expect(meta.firstSeen).toBe(1700000002000); + expect(meta.lastSeen).toBe(1700000002000); + }); + + test("defaults time bounds to now when no timestamps exist", () => { + const before = Date.now(); + const meta = extractTraceMeta([], []); + expect(meta.firstSeen).toBeGreaterThanOrEqual(before); + expect(meta.lastSeen).toBeGreaterThanOrEqual(before); + expect(meta.traceId).toBeUndefined(); + }); +}); + +describe("extractFirstTraceInfo", () => { + test("finds the first span's trace id and service name", () => { + const info = extractFirstTraceInfo({ + resourceSpans: [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + }); + expect(info).toEqual({ traceId: TRACE_ID_HEX, serviceName: "svc" }); + }); + + test("falls back to log records and returns empty when nothing matches", () => { + expect(extractFirstTraceInfo({})).toEqual({}); + const info = extractFirstTraceInfo({ + resourceLogs: [{ scopeLogs: [{ logRecords: [{ traceId: TRACE_ID_HEX }] }] }], + }); + expect(info.traceId).toBe(TRACE_ID_HEX); + }); +}); + +describe("buildTraceDetail", () => { + test("hexes ids, flattens attributes, and unwraps log bodies", () => { + const detail = buildTraceDetail( + [resourceSpan({ serviceName: "svc", spans: [agentSpan] })], + [ + { + resource: { attributes: [{ key: "service.name", value: { stringValue: "svc" } }] }, + scopeLogs: [ + { + scope: {}, + logRecords: [ + { traceId: TRACE_ID_B64, spanId: SPAN_ID_HEX, body: { stringValue: "log line" } }, + ], + }, + ], + }, + ], + ); + + const spans = detail.resourceSpans as { + resource: { attributes: Record }; + scopeSpans: { spans: { traceId: string; attributes: Record }[] }[]; + }[]; + expect(spans[0]!.resource.attributes).toEqual({ "service.name": "svc" }); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.traceId).toBe(TRACE_ID_HEX); + expect(spans[0]!.scopeSpans[0]!.spans[0]!.attributes).toEqual({ + "gen_ai.prompt": "hello", + "session.id": "session-1", + }); + + const logs = detail.resourceLogs as { + scopeLogs: { logRecords: { traceId: string; body: unknown }[] }[]; + }[]; + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.traceId).toBe(TRACE_ID_HEX); + expect(logs[0]!.scopeLogs[0]!.logRecords[0]!.body).toBe("log line"); + }); + + test("filters transport noise but keeps meaningful spans", () => { + const noiseSpans = [ + { name: "GET / http send", attributes: [] }, + { + name: "http.request", + attributes: [{ key: "asgi.event.type", value: { stringValue: "http.request" } }], + }, + { name: "POST", kind: 3, attributes: [] }, + { + name: "POST /invocations", + kind: 2, + attributes: [{ key: "http.method", value: { stringValue: "POST" } }], + }, + ]; + const detail = buildTraceDetail([resourceSpan({ spans: [...noiseSpans, agentSpan] })], []); + const spans = detail.resourceSpans as { scopeSpans: { spans: { name: string }[] }[] }[]; + expect(spans[0]!.scopeSpans[0]!.spans.map((span) => span.name)).toEqual([ + "invoke_agent strands", + ]); + }); + + test("string span kinds from JSON ingest are normalized before filtering", () => { + const detail = buildTraceDetail( + [resourceSpan({ spans: [{ name: "POST", kind: "SPAN_KIND_CLIENT", attributes: [] }] })], + [], + ); + expect(detail.resourceSpans).toBeUndefined(); + }); + + test("returns undefined sections when everything is filtered or empty", () => { + expect(buildTraceDetail([], [])).toEqual({ resourceSpans: undefined, resourceLogs: undefined }); + }); +}); + +describe("helpers", () => { + test("nanoToMs converts and handles absence", () => { + expect(nanoToMs("1700000000123456789")).toBe(1700000000123); + expect(nanoToMs(undefined)).toBe(0); + }); + + test("hexFromB64OrString accepts hex, base64, and empty", () => { + expect(hexFromB64OrString(TRACE_ID_HEX.toUpperCase())).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(TRACE_ID_B64)).toBe(TRACE_ID_HEX); + expect(hexFromB64OrString(undefined)).toBe(""); + }); + + test("flattenAttributes handles typed values, arrays, and flat passthrough", () => { + expect( + flattenAttributes([ + { key: "s", value: { stringValue: "x" } }, + { key: "i", value: { intValue: "42" } }, + { key: "d", value: { doubleValue: 1.5 } }, + { key: "b", value: { boolValue: true } }, + { key: "a", value: { arrayValue: { values: [{ stringValue: "y" }, { intValue: "7" }] } } }, + { key: "skipped" }, + ]), + ).toEqual({ s: "x", i: 42, d: 1.5, b: true, a: ["y", "7"] }); + expect(flattenAttributes({ already: "flat" })).toEqual({ already: "flat" }); + expect(flattenAttributes([])).toBeUndefined(); + expect(flattenAttributes(undefined)).toBeUndefined(); + }); + + test("extractAnyValue unwraps nested kvlist and array values", () => { + expect( + extractAnyValue({ + kvlistValue: { + values: [ + { + key: "nested", + value: { arrayValue: { values: [{ intValue: "1" }, { boolValue: false }] } }, + }, + { key: "plain", value: { stringValue: "v" } }, + ], + }, + }), + ).toEqual({ nested: [1, false], plain: "v" }); + expect(extractAnyValue("passthrough")).toBe("passthrough"); + expect(extractAnyValue(null)).toBeNull(); + }); +}); diff --git a/src/core/dev/otel/transforms.ts b/src/core/dev/otel/transforms.ts new file mode 100644 index 000000000..b2c814b40 --- /dev/null +++ b/src/core/dev/otel/transforms.ts @@ -0,0 +1,276 @@ +import type { + OtlpAttributes, + OtlpAttributeValue, + OtlpPayload, + OtlpResource, + OtlpResourceLog, + OtlpResourceSpan, +} from "./types"; + +export interface TraceMeta { + traceId?: string; + firstSeen: number; + lastSeen: number; + sessionId?: string; + serviceName?: string; + spanCount: number; +} + +/** Extract listing metadata (trace id, time bounds, session, service, count) from raw OTLP arrays. */ +export function extractTraceMeta( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): TraceMeta { + const meta: TraceMeta = { firstSeen: Infinity, lastSeen: 0, spanCount: 0 }; + + for (const resourceSpan of resourceSpans) { + meta.serviceName ??= getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(span.traceId) || undefined; + widenTimeBounds(meta, nanoToMs(span.startTimeUnixNano)); + widenTimeBounds(meta, nanoToMs(span.endTimeUnixNano)); + meta.sessionId ??= + getAttributeValue(span.attributes, "session.id") ?? + getAttributeValue(span.attributes, "attributes.session.id"); + } + } + } + + for (const resourceLog of resourceLogs) { + meta.serviceName ??= getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + meta.spanCount++; + meta.traceId ??= hexFromB64OrString(record.traceId) || undefined; + widenTimeBounds( + meta, + nanoToMs(record.timeUnixNano) || nanoToMs(record.observedTimeUnixNano), + ); + } + } + } + + const now = Date.now(); + if (meta.firstSeen === Infinity) meta.firstSeen = now; + if (meta.lastSeen === 0) meta.lastSeen = now; + return meta; +} + +/** Extract the traceId and serviceName of the first span or log record in a payload. */ +export function extractFirstTraceInfo(payload: OtlpPayload): { + traceId?: string; + serviceName?: string; +} { + for (const resourceSpan of payload.resourceSpans ?? []) { + const serviceName = getResourceAttribute(resourceSpan.resource, "service.name"); + for (const scopeSpan of resourceSpan.scopeSpans ?? []) { + for (const span of scopeSpan.spans ?? []) { + if (span.traceId) return { traceId: hexFromB64OrString(span.traceId), serviceName }; + } + } + } + for (const resourceLog of payload.resourceLogs ?? []) { + const serviceName = getResourceAttribute(resourceLog.resource, "service.name"); + for (const scopeLog of resourceLog.scopeLogs ?? []) { + for (const record of scopeLog.logRecords ?? []) { + if (record.traceId) return { traceId: hexFromB64OrString(record.traceId), serviceName }; + } + } + } + return {}; +} + +/** + * Build frontend-ready trace detail from raw OTLP arrays: ids to hex, attributes + * flattened to plain records, transport-noise spans dropped, log bodies unwrapped. + */ +export function buildTraceDetail( + resourceSpans: OtlpResourceSpan[], + resourceLogs: OtlpResourceLog[], +): { resourceSpans?: unknown[]; resourceLogs?: unknown[] } { + const spans = resourceSpans + .map((resourceSpan) => ({ + resource: resourceSpan.resource + ? { attributes: flattenAttributes(resourceSpan.resource.attributes) } + : undefined, + scopeSpans: resourceSpan.scopeSpans + ?.map((scopeSpan) => ({ + scope: scopeSpan.scope, + spans: scopeSpan.spans + ?.map((span) => ({ + ...span, + traceId: hexFromB64OrString(span.traceId), + spanId: hexFromB64OrString(span.spanId), + parentSpanId: hexFromB64OrString(span.parentSpanId), + attributes: flattenAttributes(span.attributes), + })) + .filter((span) => isMeaningfulSpan(span)), + })) + .filter((scopeSpan) => scopeSpan.spans && scopeSpan.spans.length > 0), + })) + .filter((resourceSpan) => resourceSpan.scopeSpans && resourceSpan.scopeSpans.length > 0); + + const logs = resourceLogs + .map((resourceLog) => ({ + resource: resourceLog.resource + ? { attributes: flattenAttributes(resourceLog.resource.attributes) } + : undefined, + scopeLogs: resourceLog.scopeLogs?.map((scopeLog) => ({ + scope: scopeLog.scope, + logRecords: scopeLog.logRecords?.map((record) => ({ + ...record, + traceId: hexFromB64OrString(record.traceId), + spanId: hexFromB64OrString(record.spanId), + body: record.body === undefined ? undefined : extractAnyValue(record.body), + attributes: flattenAttributes(record.attributes), + })), + })), + })) + .filter((resourceLog) => resourceLog.scopeLogs && resourceLog.scopeLogs.length > 0); + + return { + resourceSpans: spans.length > 0 ? spans : undefined, + resourceLogs: logs.length > 0 ? logs : undefined, + }; +} + +/** + * Whether a span carries application-level signal. Filters ASGI transport events, + * bare HTTP client/server noise, and other framework spans that add nothing in the UI. + */ +function isMeaningfulSpan(span: { + name?: string; + kind?: number | string; + attributes?: Record; +}): boolean { + const name = span.name ?? ""; + const attributes = span.attributes ?? {}; + const kind = normalizeSpanKind(span.kind); + + if (name.endsWith(" http send") || name.endsWith(" http receive")) return false; + if (attributes["asgi.event.type"]) return false; + if (Object.keys(attributes).some((key) => key.startsWith("gen_ai."))) return true; + if (attributes["rpc.system"] || attributes["rpc.method"]) return true; + + const scopeHints = ["strands", "bedrock", "langchain", "crewai", "autogen", "google_adk"]; + if (scopeHints.some((hint) => name.toLowerCase().includes(hint))) return true; + if (name === "tool_use" || name === "tool_call" || attributes["tool.name"]) return true; + + if (kind === SPAN_KIND.CLIENT && (name === "POST" || name === "GET" || name.startsWith("HTTP "))) + return false; + if (kind === SPAN_KIND.SERVER && name.startsWith("POST /") && attributes["http.method"]) + return false; + + return true; +} + +const SPAN_KIND = { INTERNAL: 1, SERVER: 2, CLIENT: 3, PRODUCER: 4, CONSUMER: 5 } as const; + +/** Normalize a span kind from its protobuf enum name or number to the numeric value. */ +function normalizeSpanKind(kind: number | string | undefined): number { + if (typeof kind === "number") return kind; + if (typeof kind === "string") { + const name = kind.replace(/^SPAN_KIND_/, "") as keyof typeof SPAN_KIND; + return SPAN_KIND[name] ?? 0; + } + return 0; +} + +/** Convert a nanosecond timestamp string to milliseconds (0 when absent). */ +export function nanoToMs(nano: string | undefined): number { + if (!nano) return 0; + return Math.floor(Number(nano) / 1_000_000); +} + +/** + * Normalize a trace/span id that may be base64 (protobuf JSON conversion) or + * already hex (JSON ingest) into lowercase hex. + */ +export function hexFromB64OrString(value: string | undefined): string { + if (!value) return ""; + if (/^[0-9a-f]+$/i.test(value) && (value.length === 32 || value.length === 16)) + return value.toLowerCase(); + try { + return Buffer.from(value, "base64").toString("hex"); + } catch { + return value; + } +} + +/** Flatten OTLP attributes into a plain record; passes already-flat records through. */ +export function flattenAttributes( + attributes: OtlpAttributes | undefined, +): Record | undefined { + if (!attributes) return undefined; + if (!Array.isArray(attributes)) return attributes; + if (attributes.length === 0) return undefined; + + const flat: Record = {}; + for (const attribute of attributes) { + if (!attribute.value) continue; + const value = attribute.value; + if (value.stringValue !== undefined) flat[attribute.key] = value.stringValue; + else if (value.intValue !== undefined) flat[attribute.key] = Number(value.intValue); + else if (value.doubleValue !== undefined) flat[attribute.key] = value.doubleValue; + else if (value.boolValue !== undefined) flat[attribute.key] = value.boolValue; + else if (value.arrayValue?.values) { + flat[attribute.key] = value.arrayValue.values.map( + (item: OtlpAttributeValue) => + item.stringValue ?? item.intValue ?? item.doubleValue ?? item.boolValue ?? null, + ); + } + } + return flat; +} + +/** Unwrap an OTLP AnyValue (string/int/double/bool/array/kvlist) into a plain value. */ +export function extractAnyValue(value: unknown): unknown { + if (!value || typeof value !== "object") return value; + const anyValue = value as Record; + if (anyValue.stringValue !== undefined) return anyValue.stringValue; + if (anyValue.intValue !== undefined) return Number(anyValue.intValue); + if (anyValue.doubleValue !== undefined) return anyValue.doubleValue; + if (anyValue.boolValue !== undefined) return anyValue.boolValue; + if (anyValue.arrayValue && typeof anyValue.arrayValue === "object") { + const { values } = anyValue.arrayValue as { values?: unknown[] }; + return (values ?? []).map(extractAnyValue); + } + if (anyValue.kvlistValue && typeof anyValue.kvlistValue === "object") { + const { values } = anyValue.kvlistValue as { values?: { key: string; value?: unknown }[] }; + const record: Record = {}; + for (const entry of values ?? []) { + record[entry.key] = entry.value === undefined ? undefined : extractAnyValue(entry.value); + } + return record; + } + return value; +} + +function getResourceAttribute(resource: OtlpResource | undefined, key: string): string | undefined { + return getAttributeValue(resource?.attributes, key); +} + +function getAttributeValue( + attributes: OtlpAttributes | undefined, + key: string, +): string | undefined { + if (!attributes) return undefined; + if (Array.isArray(attributes)) { + const attribute = attributes.find((entry) => entry.key === key); + if (!attribute?.value) return undefined; + return ( + attribute.value.stringValue ?? + (attribute.value.intValue != null ? String(attribute.value.intValue) : undefined) + ); + } + const value = attributes[key]; + return typeof value === "string" ? value : undefined; +} + +function widenTimeBounds(meta: TraceMeta, timeMs: number): void { + if (!timeMs) return; + if (timeMs < meta.firstSeen) meta.firstSeen = timeMs; + if (timeMs > meta.lastSeen) meta.lastSeen = timeMs; +} diff --git a/src/core/dev/otel/types.ts b/src/core/dev/otel/types.ts new file mode 100644 index 000000000..a458de9d4 --- /dev/null +++ b/src/core/dev/otel/types.ts @@ -0,0 +1,65 @@ +/** + * Wire shapes for OTLP/HTTP payloads after protobuf JSON conversion or JSON ingest. + * Attributes appear either as OTLP key/value arrays (from the SDK exporters) or as + * already-flat records (after our own flattening) — helpers accept both. + */ + +export interface OtlpAttributeValue { + stringValue?: string; + intValue?: string; + doubleValue?: number; + boolValue?: boolean; + arrayValue?: { values?: OtlpAttributeValue[] }; + kvlistValue?: { values?: OtlpAttribute[] }; +} + +export interface OtlpAttribute { + key: string; + value?: OtlpAttributeValue; +} + +export type OtlpAttributes = OtlpAttribute[] | Record; + +export interface OtlpResource { + attributes?: OtlpAttributes; +} + +export interface OtlpSpan { + traceId?: string; + spanId?: string; + parentSpanId?: string; + name?: string; + kind?: number | string; + startTimeUnixNano?: string; + endTimeUnixNano?: string; + attributes?: OtlpAttributes; + status?: { code?: number; message?: string }; + events?: unknown[]; +} + +export interface OtlpResourceSpan { + resource?: OtlpResource; + scopeSpans?: { scope?: { name?: string; version?: string }; spans?: OtlpSpan[] }[]; +} + +export interface OtlpLogRecord { + timeUnixNano?: string; + observedTimeUnixNano?: string; + severityNumber?: number; + severityText?: string; + body?: unknown; + attributes?: OtlpAttributes; + traceId?: string; + spanId?: string; +} + +export interface OtlpResourceLog { + resource?: OtlpResource; + scopeLogs?: { scope?: { name?: string; version?: string }; logRecords?: OtlpLogRecord[] }[]; +} + +/** One OTLP export payload: what a single POST /v1/traces or /v1/logs carries. */ +export interface OtlpPayload { + resourceSpans?: OtlpResourceSpan[]; + resourceLogs?: OtlpResourceLog[]; +} diff --git a/src/handlers/project/dev/index.test.ts b/src/handlers/project/dev/index.test.ts index 643544b52..060afa949 100644 --- a/src/handlers/project/dev/index.test.ts +++ b/src/handlers/project/dev/index.test.ts @@ -8,7 +8,7 @@ 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"; +import type { DevEvent, DevRunner, DevServerInput, DevTraceCollector } from "./types"; function runtime(name = "orders", build: ProjectRuntime["build"] = "CodeZip"): ProjectRuntime { return { @@ -35,6 +35,26 @@ function captureRunner(events: DevEvent[] = []) { return { runner, inputs }; } +function fakeCollector() { + const starts: { tracesDirectory: string; signal?: AbortSignal }[] = []; + const state = { closed: 0 }; + const collector: DevTraceCollector = { + port: 43180, + envVars: { + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_EXPORTER_OTLP_PROTOCOL: "http/protobuf", + }, + close: async () => { + state.closed++; + }, + }; + const start: DevProjectHandlerConfig["startTraceCollector"] = async (options) => { + starts.push(options); + return collector; + }; + return { start, starts, state }; +} + type HarnessOptions = { project?: Project; codeZip?: ReturnType; @@ -49,6 +69,7 @@ function harness(options: HarnessOptions = {}) { const io = testIO(); const codeZip = options.codeZip ?? captureRunner(); const container = options.container ?? captureRunner(); + const collector = fakeCollector(); const environmentInputs: DevEnvironmentInput[] = []; const handler = createDevProjectHandler({ io: io.io, @@ -60,6 +81,7 @@ function harness(options: HarnessOptions = {}) { return { env: { FROM_LOADER: "yes" } }; }), checkPort: options.checkPort ?? (async () => true), + startTraceCollector: collector.start, forceExit: options.forceExit ?? (() => process.exit(130)), }); const ctx = ValueContext.EmptyContext() @@ -74,9 +96,11 @@ function harness(options: HarnessOptions = {}) { return { codeZip, container, + collector, environmentInputs, io, - run: (flags: { agent?: string; port?: number } = {}) => handler.handle(ctx, flags, {}), + run: (flags: { agent?: string; port?: number; traces?: boolean } = {}) => + handler.handle(ctx, { traces: true, ...flags }, {}), }; } @@ -114,7 +138,11 @@ describe("project dev selection and dispatch", () => { expect(subject.container.inputs[0]).toMatchObject({ projectRoot: "/workspace/project", port: 4567, - env: { FROM_LOADER: "yes" }, + env: { + FROM_LOADER: "yes", + OTEL_EXPORTER_OTLP_ENDPOINT: "http://host.docker.internal:43180", + OTEL_SERVICE_NAME: "support", + }, runtime: { name: "support", build: "Container" }, }); }); @@ -131,7 +159,56 @@ describe("project dev selection and dispatch", () => { 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."); + expect(subject.io.stderr()).toContain("Port 8080 is in use; using 8081."); + }); +}); + +describe("project dev trace collection", () => { + test("starts the collector, announces it, and points a CodeZip agent at loopback", async () => { + const subject = harness(); + await subject.run(); + + expect(subject.collector.starts).toEqual([ + { + tracesDirectory: "/workspace/project/agentcore/.cli/traces/otlp", + signal: expect.any(AbortSignal), + }, + ]); + expect(subject.io.stderr()).toContain("OTEL collector listening on port 43180"); + expect(subject.codeZip.inputs[0]?.env).toMatchObject({ + OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:43180", + OTEL_SERVICE_NAME: "orders", + }); + expect(subject.collector.state.closed).toBe(1); + }); + + test("--no-traces skips the collector entirely", async () => { + const subject = harness(); + await subject.run({ traces: false }); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("a runtime with instrumentation disabled skips the collector", async () => { + const disabled = { ...runtime(), instrumentation: { enableOtel: false } } as ProjectRuntime; + const subject = harness({ project: project(disabled) }); + await subject.run(); + + expect(subject.collector.starts).toHaveLength(0); + expect(subject.codeZip.inputs[0]?.env).toEqual({ FROM_LOADER: "yes" }); + }); + + test("the collector is closed when the runner fails", async () => { + const codeZip = captureRunner(); + codeZip.runner.run = async function* () { + yield* []; + throw new InputValidationError("runner failed"); + }; + const subject = harness({ codeZip }); + + await expect(subject.run()).rejects.toThrow("runner failed"); + expect(subject.collector.state.closed).toBe(1); }); }); @@ -144,7 +221,7 @@ test("project dev renders human and NDJSON output", async () => { for (const json of [false, true]) { const subject = harness({ codeZip: captureRunner(events), json }); - await subject.run(); + await subject.run({ traces: false }); expect(subject.io.stdout()).toBe( json ? events.map((event) => JSON.stringify(event)).join("\n") : "agent output", ); @@ -186,7 +263,8 @@ describe("project dev interruption", () => { reported: true, exitCode: 130, }); - expect(subject.io.stderr()).toBe("Shutting down… (press Ctrl-C again to force)"); + expect(subject.io.stderr()).toContain("Shutting down… (press Ctrl-C again to force)"); + expect(subject.collector.state.closed).toBe(1); expect(process.listenerCount(signal)).toBe(before); }, ); diff --git a/src/handlers/project/dev/index.ts b/src/handlers/project/dev/index.ts index 530166abd..c7ffbbf58 100644 --- a/src/handlers/project/dev/index.ts +++ b/src/handlers/project/dev/index.ts @@ -1,4 +1,6 @@ +import { join } from "node:path"; import z from "zod"; +import { rewriteOtelEndpointForContainer } from "../../../core/dev/otel/collector"; import { resolveDevPort } from "../../../core/dev/port"; import type { ProjectRuntime } from "../../../core/project/schema"; import { CommandInterruptedError, InputValidationError } from "../../../errors"; @@ -7,16 +9,26 @@ 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"; +import type { DevEvent, DevRunner, DevTraceCollector, DevTraceCollectorStarter } from "./types"; export type DevProjectHandlerConfig = { io: AppIO; runners: { CodeZip: DevRunner; Container: DevRunner }; loadDevEnvironment: DevEnvironmentLoader; checkPort: PortChecker; + startTraceCollector: DevTraceCollectorStarter; forceExit: () => never; }; +/** Env for a spawned agent so its OTEL SDK reports to the collector as this runtime. */ +function otelEnvForRuntime( + collector: DevTraceCollector, + runtime: ProjectRuntime, +): Record { + const env = { ...collector.envVars, OTEL_SERVICE_NAME: runtime.name }; + return runtime.build === "Container" ? rewriteOtelEndpointForContainer(env) : env; +} + function selectRuntime(project: Project, name?: string): ProjectRuntime { if (project.runtimes.length === 0) { throw new InputValidationError( @@ -60,6 +72,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => "port for the development server", z.coerce.number().int().min(1).max(65535).optional(), ), + flag("traces", "disable local OTEL trace collection", z.boolean().default(true)), ], handle: async (ctx, flags) => { const controller = new AbortController(); @@ -72,6 +85,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => const signals = ["SIGINT", "SIGTERM"] as const; for (const signal of signals) process.on(signal, interrupt); + let collector: DevTraceCollector | undefined; try { const project = ctx.require(ProjectKey); const runtime = selectRuntime(project, flags.agent); @@ -99,12 +113,31 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => }); controller.signal.throwIfAborted(); + let otelEnv: Record = {}; + if (flags.traces && (runtime.instrumentation?.enableOtel ?? true)) { + const tracesDirectory = join(project.rootPath, "agentcore", ".cli", "traces", "otlp"); + collector = await config.startTraceCollector({ + tracesDirectory, + signal: controller.signal, + }); + otelEnv = otelEnvForRuntime(collector, runtime); + renderEvent( + config.io, + { + type: "status", + message: `OTEL collector listening on port ${collector.port}; traces persist to ${tracesDirectory}.`, + }, + json, + ); + } + controller.signal.throwIfAborted(); + const runner = config.runners[runtime.build]; for await (const event of runner.run({ runtime, projectRoot: project.rootPath, port: devPort.port, - env, + env: { ...env, ...otelEnv }, signal: controller.signal, })) { renderEvent(config.io, event, json); @@ -114,6 +147,7 @@ export const createDevProjectHandler = (config: DevProjectHandlerConfig) => throw new CommandInterruptedError(error, true); } finally { for (const signal of signals) process.removeListener(signal, interrupt); + await collector?.close(); } }, }); diff --git a/src/handlers/project/dev/types.ts b/src/handlers/project/dev/types.ts index 7fef32a65..9e5d54258 100644 --- a/src/handlers/project/dev/types.ts +++ b/src/handlers/project/dev/types.ts @@ -16,3 +16,16 @@ export type DevServerInput = { export interface DevRunner { run(input: DevServerInput): AsyncGenerator; } + +/** A local OTLP receiver that spawned agents export traces to. */ +export interface DevTraceCollector { + port: number; + /** Environment variables that point an agent's OTEL SDK at the receiver. */ + envVars: Record; + close(): Promise; +} + +export type DevTraceCollectorStarter = (options: { + tracesDirectory: string; + signal?: AbortSignal; +}) => Promise; diff --git a/src/handlers/project/index.ts b/src/handlers/project/index.ts index 6818614a9..ff45963a7 100644 --- a/src/handlers/project/index.ts +++ b/src/handlers/project/index.ts @@ -2,6 +2,7 @@ import { Router } from "../../router"; import { checkPort, loadDevEnvironment, type AppIO } from "../../io"; import { CodeZipDevRunner } from "../../core/dev/codezip"; import { ContainerDevRunner } from "../../core/dev/container"; +import { startOtelCollector } from "../../core/dev/otel/collector"; import { withProject } from "../../middleware"; import { createCreateProjectHandler } from "./create"; import { createAddProjectHandler } from "./add"; @@ -35,6 +36,7 @@ export function createProjectHandler(config: ProjectHandlerConfig): Router { }, loadDevEnvironment, checkPort, + startTraceCollector: startOtelCollector, forceExit: () => process.exit(130), }), ), diff --git a/src/io/httpServer.test.ts b/src/io/httpServer.test.ts new file mode 100644 index 000000000..770afb5c2 --- /dev/null +++ b/src/io/httpServer.test.ts @@ -0,0 +1,63 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { type HttpServerHandle, startHttpServer } from "./httpServer"; + +let handle: HttpServerHandle | undefined; + +afterEach(async () => { + await handle?.close(); + handle = undefined; +}); + +describe("startHttpServer", () => { + test("serves requests on an OS-assigned loopback port", async () => { + handle = await startHttpServer((request) => ({ + status: 200, + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + method: request.method, + url: request.url, + body: request.body.toString(), + }), + })); + + expect(handle.port).toBeGreaterThan(0); + const response = await fetch(`http://127.0.0.1:${handle.port}/v1/traces`, { + method: "POST", + body: "ping", + }); + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ method: "POST", url: "/v1/traces", body: "ping" }); + }); + + test("handler errors become 500s without crashing the server", async () => { + handle = await startHttpServer(() => { + throw new Error("boom"); + }); + + const response = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(response.status).toBe(500); + + const again = await fetch(`http://127.0.0.1:${handle.port}/`); + expect(again.status).toBe(500); + }); + + test("aborting the signal closes the server", async () => { + const controller = new AbortController(); + const server = await startHttpServer(() => ({ status: 200 }), { signal: controller.signal }); + + controller.abort(); + await Bun.sleep(20); + expect(fetch(`http://127.0.0.1:${server.port}/`)).rejects.toThrow(); + }); + + test("close is idempotent", async () => { + const server = await startHttpServer(() => ({ status: 200 })); + await server.close(); + await server.close(); + }); + + test("listen failure rejects instead of hanging", async () => { + handle = await startHttpServer(() => ({ status: 200 })); + expect(startHttpServer(() => ({ status: 200 }), { port: handle.port })).rejects.toThrow(); + }); +}); diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts new file mode 100644 index 000000000..d95ee831a --- /dev/null +++ b/src/io/httpServer.ts @@ -0,0 +1,119 @@ +// Uses node:http rather than Bun.serve because the npm bundle targets Node, +// where Bun APIs are absent (same constraint as exec.ts). +import { + type IncomingHttpHeaders, + type IncomingMessage, + type Server, + type ServerResponse, + createServer, +} from "node:http"; + +/** Cap request bodies so a runaway local client cannot exhaust memory. */ +const MAX_BODY_BYTES = 50 * 1024 * 1024; + +export interface HttpRequest { + method: string; + url: string; + headers: IncomingHttpHeaders; + body: Buffer; +} + +export interface HttpResponse { + status: number; + headers?: Record; + body?: string | Buffer; +} + +export type HttpRequestHandler = (request: HttpRequest) => HttpResponse | Promise; + +export interface HttpServerHandle { + /** The port the server is listening on. */ + port: number; + /** Stops accepting connections and closes active ones. Idempotent. */ + close(): Promise; +} + +export type HttpServerStarter = typeof startHttpServer; + +/** + * Starts a loopback-only HTTP server for local dev tooling. Binds 127.0.0.1 on + * the given port (0 lets the OS assign one). Handler errors become plain 500s; + * oversized bodies become 413s. Aborting the signal closes the server. + */ +export async function startHttpServer( + handler: HttpRequestHandler, + options: { port?: number; signal?: AbortSignal } = {}, +): Promise { + const server = createServer((request, response) => { + void respond(handler, request, response); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(options.port ?? 0, "127.0.0.1", resolve); + }); + + const address = server.address(); + const port = typeof address === "object" && address !== null ? address.port : 0; + + const close = () => closeServer(server); + options.signal?.addEventListener("abort", () => void close(), { once: true }); + + return { port, close }; +} + +async function respond( + handler: HttpRequestHandler, + request: IncomingMessage, + response: ServerResponse, +): Promise { + let body: Buffer; + try { + body = await readBody(request); + } catch (error) { + const status = error instanceof BodyTooLargeError ? 413 : 400; + response.writeHead(status).end(); + return; + } + + try { + const result = await handler({ + method: request.method ?? "GET", + url: request.url ?? "/", + headers: request.headers, + body, + }); + response.writeHead(result.status, result.headers); + response.end(result.body); + } catch { + response.writeHead(500, { "Content-Type": "application/json" }); + response.end(JSON.stringify({ error: "internal error" })); + } +} + +class BodyTooLargeError extends Error {} + +function readBody(request: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + request.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY_BYTES) { + request.destroy(); + reject(new BodyTooLargeError()); + return; + } + chunks.push(chunk); + }); + request.on("end", () => resolve(Buffer.concat(chunks))); + request.on("error", reject); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + server.closeAllConnections(); + }); +} diff --git a/src/io/index.ts b/src/io/index.ts index 3e7993415..7fe644c1c 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -29,3 +29,11 @@ export { type DevEnvironmentLoader, } from "./devEnvironment"; export { checkPort, type PortChecker } from "./port"; +export { + startHttpServer, + type HttpRequest, + type HttpRequestHandler, + type HttpResponse, + type HttpServerHandle, + type HttpServerStarter, +} from "./httpServer"; diff --git a/src/router/flags.tsx b/src/router/flags.tsx index 2b690d094..5b26a8978 100644 --- a/src/router/flags.tsx +++ b/src/router/flags.tsx @@ -5,15 +5,17 @@ import type { Flag, GlobalFlag } from "./handler"; import { coerce, formatZodError, inspect } from "./schema"; // toOption builds a Commander Option from a flag's schema. Booleans become value-less -// toggles; everything else takes a value (`` / variadic ``). A -// required, non-boolean flag is made mandatory; defaults are forwarded. +// toggles — declared as `--no-` when they default to true, so the flag turns +// the behavior off (Commander's negation stores the value under the positive name). +// Everything else takes a value (`` / variadic ``). A required, +// non-boolean flag is made mandatory; defaults are forwarded. export function toOption(flag: Flag): Option { const info = inspect(flag.schema); const long = `--${flag.name}`; let token: string; if (info.boolean) { - token = long; + token = info.hasDefault && info.defaultValue === true ? `--no-${flag.name}` : long; } else if (info.variadic) { token = `${long} <${flag.name}...>`; } else { diff --git a/src/router/router.test.ts b/src/router/router.test.ts index 5b17f96d9..dba80053b 100644 --- a/src/router/router.test.ts +++ b/src/router/router.test.ts @@ -284,6 +284,27 @@ test("boolean flags default to false when omitted", async () => { expect(seen).toEqual({ verbose: false }); }); +test("a boolean flag defaulting to true is declared as its --no- negation", async () => { + const seen: { traces: boolean }[] = []; + + const run = createHandler({ + name: "run", + description: "", + flags: [flag("traces", "collect traces", z.boolean().default(true))], + handle: async (_ctx, flags) => { + seen.push(flags); + }, + }); + + const root = new Router("app"); + root.handler(run); + + await root.route(["node", "app", "run"]); + await root.route(["node", "app", "run", "--no-traces"]); + + expect(seen).toEqual([{ traces: true }, { traces: false }]); +}); + test("applies a schema default for an omitted flag", async () => { let seen: { count: number } | undefined; From 61e0ea1cca9bd2d2c1bb2b16ece076645b279096 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:42:46 -0400 Subject: [PATCH 6/7] refactor(dev): drop unused collector server injection seam --- src/core/dev/otel/collector.ts | 13 ++++--------- src/io/httpServer.ts | 2 -- src/io/index.ts | 1 - 3 files changed, 4 insertions(+), 12 deletions(-) diff --git a/src/core/dev/otel/collector.ts b/src/core/dev/otel/collector.ts index 0e1a2d422..e3b0191f2 100644 --- a/src/core/dev/otel/collector.ts +++ b/src/core/dev/otel/collector.ts @@ -2,12 +2,7 @@ // SDKs export over HTTP) with the generated types from @opentelemetry/otlp-transformer. // The version is pinned: newer releases dropped the generated request decoders. import root from "@opentelemetry/otlp-transformer/build/src/generated/root"; -import { - type HttpRequest, - type HttpResponse, - type HttpServerStarter, - startHttpServer, -} from "../../../io"; +import { type HttpRequest, type HttpResponse, startHttpServer } from "../../../io"; import { TraceStore } from "./store"; import type { OtlpPayload } from "./types"; @@ -53,7 +48,6 @@ export interface StartOtelCollectorOptions { tracesDirectory: string; /** Closes the collector when aborted. */ signal?: AbortSignal; - startServer?: HttpServerStarter; } /** @@ -65,8 +59,9 @@ export async function startOtelCollector( options: StartOtelCollectorOptions, ): Promise { const store = new TraceStore(options.tracesDirectory); - const startServer = options.startServer ?? startHttpServer; - const server = await startServer((request) => route(request, store), { signal: options.signal }); + const server = await startHttpServer((request) => route(request, store), { + signal: options.signal, + }); return { port: server.port, store, envVars: otelEnvVars(server.port), close: server.close }; } diff --git a/src/io/httpServer.ts b/src/io/httpServer.ts index d95ee831a..dfdf32333 100644 --- a/src/io/httpServer.ts +++ b/src/io/httpServer.ts @@ -33,8 +33,6 @@ export interface HttpServerHandle { close(): Promise; } -export type HttpServerStarter = typeof startHttpServer; - /** * Starts a loopback-only HTTP server for local dev tooling. Binds 127.0.0.1 on * the given port (0 lets the OS assign one). Handler errors become plain 500s; diff --git a/src/io/index.ts b/src/io/index.ts index 7fe644c1c..ef5564e1a 100644 --- a/src/io/index.ts +++ b/src/io/index.ts @@ -35,5 +35,4 @@ export { type HttpRequestHandler, type HttpResponse, type HttpServerHandle, - type HttpServerStarter, } from "./httpServer"; From b4d0349c31bce9664f55ec8b596f052e40f61fc8 Mon Sep 17 00:00:00 2001 From: Tejas Kashinath Date: Wed, 12 Aug 2026 10:46:40 -0400 Subject: [PATCH 7/7] fix(dev): name aws-opentelemetry-distro in the missing-instrumentation hint --- src/core/dev/codezip.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/dev/codezip.ts b/src/core/dev/codezip.ts index bf4fe759e..7d6e83ba9 100644 --- a/src/core/dev/codezip.ts +++ b/src/core/dev/codezip.ts @@ -54,7 +54,7 @@ export class CodeZipDevRunner implements DevRunner { yield { type: "status", message: - "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add opentelemetry-distro to enable them.", + "OTEL auto-instrumentation is not installed in the agent environment; traces will not be collected. Add aws-opentelemetry-distro to the agent's dependencies to enable them.", }; } }