Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/assets/templates/a2a-python-strands/gitignore.template
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ env/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Box, which agentcore deploy stages for AgentCore Runtime
.box-dist/
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ env/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Box, which agentcore deploy stages for AgentCore Runtime
.box-dist/
5 changes: 4 additions & 1 deletion src/assets/templates/agent-python-strands/gitignore.template
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ env/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Box, which agentcore deploy stages for AgentCore Runtime
.box-dist/
5 changes: 4 additions & 1 deletion src/assets/templates/agui-python-strands/gitignore.template
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ env/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Box, which agentcore deploy stages for AgentCore Runtime
.box-dist/
7 changes: 7 additions & 0 deletions src/assets/templates/box-codezip/start.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Starts the agent in a box on AgentCore Runtime. The Runtime's entry point must be a .py file, so
# this hands over to start.sh, and tells it which Python the Runtime runs.
import os
import sys

script = os.path.join(os.path.dirname(os.path.abspath(__file__)), "start.sh")
os.execve("/bin/bash", ["bash", script], {**os.environ, "BOX_START_PYTHON": sys.executable})
115 changes: 115 additions & 0 deletions src/assets/templates/box-codezip/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#!/bin/bash
# Runs the agent in a box on AgentCore Runtime (CodeZip). start.py runs this script. Written by
# `agentcore create`; `agentcore deploy` stages .box-dist/ beside it.
#
# The microVM (Amazon Linux 2023, arm64, an ordinary user, HOME unset):
#
# /var/task/ the zip, read-only $CODE
# ├── start.py start.sh main.py main.py is the agent
# ├── <dependencies> installed at the zip root
# └── .box-dist/ staged by agentcore deploy
# ├── box strands-box-sock-alias strands-box-contain-trampoline
# ├── resources/ box's packs
# ├── ffi/ _ctypes and a libffi that Box Core's W^X filter allows
# └── profile.toml rendered from agentcore.json, with @CODE@, @PYTHON@,
# @PYTHON_PREFIX@ and @GATEWAY_BOX_URL@ for this script
#
# /tmp/box/ written here; gone with the microVM
# ├── profile.toml the filled profile, which box run takes by path $PROFILE
# ├── workspace/ where box run starts; the agent reads and writes $WORKSPACE
# └── home/ HOME for box; the agent cannot read it $HOME_DIR
#
# Box signs the AWS requests with the execution role: a bare aws:// in the profile resolves the AWS
# default credential chain, which Box Core reads and refreshes itself.
#
# If box fails, or the agent in it does not answer by 22s, this serves main.py without box: the
# Runtime allows 30s to start. Set BOX_START_DISABLE=1 on the Runtime to skip box.
set -u

CODE=$(cd "$(dirname "$0")" && pwd)
PYTHON=${BOX_START_PYTHON:-$(command -v python3)}
DIST=$CODE/.box-dist
BOX=$DIST/box
WORKSPACE=/tmp/box/workspace
HOME_DIR=/tmp/box/home
# Outside the workspace, so box accepts the profile file.
PROFILE=/tmp/box/profile.toml
READY_BY=22

log() { echo "[box] $*"; }

# A Python for helper scripts. Setting PYTHONPATH drops the OpenTelemetry startup hooks that
# opentelemetry-instrument adds: they cost about 3s and start threads.
pyq() { env PYTHONPATH="$CODE" "$PYTHON" "$@"; }

serve_without_box() {
log "FALLBACK ($1) after ${SECONDS}s: serving the agent without box"
cd "$CODE" && exec "$PYTHON" main.py
}

# --- The profile -----------------------------------------------------------------------------------
write_profile() {
local python_prefix agent_env
python_prefix=$(pyq -c 'import sys; print(sys.base_prefix)')

# Only the deployed Memories' and Gateways' IDs and URLs reach the agent, never a secret.
agent_env=$(env | grep -E '^((AGENTCORE_GATEWAY|MEMORY)_[A-Z0-9_]+_(URL|ARN|ID|AUTH_TYPE)|AWS_REGION|AWS_DEFAULT_REGION)=[!-~]*$' \
| sed -E 's/^([^=]+)=(.*)$/\1 = "\2"/')

# The Box gateway's URL exists only after deploy; the CDK sets it on the Runtime.
sed -e "s|@CODE@|$CODE|g" -e "s|@PYTHON_PREFIX@|$python_prefix|g" -e "s|@PYTHON@|$PYTHON|g" \
-e "s|@GATEWAY_BOX_URL@|${AGENTCORE_GATEWAY_BOX_URL:-}|g" \
"$DIST/profile.toml" \
| awk -v lines="$agent_env" '$0 == "# @AGENT_ENV@" { if (lines != "") print lines; next } { print }' \
> "$PROFILE"

}

