Skip to content

Commit d003ce7

Browse files
committed
feat(project): wire dev handler
1 parent 10cea59 commit d003ce7

23 files changed

Lines changed: 1080 additions & 27 deletions

package.json

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

src/core/dev/codezip.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,37 @@ describe("CodeZipDevRunner", () => {
110110
]);
111111
});
112112

113+
test("keeps runner-owned variables and omits the host profile with explicit credentials", async () => {
114+
const root = await projectRoot();
115+
const { calls, runner } = harness();
116+
const devInput = input(root, runtime({ protocol: "MCP" }));
117+
devInput.env = {
118+
AWS_ACCESS_KEY_ID: "access",
119+
AWS_SECRET_ACCESS_KEY: "secret",
120+
PORT: "9999",
121+
FASTMCP_PORT: "9998",
122+
LOCAL_DEV: "0",
123+
};
124+
const profile = process.env.AWS_PROFILE;
125+
process.env.AWS_PROFILE = "host-profile";
126+
127+
try {
128+
await collect(runner.run(devInput));
129+
} finally {
130+
if (profile === undefined) delete process.env.AWS_PROFILE;
131+
else process.env.AWS_PROFILE = profile;
132+
}
133+
134+
expect(calls[0]?.options.env).toMatchObject({
135+
AWS_ACCESS_KEY_ID: "access",
136+
AWS_SECRET_ACCESS_KEY: "secret",
137+
PORT: "9000",
138+
FASTMCP_PORT: "9000",
139+
LOCAL_DEV: "1",
140+
});
141+
expect(calls[0]?.options.env?.AWS_PROFILE).toBeUndefined();
142+
});
143+
113144
test.each(["MCP", "A2A", "AGUI"] as const)(
114145
"runs %s Python entrypoints directly",
115146
async (protocol) => {

src/core/dev/codezip.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ function commandForRuntime(
4848
PORT: String(input.port),
4949
LOCAL_DEV: "1",
5050
};
51+
if (input.env?.AWS_ACCESS_KEY_ID && input.env.AWS_SECRET_ACCESS_KEY) {
52+
delete env.AWS_PROFILE;
53+
}
5154

5255
if (input.runtime.protocol === "MCP") {
5356
env.FASTMCP_PORT = String(input.port);

src/core/dev/container.ts

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
type ProcessStreamer,
1111
type StreamProcessOptions,
1212
} from "../../io";
13+
import { devPortForProtocol } from "./port";
1314

1415
const CONTAINER_TOOLS = ["docker", "podman", "finch"] as const;
1516
const CLEANUP_TIMEOUT_MS = 2_000;
@@ -101,7 +102,7 @@ export class ContainerDevRunner implements DevRunner {
101102
buildOptions,
102103
);
103104

104-
const containerPort = portForProtocol(input.runtime.protocol);
105+
const containerPort = devPortForProtocol(input.runtime.protocol);
105106
const forwardedEnv: Record<string, string> = {
106107
...input.env,
107108
PORT: String(containerPort),
@@ -168,12 +169,6 @@ export class ContainerDevRunner implements DevRunner {
168169
}
169170
}
170171

171-
function portForProtocol(protocol: DevServerInput["runtime"]["protocol"]): number {
172-
if (protocol === "MCP") return 8000;
173-
if (protocol === "A2A") return 9000;
174-
return 8080;
175-
}
176-
177172
function isDirectory(path: string): boolean {
178173
try {
179174
return statSync(path).isDirectory();

src/core/dev/port.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { InputValidationError } from "../../errors";
3+
import type { PortChecker } from "../../io";
4+
import { resolveDevPort } from "./port";
5+
6+
const signal = new AbortController().signal;
7+
8+
function checker(occupied: number[] = []): { check: PortChecker; checked: number[] } {
9+
const checked: number[] = [];
10+
return {
11+
checked,
12+
check: async (port) => {
13+
checked.push(port);
14+
return !occupied.includes(port);
15+
},
16+
};
17+
}
18+
19+
describe("resolveDevPort", () => {
20+
test("uses the HTTP default when it is free", async () => {
21+
const { check, checked } = checker();
22+
23+
expect(await resolveDevPort("HTTP", undefined, check, signal)).toEqual({
24+
port: 8080,
25+
requestedPort: 8080,
26+
});
27+
expect(checked).toEqual([8080]);
28+
});
29+
30+
test("walks up from an occupied default HTTP port", async () => {
31+
const { check, checked } = checker([8080, 8081]);
32+
33+
expect(await resolveDevPort("AGUI", undefined, check, signal)).toEqual({
34+
port: 8082,
35+
requestedPort: 8080,
36+
});
37+
expect(checked).toEqual([8080, 8081, 8082]);
38+
});
39+
40+
test("honors an explicit free HTTP port", async () => {
41+
const { check } = checker();
42+
expect(await resolveDevPort("HTTP", 4567, check, signal)).toEqual({
43+
port: 4567,
44+
requestedPort: 4567,
45+
});
46+
});
47+
48+
test("rejects an occupied explicit port with inspection commands", async () => {
49+
const { check } = checker([4567]);
50+
51+
await expect(resolveDevPort("HTTP", 4567, check, signal)).rejects.toMatchObject({
52+
name: InputValidationError.name,
53+
message: expect.stringContaining("lsof -i :4567"),
54+
});
55+
});
56+
57+
test.each([
58+
["MCP", 8000, 8001],
59+
["A2A", 9000, 9001],
60+
] as const)("requires the fixed %s port", async (protocol, required, invalid) => {
61+
const { check, checked } = checker();
62+
63+
await expect(resolveDevPort(protocol, invalid, check, signal)).rejects.toThrow(
64+
`${protocol} development servers require port ${required}`,
65+
);
66+
expect(checked).toEqual([]);
67+
});
68+
69+
test.each([
70+
["MCP", 8000],
71+
["A2A", 9000],
72+
] as const)("rejects an occupied default %s port", async (protocol, port) => {
73+
const { check } = checker([port]);
74+
await expect(resolveDevPort(protocol, undefined, check, signal)).rejects.toThrow(
75+
`${protocol} development servers require this port`,
76+
);
77+
});
78+
79+
test("bounds the default HTTP search to 100 ports", async () => {
80+
const check: PortChecker = async () => false;
81+
await expect(resolveDevPort("HTTP", undefined, check, signal)).rejects.toThrow(
82+
"No free port found in range 8080-8179",
83+
);
84+
});
85+
});

src/core/dev/port.ts

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

src/errors/errors.tsx

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

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

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

src/errors/index.tsx

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

0 commit comments

Comments
 (0)