# --- Start box -------------------------------------------------------------------------------------
# Box opens the ingress port before the agent is up, so an answer from the agent through it is the
# sign of ready. Any HTTP status line counts: the agent answered, whatever its paths are.
agent_ready() (
exec 3<>"/dev/tcp/127.0.0.1/$1" || exit 1
printf 'GET /ping HTTP/1.0\r\nHost: localhost\r\n\r\n' >&3
read -r -t 2 status <&3 && [[ "$status" == HTTP/* ]]
) 2>/dev/null

start_box() {
local port pid code
port=$(grep -m1 -oE '^listen = "[0-9.]+:[0-9]+"' "$PROFILE" | grep -oE '[0-9]+"$' | tr -d '"')
cd "$WORKSPACE" || serve_without_box "no workspace"
log "starting box run (ingress :${port:-8080})"
# The prefix keeps box's own lines in a "[box]" log query.
env HOME="$HOME_DIR" "$BOX" run "$PROFILE" --no-banner > >(sed -u 's/^/[box] box: /') 2>&1 &
pid=$!
while [ "$SECONDS" -lt "$READY_BY" ]; do
if ! kill -0 "$pid" 2>/dev/null; then
wait "$pid"
serve_without_box "box run exited with $?"
fi
if agent_ready "${port:-8080}"; then
log "the agent in box answers after ${SECONDS}s"
trap 'kill -TERM "$pid"' TERM INT
wait "$pid"
code=$?
log "box run exited with $code"
exit "$code"
fi
sleep 0.5
done
kill "$pid" 2>/dev/null

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: port race. kill "$pid" sends SIGTERM but does not wait, so serve_without_box execs main.py right away. Box opened the ingress port (0.0.0.0:8080 for HTTP) before the agent was up, and main.py binds that same port. If box has not closed its listener yet, the fallback dies with EADDRINUSE and the Runtime gets nothing. The agent child that box started on the workload port may also outlive box. Suggest kill "$pid"; wait "$pid" 2>/dev/null with a short bounded wait, escalating to kill -KILL if needed. That matters even more if finding 2 keeps any fallback.

— 🤖 Abhilash's Claude Bot

serve_without_box "the agent in box did not answer by ${READY_BY}s"
}

# --- Main --------------------------------------------------------------------------------------------
[ "${BOX_START_DISABLE:-}" = 1 ] && serve_without_box "BOX_START_DISABLE=1"
[ -x "$BOX" ] && [ -f "$DIST/profile.toml" ] || serve_without_box "no $DIST; deploy stages it"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

High (design): fails open. Every path here, and the 22s timeout and box run exit paths in start_box, ends in exec python main.py with no box. The agent then runs with the full execution role, unrestricted egress and no Dogwood policy, and the only sign is a single [box] FALLBACK log line. A slow cold start, a bad profile, or a missing .box-dist (for example, a deploy without staging) quietly downgrades a contained agent to an uncontained one. Box is the containment boundary in the architecture doc, and the security notes stress telling customers what the box does and does not give them. Suggest failing closed by default: exit non-zero so the Runtime health check fails visibly. Keep the unboxed fallback behind an explicit opt-in such as BOX_START_FALLBACK=1, the counterpart of BOX_START_DISABLE.

— 🤖 Abhilash's Claude Bot

log "box: $("$BOX" --version 2>&1 | head -1) python: $PYTHON user: $(id -u)"

mkdir -p "$WORKSPACE" "$HOME_DIR"
chmod 0700 "$HOME_DIR"

write_profile
log "profile:"
sed 's/^/[box] /' "$PROFILE"
start_box
5 changes: 4 additions & 1 deletion src/assets/templates/mcp-python-fastmcp/gitignore.template
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,7 @@ env/

# OS
.DS_Store
Thumbs.db
Thumbs.db

# Box, which agentcore deploy stages for AgentCore Runtime
.box-dist/
20 changes: 18 additions & 2 deletions src/core/dev/box.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { InputValidationError } from "../../errors";
import { InputValidationError, ResourceNotFoundError } from "../../errors";
import type { ProjectRuntime } from "../../projectSchemas/runtime";
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
import type { ProcessStreamer, StreamProcessOptions } from "../../io";
Expand Down Expand Up @@ -52,9 +52,10 @@ function input(
projectRoot: string,
port = 8080,
protocol?: ProjectRuntime["protocol"],
entrypoint = "main.py",
): DevServerInput {
return {
runtime: { name: "agent", build: "CodeZip", protocol } as ProjectRuntime,
runtime: { name: "agent", build: "CodeZip", protocol, entrypoint } as ProjectRuntime,
projectRoot,
port,
env: { OTEL_EXPORTER_OTLP_ENDPOINT: "http://127.0.0.1:4318" },
Expand Down Expand Up @@ -115,6 +116,21 @@ describe("BoxDevRunner", () => {
expect(events).toEqual([{ type: "stdout", line: "fallback" }]);
});

test("refuses a runtime that starts box, when the project has no box profile", async () => {
const { projectRoot, calls, fallbackRuns, runner } = await scene(false);

const error = await collect(runner.run(input(projectRoot, 8080, "HTTP", "start.py"))).catch(
(caught) => caught,
);

expect(error).toBeInstanceOf(ResourceNotFoundError);
expect((error as Error).message).toContain(
'runtime "agent" runs its agent in a box (entrypoint start.py)',
);
expect(calls).toEqual([]);
expect(fallbackRuns).toHaveLength(0);
});

test("expects the dev port of the runtime's protocol", async () => {
const { projectRoot, calls, runner } = await scene(true);

Expand Down
17 changes: 15 additions & 2 deletions src/core/dev/box.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
import { existsSync } from "node:fs";
import { InputValidationError } from "../../errors";
import { InputValidationError, ResourceNotFoundError } from "../../errors";
import type { DevEvent, DevRunner, DevServerInput } from "../../handlers/project/dev/types";
import { streamProcess, type ProcessStreamer } from "../../io";
import { BOX_PROFILE_FILE, boxIngressPort, boxProfilePath } from "../project/templates/boxProfile";
import {
BOX_PROFILE_FILE,
boxIngressPort,
boxProfilePath,
runsBoxStartScript,
} from "../project/templates/boxProfile";

type BoxDevRunnerConfig = {
/** The runner used when the project has no box profile. */
Expand All @@ -28,6 +33,14 @@ export class BoxDevRunner implements DevRunner {
public async *run(input: DevServerInput): AsyncGenerator<DevEvent, void> {
const profile = boxProfilePath(input.projectRoot);
if (!existsSync(profile)) {
// start.py starts box on AgentCore Runtime, and runs no agent of its own.
if (runsBoxStartScript(input.runtime.entrypoint)) {
throw new ResourceNotFoundError(
`runtime "${input.runtime.name}" runs its agent in a box (entrypoint ` +
`${input.runtime.entrypoint}), but its box profile ${profile} does not exist. ` +
"Restore the profile that `agentcore create` wrote.",
);
}
yield* this.fallback.run(input);
return;
}
Expand Down
14 changes: 11 additions & 3 deletions src/core/project/__snapshots__/manager.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ exports[`FsProjectManager.create scaffolds the expected file tree into a fresh d
"app/agent_python_minimal/README.md",
"app/agent_python_minimal/main.py",
"app/agent_python_minimal/pyproject.toml",
"app/agent_python_minimal/start.py",
"app/agent_python_minimal/start.sh",
]
`;

Expand Down Expand Up @@ -44,6 +46,8 @@ exports[`FsProjectManager.create snapshots the Strands project manifest and runt
"app/agent_python_strands/model/load.py",
"app/agent_python_strands/parse.py",
"app/agent_python_strands/pyproject.toml",
"app/agent_python_strands/start.py",
"app/agent_python_strands/start.sh",
],
"memories": [
{
Expand Down Expand Up @@ -72,7 +76,7 @@ exports[`FsProjectManager.create snapshots the Strands project manifest and runt
{
"build": "CodeZip",
"codeLocation": "app/agent_python_strands",
"entrypoint": "main.py",
"entrypoint": "start.py",
"modelProvider": "Bedrock",
"name": "agent_python_strands",
"protocol": "HTTP",
Expand Down Expand Up @@ -164,6 +168,8 @@ exports[`FsProjectManager.create snapshots the Strands A2A project manifest and
"app/a2a_python_strands/model/__init__.py",
"app/a2a_python_strands/model/load.py",
"app/a2a_python_strands/pyproject.toml",
"app/a2a_python_strands/start.py",
"app/a2a_python_strands/start.sh",
],
"memories": [
{
Expand Down Expand Up @@ -192,7 +198,7 @@ exports[`FsProjectManager.create snapshots the Strands A2A project manifest and
{
"build": "CodeZip",
"codeLocation": "app/a2a_python_strands",
"entrypoint": "main.py",
"entrypoint": "start.py",
"modelProvider": "Bedrock",
"name": "a2a_python_strands",
"protocol": "A2A",
Expand Down Expand Up @@ -223,13 +229,15 @@ exports[`FsProjectManager.create snapshots the LangChain project manifest and ru
"app/agent_python_langchain/model/__init__.py",
"app/agent_python_langchain/model/load.py",
"app/agent_python_langchain/pyproject.toml",
"app/agent_python_langchain/start.py",
"app/agent_python_langchain/start.sh",
],
"memories": undefined,
"runtimes": [
{
"build": "CodeZip",
"codeLocation": "app/agent_python_langchain",
"entrypoint": "main.py",
"entrypoint": "start.py",
"modelProvider": "Bedrock",
"name": "agent_python_langchain",
"protocol": "HTTP",
Expand Down
Loading