diff --git a/AGENTS.md b/AGENTS.md index 13c5fdd5..a1b0c5e6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,13 +55,15 @@ These are the abstractions that span many files; understanding them is what make productive here. The per-feature detail lives in `docs/architecture.md` and the specs under `.trellis/spec/launchpad/`. -- **Three creation methods, one pipeline.** 方式A (Claude Agent SDK → ARM64 container), - 方式B (managed Harness, no build), 方式C (Strands Studio canvas), plus `zip_runtime`, +- **Four creation methods, one pipeline.** 方式A (Claude Agent SDK → ARM64 container), + 方式B (managed Harness, no build), 方式C (Strands Studio canvas), `byoc` (member- + uploaded code zip / Dockerfile context / existing ECR image), plus `zip_runtime`, all converge into the ordered stages `generate → package → provision → deploy → register` in `backend/app/deployer/pipeline.py`. Each method registers one callable per stage (or omits it) via `register_method()`; the method modules - (`deployer/harness.py`, `zip_runtime.py`, `container.py`) are imported **for their - side effects** in `app/main.py`, so a new method must be imported there to exist. + (`deployer/harness.py`, `zip_runtime.py`, `container.py`, `byoc.py`) are imported + **for their side effects** in `app/main.py`, so a new method must be imported there + to exist. - **Deploy is an async, resumable job.** `POST /api/agents` returns `202` with a `job_id`; the job runs on a background thread, persisting per-stage status onto the diff --git a/CLAUDE.md b/CLAUDE.md index 77964727..489baca0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,13 +59,15 @@ These are the abstractions that span many files; understanding them is what make productive here. The per-feature detail lives in `docs/architecture.md` and the specs under `.trellis/spec/launchpad/`. -- **Three creation methods, one pipeline.** 方式A (Claude Agent SDK → ARM64 container), - 方式B (managed Harness, no build), 方式C (Strands Studio canvas), plus `zip_runtime`, +- **Four creation methods, one pipeline.** 方式A (Claude Agent SDK → ARM64 container), + 方式B (managed Harness, no build), 方式C (Strands Studio canvas), `byoc` (member- + uploaded code zip / Dockerfile context / existing ECR image), plus `zip_runtime`, all converge into the ordered stages `generate → package → provision → deploy → register` in `backend/app/deployer/pipeline.py`. Each method registers one callable per stage (or omits it) via `register_method()`; the method modules - (`deployer/harness.py`, `zip_runtime.py`, `container.py`) are imported **for their - side effects** in `app/main.py`, so a new method must be imported there to exist. + (`deployer/harness.py`, `zip_runtime.py`, `container.py`, `byoc.py`) are imported + **for their side effects** in `app/main.py`, so a new method must be imported there + to exist. - **Deploy is an async, resumable job.** `POST /api/agents` returns `202` with a `job_id`; the job runs on a background thread, persisting per-stage status onto the diff --git a/README.md b/README.md index 3a741fd6..76a5fbc2 100644 --- a/README.md +++ b/README.md @@ -13,14 +13,16 @@ deploy it to AgentCore Runtime, and consume it** over chat or HTTP. Launchpad is one console (React) over one FastAPI backend, plus shared AWS infrastructure (CDK) and a vendored Strands Studio sub-app. It delivers: -- **Three creation methods, one deploy pipeline.** Users create agents via +- **Four creation methods, one deploy pipeline.** Users create agents via **方式B — Managed Harness** (declarative `CreateHarness` — model, prompt, tools, skills, memory; no code, no build), **方式C — Strands Studio** - (visual drag-and-drop canvas that generates Strands code), or + (visual drag-and-drop canvas that generates Strands code), **方式A — Other Agent SDK** (bring your own agent SDK — the Claude Agent SDK - today — packaged into an ARM64 container image). All three - converge into the same five-stage pipeline and land on AgentCore Runtime - (方式A/C) or the managed Harness service (方式B). + today — packaged into an ARM64 container image), or **BYOC — Bring Your Own + Code** (upload a zip of your own agent code, or a Dockerfile build context, + or reference an existing private-ECR image; developers need no AWS access). + All of them converge into the same five-stage pipeline and land on AgentCore + Runtime (方式A/C/BYOC) or the managed Harness service (方式B). - **Registry console.** A visual front end over AgentCore Registry for cataloguing and discovering the three asset types — agents (A2A), MCP tools, and skills — with submit → approve lifecycle actions. @@ -243,7 +245,7 @@ For terminal-attached development, use `make dev` and stop it with `Ctrl+C`. |---|---| | `backend/` | FastAPI backend — deploy pipeline, invoke chain, evaluation & optimization, SQLite ledger | | `backend/app/routers/` | Console `/api` + public `/v1` endpoints | -| `backend/app/deployer/` | Unified pipeline + per-method stages (harness, zip_runtime, container, studio) | +| `backend/app/deployer/` | Unified pipeline + per-method stages (harness, zip_runtime, container, studio, byoc) | | `frontend/` | React console (Vite) — Overview, Create Agent, Registry, Chat, Observability, Evaluation, Skill Lab, Governance | | `infra/` | AWS CDK app — the `launchpad-base` shared stack | | `apps/studio/` | Vendored Strands Studio sub-app (方式C), rewired to the platform pipeline | diff --git a/README.zh-CN.md b/README.zh-CN.md index f12168fc..a8dec11d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,13 +12,15 @@ AgentCore Launchpad 是一套基于 Amazon Bedrock AgentCore 的**企业 Agent O Launchpad 由 React 控制台、FastAPI 后端和一套 CDK 共享基础设施组成,另带一个 vendored Strands Studio 子应用。主要能力包括: -- **三种创建方式,共用一条部署流水线。** 用户可以选择**方式B(Managed Harness)**, +- **四种创建方式,共用一条部署流水线。** 用户可以选择**方式B(Managed Harness)**, 通过模型、提示词、工具、技能和记忆创建 Harness,无需编写代码或构建产物; 选择**方式C(Strands Studio)**,在可视化画布中生成 Strands 代码; - 也可以选择**方式A(其他 Agent SDK)**,自带 Agent SDK(目前为 Claude Agent - SDK)并打包成 ARM64 容器镜像。 - 三种方式都进入同一条五阶段流水线,最终部署到 AgentCore Runtime(方式A/C) - 或托管 Harness 服务(方式B)。 + 选择**方式A(其他 Agent SDK)**,自带 Agent SDK(目前为 Claude Agent + SDK)并打包成 ARM64 容器镜像;也可以选择**自带代码(BYOC)**,上传自己编写的 + Agent 代码 zip 或 Dockerfile 构建上下文,或引用本账户私有 ECR 中的现有镜像, + 开发者无需任何 AWS 权限。 + 所有方式都进入同一条五阶段流水线,最终部署到 AgentCore Runtime + (方式A/C/BYOC)或托管 Harness 服务(方式B)。 - **注册中心。** 通过 AgentCore Registry 登记和查找三类资产:Agent(A2A)、 MCP 工具和 Skill,并支持提交、审批等生命周期操作。 - **知识库。** 托管 Bedrock 知识库——全托管 RAG,向量库、嵌入与重排都由服务负责。 @@ -220,7 +222,7 @@ export LAUNCHPAD_AUTH_ALLOWED_EMAIL_DOMAINS='["your-company.com"]' # 白名单 |---|---| | `backend/` | FastAPI 后端:部署流水线、调用链、评估与优化、SQLite 台账 | | `backend/app/routers/` | 控制台 `/api` + 公开 `/v1` 接口 | -| `backend/app/deployer/` | 统一流水线 + 各方式的阶段实现(harness、zip_runtime、container、studio) | +| `backend/app/deployer/` | 统一流水线 + 各方式的阶段实现(harness、zip_runtime、container、studio、byoc) | | `frontend/` | React 控制台(Vite):Overview、Create Agent、Registry、Chat、Governance、Evaluation | | `infra/` | AWS CDK 应用:`launchpad-base` 共享栈 | | `apps/studio/` | vendored Strands Studio 子应用(方式C),已接入平台流水线 | diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e1718fc8..f788155c 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -123,6 +123,16 @@ class Settings(BaseSettings): image_scan_block_severities: list[str] = ["CRITICAL"] image_scan_timeout_s: int = Field(default=300, gt=0) + # The manylinux level the zip deploy paths resolve/install Python wheels for + # (consumed via app/core/runtime_target.py — see its module docstring for the + # measured evidence: AgentCore Runtime = AL2023, glibc 2.34, aarch64, + # 2026-09-18). manylinux_2_28 matches what current wheel builders publish; + # "manylinux2014" is the documented safe fallback (the official docs' + # conservative recommendation) if a runtime image ever reports older glibc. + runtime_python_platform: str = Field( + default="manylinux_2_28", pattern=r"^manylinux(2014|_2_\d+)$" + ) + # AgentCore synchronous runtime requests may run for up to 15 minutes. # Keep the SDK read timeout above that service limit so buffered agents can # return their final response. diff --git a/backend/app/core/route_policy.py b/backend/app/core/route_policy.py index fe45877a..376304b7 100644 --- a/backend/app/core/route_policy.py +++ b/backend/app/core/route_policy.py @@ -99,6 +99,9 @@ ("POST", "/api/agents"): PERM_AGENT_DEPLOY, ("GET", "/api/agents/discovery"): MEMBER, ("POST", "/api/agents/discovery/import"): PERM_AGENT_IMPORT, + # BYOC artifact staging is one half of a deploy, so it carries deploy perms + ("POST", "/api/agents/uploads"): PERM_AGENT_DEPLOY, + ("GET", "/api/agents/uploads/{upload_id}"): MEMBER, ("GET", "/api/agents/{agent_id}"): MEMBER, ("GET", "/api/agents/{agent_id}/versions"): MEMBER, # read-only AWS view ("GET", "/api/agents/{agent_id}/conversions"): MEMBER, # ledger read: runtime twins diff --git a/backend/app/core/runtime_target.py b/backend/app/core/runtime_target.py new file mode 100644 index 00000000..a10326dd --- /dev/null +++ b/backend/app/core/runtime_target.py @@ -0,0 +1,81 @@ +"""The Python platform the zip deploy paths resolve and install for. + +One definition, three consumers that MUST agree: the `uv pip compile` resolve, +the `pip install` into the bundle (both in `app/deployer/`), and the +range→pin resolver (`app/schemas/requirements.py`). Resolving against one +platform and installing for another produces a lock that does not describe the +artifact. + +The default is `manylinux_2_28` on aarch64. Evidence (measured 2026-09-18 from +inside a deployed AgentCore Runtime direct-code agent, PYTHON_3_13): the runtime +OS is Amazon Linux 2023.12.20260817 on aarch64 with glibc 2.34, so every +manylinux tag up to `manylinux_2_34` is loadable. The official docs recommend +`manylinux2014` (glibc 2.17), which is safe but strictly narrower — packages +that only publish `manylinux_2_26`/`2_28` aarch64 wheels (e.g. `google-re2`, +pulled in by `chromadb`) are unsolvable under it even though they run fine on +the runtime. `2_28` is what current manylinux images actually publish for; +`2_34` would buy nothing today and breaks the day the fleet moves to an older +glibc image, so the headroom stays unspent. + +Configurable as `runtime_python_platform` (`LAUNCHPAD_RUNTIME_PYTHON_PLATFORM`), +value `manylinux_2_` or the legacy alias `manylinux2014`. `manylinux2014` +is the documented safe fallback if a future runtime image ever reports an older +glibc. +""" + +import re + +from app.core.config import get_settings + +# AgentCore Runtime zips run on Python 3.13 regardless of the resolve platform. +TARGET_PYTHON = "3.13" + +_PLATFORM_RE = re.compile(r"^manylinux(?:2014|_2_(?P\d+))$") + +# The legacy aliases map onto PEP 600 glibc minors; pip expands neither +# direction on its own (measured: `--platform manylinux_2_28_aarch64` alone +# refuses a manylinux_2_17-only wheel), so the ladder below is built explicitly. +_MANYLINUX2014_MINOR = 17 + + +def _glibc_minor(setting: str) -> int: + match = _PLATFORM_RE.match(setting) + if match is None: + raise ValueError( + f"runtime_python_platform {setting!r} is not a manylinux platform — " + "use manylinux_2_ (e.g. manylinux_2_28) or manylinux2014" + ) + return int(match.group("minor") or _MANYLINUX2014_MINOR) + + +def uv_platform() -> str: + """The single `--python-platform` value for `uv pip compile`. + + uv derives the whole compatible-tag set from one platform (a + `aarch64-manylinux_2_28` resolve accepts `manylinux_2_17` wheels), so no + ladder is needed on this side. + """ + return f"aarch64-{get_settings().runtime_python_platform}" + + +def pip_platforms() -> list[str]: + """Every `--platform` value the `pip install` step must pass. + + pip treats `--platform` tags as exact strings — it does NOT expand + `manylinux_2_28_aarch64` down to older glibc tags the way it expands the + *running* interpreter's platform. One flag would therefore reject the + `manylinux_2_17`-only wheels most projects publish, undoing the resolve. + """ + minor = _glibc_minor(get_settings().runtime_python_platform) + ladder = [f"manylinux_2_{m}_aarch64" for m in range(minor, _MANYLINUX2014_MINOR - 1, -1)] + # the pre-PEP600 alias many wheel filenames still use (manylinux2014 == 2_17) + ladder.append("manylinux2014_aarch64") + return ladder + + +def pip_platform_args() -> list[str]: + """`--platform` argv fragments for a pip command line.""" + args: list[str] = [] + for platform in pip_platforms(): + args += ["--platform", platform] + return args diff --git a/backend/app/deployer/byoc.py b/backend/app/deployer/byoc.py new file mode 100644 index 00000000..b51b3209 --- /dev/null +++ b/backend/app/deployer/byoc.py @@ -0,0 +1,482 @@ +"""Bring Your Own Code (byoc) — deploy user-written agent code to Runtime. + + generate → load the staged upload's manifest (or describe the ECR image) + and stamp server-verified provenance onto the spec + package → code_zip: download → safe-extract → verify entrypoint → + resolve requirements.txt for linux/aarch64 → zip → S3 + container_source: download → verify Dockerfile → CodeBuild → ECR + container_image: verify the image exists in this account+region + provision → per-agent least-privilege IAM execution role + deploy → CreateAgentRuntime (codeConfiguration or containerConfiguration) + + poll READY; re-publish → UpdateAgentRuntime (new version) + register → the shared A2A registry record stage + +The platform NEVER executes the uploaded code on the Launchpad host: package +work is archive extraction, file checks and a pip *download/install into the +bundle directory* (wheels are unpacked, never imported or run). +""" + +import os +import shutil +import subprocess +import sys +import time +import zipfile +from pathlib import Path +from typing import Any + +from app.core.config import get_settings +from app.core.runtime_target import pip_platform_args +from app.deployer.environment import runtime_environment +from app.deployer.pipeline import StageContext, StageResult, register_method +from app.models.ledger import Agent +from app.schemas.agent import AgentSpec, ByocConfig, parse_ecr_image_uri +from app.services import agent_iam, byoc_uploads +from app.services.agentcore import runtime as rt +from app.services.agentcore.client import control_client +from app.services.requirements_txt import ( + RESOLVE_FIX_HINTS, + RequirementsFileError, + parse_requirements_txt, + pip_python_version, + summarize_resolver_failure, +) +from app.services.workspace import WorkspaceContext + +from .container import ( + _image_ref, + _recorded_digest_uri, + build_and_push_image, + platform_buildspec_path, +) +from .zip_runtime import _compile_lock, sanitize_runtime_name + +PACKAGE_KEY_TMPL = "agents/{name}/byoc_package.zip" + + +def _config(spec: AgentSpec) -> ByocConfig: + if spec.byoc is None: # schema guarantees this; belt for hand-built rows + raise RuntimeError("byoc spec has no byoc settings block") + return spec.byoc + + +# PYTHON_3_13 → 3.13 (the shape pip/uv take); shared with the upload pre-resolve +_pip_python_version = pip_python_version + + +def _requirements_lines(path: Path) -> list[str]: + """The zip's requirements entries, per the pip file format (continuations, + comments, markers) and the platform's supply-chain boundary (no includes, + no URLs/VCS/paths, no index options — see `services/requirements_txt`). + `--hash` options are dropped: the platform re-locks against its own deploy + target and generates fresh hashes.""" + try: + return parse_requirements_txt(path.read_text(encoding="utf-8")) + except RequirementsFileError as exc: + raise RuntimeError(f"the zip's requirements.txt was refused — {exc}") from exc + + +def _stamp_provenance(ctx: StageContext, agent: Agent, provenance: dict[str, Any]) -> None: + """Persist server-verified provenance into the row's spec (idempotent). + + Overwrites whatever the client sent — provenance is server-owned; the spec + field only exists so the console can render it back.""" + db = ctx.session() + try: + row = db.get(Agent, agent.id) + spec = dict(row.spec) + spec["byoc"] = {**(spec.get("byoc") or {}), "provenance": provenance} + row.spec = spec + db.commit() + agent.spec = spec + finally: + db.close() + + +def _stage_generate(ctx: StageContext, agent: Agent) -> StageResult: + """No code to generate — verify the artifact reference and stamp provenance.""" + spec = AgentSpec(**agent.spec) + cfg = _config(spec) + + if cfg.artifact_kind == "container_image": + digest, pushed_at = describe_image(ctx.workspace, cfg.image_uri or "") + provenance = { + "sha256": digest, + "size_bytes": 0, + "original_filename": cfg.image_uri or "", + "uploaded_by": "", + "uploaded_at": pushed_at, + } + _stamp_provenance(ctx, agent, provenance) + ctx.log(f"ECR image verified · {cfg.image_uri} · {digest}") + return StageResult(detail=f"container_image · {digest[:19]}…") + + manifest = byoc_uploads.get_manifest(ctx.workspace, cfg.upload_id or "") + provenance = { + "sha256": manifest.get("sha256", ""), + "size_bytes": manifest.get("size_bytes", 0), + "original_filename": manifest.get("original_filename", ""), + "uploaded_by": manifest.get("uploaded_by", ""), + "uploaded_at": manifest.get("uploaded_at", ""), + } + _stamp_provenance(ctx, agent, provenance) + detected = manifest.get("detected") or {} + if cfg.artifact_kind == "code_zip" and not detected.get("agentcore_sdk_detected"): + ctx.log( + "note: no BedrockAgentCoreApp/@app.entrypoint marker found — the " + "entrypoint must serve POST /invocations + GET /ping on :8080 itself" + ) + ctx.log( + f"{cfg.artifact_kind} · {provenance['original_filename']} · " + f"{provenance['size_bytes'] / 1e6:.1f}MB · sha256 {provenance['sha256'][:12]} · " + f"uploaded by {provenance['uploaded_by'] or 'unknown'}" + ) + return StageResult( + detail=f"{cfg.artifact_kind} · sha256 {provenance['sha256'][:12]}…" + ) + + +def describe_image( + workspace: WorkspaceContext, image_uri: str, ecr_client: Any = None +) -> tuple[str, str]: + """(imageDigest, imagePushedAt ISO) of a private ECR image in THIS + account+region — refuses other accounts/regions and missing images.""" + parsed = parse_ecr_image_uri(image_uri) + if parsed is None: + raise RuntimeError(f"'{image_uri}' is not a private ECR image URI") + account_id, region = parsed + if account_id != workspace.account_id or region != workspace.region: + raise RuntimeError( + f"image {image_uri} lives in {account_id}/{region}; this workspace " + f"deploys from {workspace.account_id}/{workspace.region} only" + ) + rest = image_uri.split(".amazonaws.com/", 1)[1] + if "@sha256:" in rest: + repo, _, ref = rest.partition("@") + image_id = {"imageDigest": ref} + else: + repo, _, ref = rest.rpartition(":") + image_id = {"imageTag": ref} + ecr = ecr_client or workspace.client("ecr") + detail = ecr.describe_images(repositoryName=repo, imageIds=[image_id]) + images = detail.get("imageDetails", []) + if not images: + raise RuntimeError(f"image {image_uri} not found in ECR") + pushed = images[0].get("imagePushedAt") + pushed_at = pushed.isoformat() if hasattr(pushed, "isoformat") else str(pushed or "") + return images[0].get("imageDigest", ""), pushed_at + + +def resolve_requirements_into( + src_root: Path, + build_dir: Path, + python_version: str, + log: Any, + pip_runner: Any = subprocess.run, + compile_runner: Any = None, +) -> int: + """Hash-locked install of the zip's requirements.txt into the bundle root + for the Runtime target (linux/aarch64). Returns the locked package count; + 0 when there is nothing to resolve. Wheels are unpacked, never executed.""" + req_file = src_root / "requirements.txt" + if not req_file.exists(): + return 0 + requirements = _requirements_lines(req_file) + if not requirements: + return 0 + pip_version = _pip_python_version(python_version) + lock = _compile_lock( + requirements, build_dir, compile_runner or pip_runner, python_version=pip_version + ) + locked = [ + line for line in lock.read_text(encoding="utf-8").splitlines() + if "==" in line and not line.lstrip().startswith("#") + ] + log(f"requirements locked · {len(locked)} packages pinned with hashes") + proc = pip_runner( + [ + sys.executable, "-m", "pip", "install", + "--require-hashes", "-r", str(lock), + "-t", str(src_root), + *pip_platform_args(), + "--only-binary=:all:", + "--python-version", pip_version, + "--quiet", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + raise RuntimeError( + "pip install of the zip's locked requirements failed: " + + summarize_resolver_failure( + proc.stderr or "", python_version=pip_version, hints=RESOLVE_FIX_HINTS + ) + ) + # the lock ships inside the artifact — the record of what was installed + shutil.copy2(lock, src_root / "requirements.lock") + return len(locked) + + +def _zip_tree(src_root: Path, zip_path: Path) -> None: + with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: + for root, _, files in os.walk(src_root): + if "__pycache__" in root: + continue + for name in files: + if name.endswith(".pyc"): + continue + full = Path(root) / name + zf.write(full, full.relative_to(src_root)) + + +def _package_code_zip( + ctx: StageContext, agent: Agent, cfg: ByocConfig, bucket: str +) -> StageResult: + build_dir = Path(f"/tmp/launchpad_byoc_{agent.name}") + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True) + zip_path = build_dir / "upload.zip" + byoc_uploads.download_upload(ctx.workspace, agent.workspace_id, cfg.upload_id or "", + zip_path) + src_root = byoc_uploads.extract_zip(zip_path, build_dir / "src") + + entry = src_root / cfg.entrypoint + if not entry.is_file(): + raise RuntimeError( + f"entrypoint '{cfg.entrypoint}' not found in the uploaded zip — " + "pick one of the detected candidates or re-upload" + ) + + t0 = time.monotonic() + installed = 0 + if cfg.install_requirements: + installed = resolve_requirements_into( + src_root, build_dir, cfg.python_version, ctx.log + ) + if installed == 0: + ctx.log("no requirements.txt in the zip — bundle shipped as uploaded") + else: + ctx.log("install_requirements=false — bundle shipped as uploaded") + + final_zip = build_dir / "deployment_package.zip" + _zip_tree(src_root, final_zip) + size_mb = final_zip.stat().st_size / 1e6 + secs = time.monotonic() - t0 + + s3_key = PACKAGE_KEY_TMPL.format(name=agent.name) + ctx.workspace.client("s3").upload_file(str(final_zip), bucket, s3_key) + ctx.scratch["s3_bucket"], ctx.scratch["s3_key"] = bucket, s3_key + ctx.log(f"package {secs:.1f}s · {size_mb:.1f}MB → s3://{bucket}/{s3_key}") + detail = f"code_zip · {size_mb:.1f}MB · s3 ✓" + if installed: + detail += f" · {installed} deps resolved (aarch64)" + return StageResult(detail=detail) + + +def _package_container_source( + ctx: StageContext, agent: Agent, cfg: ByocConfig +) -> StageResult: + build_dir = Path(f"/tmp/launchpad_byoc_{agent.name}") + if build_dir.exists(): + shutil.rmtree(build_dir) + build_dir.mkdir(parents=True) + zip_path = build_dir / "upload.zip" + byoc_uploads.download_upload(ctx.workspace, agent.workspace_id, cfg.upload_id or "", + zip_path) + # extract_zip normalizes a single top-level dir, so a `zip -r ctx.zip myagent/` + # upload still presents its Dockerfile at the build-context root + src_root = byoc_uploads.extract_zip(zip_path, build_dir / "src") + if not (src_root / "Dockerfile").is_file(): + raise RuntimeError( + "no Dockerfile at the zip root — a container_source upload must " + "carry the docker build context (Dockerfile + code)" + ) + # CodeBuild reads buildspec.yml from the source zip; the platform owns the + # build recipe, so this overwrites any buildspec the member uploaded — + # their Dockerfile is the only build input they control. + if (src_root / "buildspec.yml").exists(): + ctx.log("upload carries its own buildspec.yml — replaced by the platform's") + shutil.copy2(platform_buildspec_path(), src_root / "buildspec.yml") + archive = shutil.make_archive(str(build_dir / "context_src"), "zip", src_root) + tag, mins = build_and_push_image(ctx, agent, archive) + digest = ctx.scratch["image_digest"] + return StageResult(detail=f"codebuild · arm64 · {mins:.1f}m → :{tag} @ {digest[:19]}…") + + +def _stage_package(ctx: StageContext, agent: Agent) -> StageResult: + spec = AgentSpec(**agent.spec) + cfg = _config(spec) + if cfg.artifact_kind == "container_image": + # nothing to build — deploy pins the URI the generate stage verified + ctx.scratch["image_uri"] = cfg.image_uri + return StageResult(skipped=True, detail="existing image — no build") + bucket = ctx.workspace.resources.get("artifacts_bucket") + if not bucket: + raise RuntimeError( + "artifacts_bucket missing from this workspace's resource map — run its bootstrap" + ) + if cfg.artifact_kind == "code_zip": + return _package_code_zip(ctx, agent, cfg, bucket) + return _package_container_source(ctx, agent, cfg) + + +def _stage_provision(ctx: StageContext, agent: Agent, iam_client: Any = None) -> StageResult: + spec = AgentSpec(**agent.spec) + role_arn, detail = agent_iam.provision_execution_role( + agent, spec, get_settings(), ctx.workspace, ctx.log, iam=iam_client + ) + ctx.scratch["execution_role_arn"] = role_arn + return StageResult(detail=detail) + + +def _container_uri(ctx: StageContext, agent: Agent, cfg: ByocConfig) -> str: + if cfg.artifact_kind == "container_image": + return cfg.image_uri or "" + registry, repo, tag = _image_ref(ctx.workspace, agent) + return ( + ctx.scratch.get("image_uri") + or _recorded_digest_uri(ctx, registry, repo) + # only reached when the digest record is gone; the tag is mutable, so + # this is a fallback, not a path to rely on + or f"{registry}/{repo}:{tag}" + ) + + +def _stage_deploy(ctx: StageContext, agent: Agent) -> StageResult: + client = control_client(ctx.workspace) + mode = ctx.scratch.get("mode", "create") + db = ctx.session() + try: + row = db.get(Agent, agent.id) + spec = AgentSpec(**row.spec) + cfg = _config(spec) + role_arn = ctx.scratch.get("execution_role_arn") or ctx.workspace.resources.get( + "execution_role_arn", "" + ) + environment = runtime_environment(spec, ctx.workspace.resources) + # The per-agent execution role scopes bedrock:InvokeModel to exactly + # spec.allowed_model_ids (agent_iam.allowed_model_resources) — hand the + # permitted ids to the user code: MODEL_ID is the primary (= model_id), + # ALLOWED_MODEL_IDS the full comma-separated list. Explicit spec.env + # values win for both. + environment.setdefault("MODEL_ID", spec.model_id) + environment.setdefault("ALLOWED_MODEL_IDS", ",".join(spec.allowed_model_ids)) + + def _kwargs() -> dict: + if cfg.artifact_kind == "code_zip": + return { + "s3_bucket": ctx.scratch.get("s3_bucket") + or ctx.workspace.resources.get("artifacts_bucket", ""), + "s3_key": ctx.scratch.get("s3_key") + or PACKAGE_KEY_TMPL.format(name=row.name), + "role_arn": role_arn, + "environment": environment, + "python_version": cfg.python_version, + "entrypoint": cfg.entrypoint, + # user zips don't necessarily vendor the ADOT distro; an + # absent opentelemetry-instrument launcher fails at start + "instrument": False, + } + return { + "container_uri": _container_uri(ctx, row, cfg), + "role_arn": role_arn, + "environment": environment, + } + + create_fn = ( + rt.create_code_runtime if cfg.artifact_kind == "code_zip" + else rt.create_container_runtime + ) + update_fn = ( + rt.update_code_runtime if cfg.artifact_kind == "code_zip" + else rt.update_container_runtime + ) + + if mode == "update" and row.resource_id: # re-publish → new version, same ARN + runtime_id = row.resource_id + updated = agent_iam.retry_iam_propagation( + lambda: update_fn(client, runtime_id=runtime_id, **_kwargs()), + ctx.log, + ) + row.version = str(updated.get("agentRuntimeVersion", row.version or "1")) + db.commit() + ctx.log( + f"UpdateAgentRuntime accepted · runtimeId {runtime_id} · " + f"new version {row.version}" + ) + elif row.resource_id: + runtime_id = row.resource_id + ctx.log(f"resuming — runtime {runtime_id} already created, polling status") + else: + created = agent_iam.retry_iam_propagation( + lambda: create_fn( + client, runtime_name=sanitize_runtime_name(row.name), **_kwargs() + ), + ctx.log, + ) + runtime_id = created["agentRuntimeId"] + row.resource_id = runtime_id + row.arn = created["agentRuntimeArn"] + row.version = str(created.get("agentRuntimeVersion", "1")) + db.commit() + ctx.log(f"CreateAgentRuntime accepted · runtimeId {runtime_id}") + + ready = rt.wait_runtime_ready( + client, runtime_id, on_status=lambda s: ctx.log(f"runtime status: {s}") + ) + row.arn = ready["agentRuntimeArn"] + row.version = str(ready.get("agentRuntimeVersion", row.version or "1")) + db.commit() + return StageResult(detail=f"READY · {ready['agentRuntimeArn']}") + finally: + db.close() + + +def _stage_register(ctx: StageContext, agent: Agent) -> StageResult: + from app.deployer.registration import register_stage + + return register_stage(ctx, agent) + + +STAGES = { + "generate": _stage_generate, + "package": _stage_package, + "provision": _stage_provision, + "deploy": _stage_deploy, + "register": _stage_register, +} + +register_method("byoc", STAGES) + + +def delete_agent_resources( + agent: Agent, workspace: WorkspaceContext, ecr_client: Any = None +) -> None: + """Runtime + staged upload objects + (container_source) the built image tags. + + Everything after the runtime delete is best-effort: an orphan S3 object or + ECR tag must never block deleting the agent.""" + if agent.resource_id: + client = control_client(workspace) + try: + rt.delete_runtime(client, agent.resource_id) + except client.exceptions.ResourceNotFoundException: + pass + cfg = (agent.spec or {}).get("byoc") or {} + upload_id = cfg.get("upload_id") + if upload_id: + byoc_uploads.delete_upload_objects(workspace, agent.workspace_id, upload_id) + if cfg.get("artifact_kind") == "container_source": + # the builds this agent pushed are {name}-v{version} tags on the shared repo + repo = (workspace.resources or {}).get("ecr_repo", "launchpad-agents") + try: + ecr = ecr_client or workspace.client("ecr") + versions = range(1, int(agent.version or "1") + 1) + ecr.batch_delete_image( + repositoryName=repo, + imageIds=[{"imageTag": f"{agent.name}-v{v}"} for v in versions], + ) + except Exception: # noqa: BLE001 — cleanup must never block a delete + pass diff --git a/backend/app/deployer/container.py b/backend/app/deployer/container.py index a025f1db..41f5b674 100644 --- a/backend/app/deployer/container.py +++ b/backend/app/deployer/container.py @@ -24,11 +24,23 @@ from app.services.agentcore import runtime as rt from app.services.agentcore.client import control_client from app.services.workspace import WorkspaceContext -from app.templates.claude_sdk_agent import assemble_build_context +from app.templates.claude_sdk_agent import TEMPLATE_DIR, assemble_build_context from .zip_runtime import bundle_skill_paths_into, sanitize_runtime_name +def platform_buildspec_path() -> Path: + """The platform-owned CodeBuild recipe (docker build + push, ARM64). + + The launchpad-agent-builder project has no inline buildspec — it reads + buildspec.yml from the source zip, so every build context this platform + submits must ship this exact file. Callers that accept user-supplied build + contexts (BYOC container_source) must copy it in OVERWRITING any + buildspec.yml in the upload: the member controls the Dockerfile only, + never the build recipe.""" + return TEMPLATE_DIR / "buildspec.yml" + + def _image_ref(workspace: WorkspaceContext, agent: Agent) -> tuple[str, str, str]: registry = f"{workspace.account_id}.dkr.ecr.{workspace.region}.amazonaws.com" repo = workspace.resources.get("ecr_repo", "launchpad-agents") @@ -56,7 +68,16 @@ def _stage_generate(ctx: StageContext, agent: Agent) -> StageResult: return StageResult(detail=f"container context · {len(files)} files") -def _stage_package(ctx: StageContext, agent: Agent) -> StageResult: +def build_and_push_image(ctx: StageContext, agent: Agent, archive: str) -> tuple[str, float]: + """Upload one build-context archive → CodeBuild (ARM64) → ECR; pin + gate. + + The build/wait/digest/scan sequence shared by the container method and BYOC + ``container_source``: uploads ``archive`` to ``builds/{agent.name}/source.zip``, + runs the workspace's ``launchpad-agent-builder`` project, resolves the pushed + tag to its immutable digest (recorded on the Deployment row so a resumed job + re-uses the same image) and runs the scan gate. Returns + ``(tag, minutes)`` and leaves ``image_digest``/``image_uri`` in scratch. + """ settings = get_settings() workspace = ctx.workspace bucket = workspace.resources.get("artifacts_bucket") @@ -67,12 +88,6 @@ def _stage_package(ctx: StageContext, agent: Agent) -> StageResult: "resource map — run its bootstrap" ) - spec = AgentSpec(**agent.spec) - context_dir = Path( - ctx.scratch.get("context_dir") - or assemble_build_context(spec, Path(f"/tmp/launchpad_ctx_{agent.name}")) - ) - archive = shutil.make_archive(str(context_dir) + "_src", "zip", context_dir) s3_key = f"builds/{agent.name}/source.zip" workspace.client("s3").upload_file(archive, bucket, s3_key) ctx.log(f"source zip uploaded → s3://{bucket}/{s3_key}") @@ -105,6 +120,18 @@ def _stage_package(ctx: StageContext, agent: Agent) -> StageResult: ctx.log(f"image pushed · {registry}/{repo}:{tag} · {digest}") _run_scan_gate(ctx, ecr_client, repo, digest, settings) + return tag, mins + + +def _stage_package(ctx: StageContext, agent: Agent) -> StageResult: + spec = AgentSpec(**agent.spec) + context_dir = Path( + ctx.scratch.get("context_dir") + or assemble_build_context(spec, Path(f"/tmp/launchpad_ctx_{agent.name}")) + ) + archive = shutil.make_archive(str(context_dir) + "_src", "zip", context_dir) + tag, mins = build_and_push_image(ctx, agent, archive) + digest = ctx.scratch["image_digest"] return StageResult(detail=f"codebuild · arm64 · {mins:.1f}m → :{tag} @ {digest[:19]}…") diff --git a/backend/app/deployer/zip_runtime.py b/backend/app/deployer/zip_runtime.py index bc722934..3088a81a 100644 --- a/backend/app/deployer/zip_runtime.py +++ b/backend/app/deployer/zip_runtime.py @@ -24,6 +24,7 @@ from typing import Any from app.core.config import get_settings +from app.core.runtime_target import TARGET_PYTHON, pip_platform_args, uv_platform from app.deployer.environment import runtime_environment from app.deployer.pipeline import StageContext, StageResult, register_method from app.models.ledger import Agent @@ -31,6 +32,7 @@ from app.services import agent_iam from app.services.agentcore import runtime as rt from app.services.agentcore.client import control_client +from app.services.requirements_txt import RESOLVE_FIX_HINTS, summarize_resolver_failure from app.services.skill_ingest import SKILL_BUNDLE_MAX_BYTES, SKILL_NAME_RE from app.services.workspace import WorkspaceContext from app.templates.strands_agent import base_requirements, render_main_py @@ -42,12 +44,11 @@ def sanitize_runtime_name(name: str) -> str: return f"{base}_{uuid.uuid4().hex[:6]}" -# The deploy target: AgentCore Runtime zips run ARM64 on Python 3.13. Named once -# because the resolve and the install must agree — resolving for this host and -# installing for aarch64 would produce a lock that does not match the artifact. -TARGET_PYTHON = "3.13" -TARGET_PIP_PLATFORM = "manylinux2014_aarch64" -TARGET_UV_PLATFORM = "aarch64-manylinux2014" +# The deploy target: AgentCore Runtime zips run ARM64 on Python 3.13. The +# manylinux level (default manylinux_2_28 — AL2023/glibc 2.34, measured +# 2026-09-18) is defined once in app/core/runtime_target.py because the resolve +# and the install must agree — resolving for this host and installing for +# aarch64 would produce a lock that does not match the artifact. LOCK_FILENAME = "requirements.lock" @@ -56,6 +57,7 @@ def _compile_lock( requirements: list[str], build_dir: Path, compile_runner: Callable[..., Any], + python_version: str | None = None, ) -> Path: """Resolve the declared requirements into a fully hashed lockfile. @@ -73,11 +75,11 @@ def _compile_lock( [ "uv", "pip", "compile", str(declared), "--generate-hashes", "--quiet", - "--python-version", TARGET_PYTHON, - "--python-platform", TARGET_UV_PLATFORM, + "--python-version", python_version or TARGET_PYTHON, + "--python-platform", uv_platform(), # The install below is binary-only. Resolve from that same artifact # set, or uv can lock an sdist-only release that pip then cannot - # install for the Runtime's ARM64 manylinux2014 target. + # install for the Runtime's ARM64 binary-only target. "--only-binary=:all:", "-o", str(lock), ], @@ -85,10 +87,13 @@ def _compile_lock( text=True, ) if proc.returncode != 0: - detail = (proc.stderr or proc.stdout or "").strip()[-2000:] raise RuntimeError( - f"could not resolve {requirements} into a hashed lockfile: {detail} " - "(the backend needs the `uv` CLI on PATH and access to the package index)" + "could not resolve the requirements into a hashed lockfile: " + + summarize_resolver_failure( + proc.stderr or proc.stdout or "", + python_version=python_version or TARGET_PYTHON, + hints=RESOLVE_FIX_HINTS, + ) ) return lock @@ -130,7 +135,7 @@ def build_zip( # shipping. "--require-hashes", "-r", str(lock), "-t", str(pkg_dir), - "--platform", TARGET_PIP_PLATFORM, + *pip_platform_args(), "--only-binary=:all:", "--python-version", TARGET_PYTHON, "--quiet", @@ -139,8 +144,10 @@ def build_zip( text=True, ) if proc.returncode != 0: - stderr = (proc.stderr or "").strip()[-2000:] - raise RuntimeError(f"pip install failed for {requirements}: {stderr}") + raise RuntimeError( + "pip install of the locked requirements failed: " + + summarize_resolver_failure(proc.stderr or "", hints=RESOLVE_FIX_HINTS) + ) (pkg_dir / "main.py").write_text(code, encoding="utf-8") (pkg_dir / "requirements.txt").write_text("\n".join(requirements) + "\n", encoding="utf-8") diff --git a/backend/app/evaluation/service.py b/backend/app/evaluation/service.py index 99d3d7f1..d4737e0e 100644 --- a/backend/app/evaluation/service.py +++ b/backend/app/evaluation/service.py @@ -44,7 +44,9 @@ from app.services.workspace import WorkspaceContext, context_for_workspace from app.templates import gateway_support -EVAL_SUPPORTED_METHODS = {"zip_runtime", "studio", "container", "harness"} +# byoc: telemetry identity derivation is method-agnostic for runtimes; whether +# the user's code emits gen_ai spans for the evaluator to read is theirs. +EVAL_SUPPORTED_METHODS = {"zip_runtime", "studio", "container", "harness", "byoc"} TELEMETRY_READY_GRACE_SECONDS = 120 TELEMETRY_QUERY_LOOKBACK_MS = 60_000 diff --git a/backend/app/main.py b/backend/app/main.py index d3780167..d00ec6d7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +import app.deployer.byoc # noqa: F401 — registers the byoc (bring your own code) method import app.deployer.container # noqa: F401 — registers the container (Claude SDK) method import app.deployer.harness # noqa: F401 — registers the harness deploy method import app.deployer.zip_runtime # noqa: F401 — registers zip_runtime + studio methods @@ -49,7 +50,7 @@ from app.routers.tools import router as tools_router from app.routers.users import router as users_router from app.routers.workspaces import router as workspaces_router -from app.services import local_exec +from app.services import byoc_uploads, local_exec from app.services.governance import reconcile_policy_changes from app.services.model_prices import start_auto_refresh from app.skill_lab import task_assets @@ -120,8 +121,9 @@ def create_app(resume_jobs: bool = False) -> FastAPI: app.middleware("http")(hsts) # Register before auth so Starlette's reverse middleware stack keeps auth - # outermost while this exact-route gate still runs before multipart parsing. + # outermost while these exact-route gates still run before multipart parsing. app.middleware("http")(task_assets.task_asset_body_limit_middleware) + app.middleware("http")(byoc_uploads.upload_body_limit_middleware) app.middleware("http")(auth_middleware) app.add_middleware(AssistantBodyCap) # ingress byte cap for assistant writes app.add_middleware( diff --git a/backend/app/models/ledger.py b/backend/app/models/ledger.py index 6b98e877..fee74c47 100644 --- a/backend/app/models/ledger.py +++ b/backend/app/models/ledger.py @@ -87,7 +87,7 @@ class Agent(Base): # uniqueness among non-deleted rows is enforced in the API layer, so a # deleted agent's name can be reused name: Mapped[str] = mapped_column(String(64), index=True) - method: Mapped[str] = mapped_column(String(24)) # harness|zip_runtime|container|studio + method: Mapped[str] = mapped_column(String(24)) # harness|zip_runtime|container|studio|byoc status: Mapped[str] = mapped_column(String(24), default="draft") # draft | deploying | active | failed | deleted spec: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict) diff --git a/backend/app/optimization/service.py b/backend/app/optimization/service.py index dbf189fd..ec8ee5be 100644 --- a/backend/app/optimization/service.py +++ b/backend/app/optimization/service.py @@ -263,6 +263,18 @@ def experiment_capability(agent_row: Any) -> dict[str, Any]: "reason_code": "system-managed", "reason": "System-managed presets cannot be modified by an experiment.", } + if agent_row.method == "byoc": + # Same verdict as spec.code/code_bundle below — BYOC is user source by + # definition, and it never reaches that branch because the method gate + # right after this would answer "not-http-runtime" instead. + return { + **base, + "reason_code": "custom-source-unverified", + "reason": ( + "Custom runtime source is not verified to consume " + "Launchpad configuration bundles." + ), + } if agent_row.method != "zip_runtime": return { **base, @@ -332,7 +344,7 @@ def canary_capability(agent_row: Any) -> dict[str, Any]: "reason_code": "not-active", "reason": "Canary agent must be active.", } - if agent_row.method not in {"zip_runtime", "container", "studio"}: + if agent_row.method not in {"zip_runtime", "container", "studio", "byoc"}: return { **base, "reason_code": "not-runtime", @@ -344,6 +356,14 @@ def canary_capability(agent_row: Any) -> dict[str, Any]: "reason_code": "container-followup", "reason": "Container canary candidate minting via CodeBuild is a follow-up.", } + if agent_row.method == "byoc": + # candidate minting rebuilds the artifact from an edited spec, which the + # platform cannot do for user-owned source + return { + **base, + "reason_code": "custom-source-unverified", + "reason": "BYOC candidates cannot be minted from an edited spec.", + } if (agent_row.spec or {}).get("protocol", "http") != "http": return { **base, diff --git a/backend/app/routers/agents.py b/backend/app/routers/agents.py index 044c5494..3b880f5d 100644 --- a/backend/app/routers/agents.py +++ b/backend/app/routers/agents.py @@ -1,25 +1,38 @@ -"""Agents API — create/deploy, list, invoke, delete; jobs polling.""" +"""Agents API — create/deploy, list, invoke, delete; jobs polling; BYOC uploads.""" +import hashlib import json import logging +import tempfile import time from datetime import UTC, datetime -from typing import Any +from pathlib import Path +from typing import Any, get_args -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request +from fastapi.concurrency import run_in_threadpool from sqlalchemy.orm import Session +from starlette.datastructures import UploadFile from app.core.config import get_settings from app.core.db import get_db from app.core.errors import AppError, NotFoundError +from app.deployer import byoc as byoc_method from app.deployer import container as container_method from app.deployer import harness as harness_method from app.deployer import zip_runtime as zip_method from app.deployer.pipeline import create_deployment, start_deploy_async from app.models.ledger import Agent, Deployment, Job +from app.routers.auth import require_identity from app.routers.workspaces import WorkspaceScope, require_workspace -from app.schemas.agent import AgentSpec, InvokeRequest, InvokeResponse, RuntimeImportRequest -from app.services import agent_iam, agent_names +from app.schemas.agent import ( + AgentSpec, + ByocPythonVersion, + InvokeRequest, + InvokeResponse, + RuntimeImportRequest, +) +from app.services import agent_iam, agent_names, byoc_uploads from app.services.agent_versions import list_agent_versions from app.services.agentcore.client import control_client from app.services.invoke import invoke_agent_text @@ -41,7 +54,8 @@ router = APIRouter(prefix="/api", tags=["agents"]) -SUPPORTED_METHODS = {"harness", "zip_runtime", "container", "studio"} +SUPPORTED_METHODS = {"harness", "zip_runtime", "container", "studio", "byoc"} +BYOC_PYTHON_VERSIONS = set(get_args(ByocPythonVersion)) def _agent_out(agent: Agent, deployment: Deployment | None = None) -> dict[str, Any]: @@ -114,6 +128,8 @@ def _delete_agent_resources(agent: Agent, workspace: WorkspaceContext) -> bool: zip_method.delete_agent_resources(agent, workspace) elif agent.method == "container": container_method.delete_agent_resources(agent, workspace) + elif agent.method == "byoc": + byoc_method.delete_agent_resources(agent, workspace) # After the resource, never before: deleting the execution role while the # runtime still references it can wedge the runtime's own deletion. A failed # role delete must not block deleting the agent, so this returns rather than @@ -226,6 +242,85 @@ def import_discovered_runtimes( return result +@router.post("/agents/uploads", status_code=201) +async def upload_byoc_artifact( + request: Request, + python_version: str = "PYTHON_3_13", + ws: WorkspaceScope = Depends(require_workspace), +) -> dict[str, Any]: + """Stage a BYOC source zip (multipart, single part ``file``, .zip only). + + Streams to a temp file (250 MiB cap enforced mid-stream — the Content-Length + guard in ``byoc_uploads.upload_body_limit_middleware`` already refused + known-oversize bodies before the parser ran), validates the archive without + executing anything in it, stores zip + manifest to the artifacts bucket under + ``byoc/{workspace_id}/{upload_id}/`` and returns the detection summary — + including a dry resolve of the zip's requirements.txt against the deploy + target for ``python_version``, so the wizard can flag an unresolvable file + before deploy. Staging runs in the threadpool: the resolve may take tens of + seconds and must not stall the event loop. + """ + identity = require_identity(request) + if python_version not in BYOC_PYTHON_VERSIONS: + raise AppError( + "byoc.invalid_python_version", + f"python_version must be one of {sorted(BYOC_PYTHON_VERSIONS)}", + status_code=422, + ) + form = await request.form() + upload = form.get("file") + if not isinstance(upload, UploadFile): + raise AppError("byoc.invalid_upload", "expected a .zip part named 'file'", + status_code=400) + filename = Path(upload.filename or "").name + if not filename.lower().endswith(".zip"): + raise AppError("byoc.invalid_upload", "expected a .zip file", status_code=400) + + digest = hashlib.sha256() + size = 0 + with tempfile.TemporaryDirectory(prefix="byoc-upload-") as tmp: + tmp_zip = Path(tmp) / "source.zip" + with tmp_zip.open("wb") as target: + while chunk := await upload.read(1024 * 1024): + size += len(chunk) + if size > byoc_uploads.MAX_ZIP_BYTES: + raise AppError( + "byoc.upload_too_large", + "BYOC upload exceeds the 250 MiB zip limit", + status_code=413, + ) + digest.update(chunk) + target.write(chunk) + if size == 0: + raise AppError("byoc.invalid_upload", "the uploaded file is empty", + status_code=400) + manifest = await run_in_threadpool( + byoc_uploads.stage_upload, + ws.context, + filename=filename, + tmp_zip=tmp_zip, + sha256=digest.hexdigest(), + size_bytes=size, + uploaded_by=identity.username, + uploaded_at=datetime.now(UTC).isoformat(timespec="seconds"), + python_version=python_version, + ) + logger.info( + "byoc upload %s staged by %s (%s, %d bytes, sha256 %s)", + manifest["upload_id"], identity.username, filename, size, manifest["sha256"][:12], + ) + return manifest + + +@router.get("/agents/uploads/{upload_id}") +def get_byoc_upload( + upload_id: str, + ws: WorkspaceScope = Depends(require_workspace), +) -> dict[str, Any]: + """The stored detection summary + provenance for one staged BYOC upload.""" + return byoc_uploads.get_manifest(ws.context, upload_id) + + @router.get("/agents/{agent_id}") def get_agent( agent_id: str, diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index ca9faa66..7903c855 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -12,7 +12,7 @@ # bedrock list-inference-profiles; there is no "sonnet-5" profile). DEFAULT_MODEL_ID = "global.anthropic.claude-sonnet-5" -Method = Literal["harness", "zip_runtime", "container", "studio"] +Method = Literal["harness", "zip_runtime", "container", "studio", "byoc"] # Which agent SDK the "container" method packages. The console presents that # method as the "Other Agent SDK" entrance with this as a second-level choice, @@ -181,6 +181,117 @@ def _check(self) -> "FilesystemConfig": _ALLOWED_TOOL_RE = re.compile(r"^(\*|@?[^/]+(/[^/]+)?)$") ALLOWED_TOOL_MAX_LEN = 64 +# ── BYOC (bring your own code) ────────────────────────────────────────────── + +# What the uploaded/BYO artifact is. code_zip → direct-code Runtime +# (codeConfiguration); container_source → CodeBuild → ECR → Runtime; +# container_image → an existing private-ECR image, no build. +ByocArtifactKind = Literal["code_zip", "container_source", "container_image"] + +# AgentManagedRuntimeType members the console offers (the service model also +# carries PYTHON_3_14/NODE_22; Python-only here because requirements resolution +# targets CPython wheels). +ByocPythonVersion = Literal["PYTHON_3_10", "PYTHON_3_11", "PYTHON_3_12", "PYTHON_3_13"] + +# Ceiling for ``ByocConfig.allowed_models`` — each entry becomes one or two ARNs +# in the execution role's bedrock:InvokeModel statement, so the bound is an IAM +# policy-size sanity cap, not a model catalogue. +BYOC_ALLOWED_MODELS_MAX = 20 + +# Private ECR in *some* account/region — the workspace match (this account, this +# region) is a resource check, done against the WorkspaceContext at request time, +# not here. Public registries (public.ecr.aws, docker.io) never match. +_PRIVATE_ECR_IMAGE_RE = re.compile( + r"^(\d{12})\.dkr\.ecr\.([a-z0-9-]+)\.amazonaws\.com/" + r"[a-z0-9._/-]+(:[A-Za-z0-9._-]+|@sha256:[0-9a-f]{64})$" +) + + +def parse_ecr_image_uri(uri: str) -> tuple[str, str] | None: + """(account_id, region) of a private-ECR image URI, or None if not one.""" + match = _PRIVATE_ECR_IMAGE_RE.match(uri) + return (match.group(1), match.group(2)) if match else None + + +class ByocProvenance(BaseModel): + """Who uploaded what — stamped by the SERVER from the stored upload manifest + (or, for container_image, from the describe_images check), never taken from + the client. Rendered on the agent detail page.""" + + sha256: str = "" + size_bytes: int = 0 + original_filename: str = Field(default="", max_length=255) + uploaded_by: str = Field(default="", max_length=128) + uploaded_at: str = Field(default="", max_length=64) + + +class ByocConfig(BaseModel): + """User-code deployment settings for method="byoc".""" + + artifact_kind: ByocArtifactKind + # the staged zip (POST /api/agents/uploads) — code_zip / container_source + upload_id: str | None = Field(default=None, max_length=64, pattern=r"^[A-Za-z0-9_-]+$") + # an existing image — container_image only + image_uri: str | None = Field(default=None, max_length=512) + # code_zip only: must exist at the zip root (or the single top-level dir) + entrypoint: str = Field(default="main.py", max_length=255) + python_version: ByocPythonVersion = "PYTHON_3_13" + # code_zip: resolve requirements.txt in the zip for linux/aarch64 at package + # time (skipped when the zip has none). Never executes user code. + install_requirements: bool = True + # Documentation-only in v1: both values send {"prompt", "actor_id"}; "raw" + # merely acknowledges the contract warning in the console. No payload mapper. + invoke_contract: Literal["launchpad_prompt", "raw"] = "launchpad_prompt" + # Every Bedrock model this agent's code may invoke (foundation-model or + # inference-profile ids — deliberately unvalidated beyond shape, same as + # spec.model_id: custom ids are first-class and the valid id space cannot be + # enumerated). The execution role's bedrock:InvokeModel statement covers the + # union; entry [0] is the PRIMARY model (= spec.model_id, injected as env + # MODEL_ID). None ⇒ [spec.model_id] — every spec written before this field + # existed reads back unchanged. + allowed_models: list[str] | None = Field(default=None, min_length=1, + max_length=BYOC_ALLOWED_MODELS_MAX) + provenance: ByocProvenance | None = None + + @model_validator(mode="after") + def _allowed_models_shape(self) -> "ByocConfig": + if self.allowed_models is None: + return self + cleaned = [model_id.strip() for model_id in self.allowed_models] + if any(not model_id for model_id in cleaned): + raise ValueError("allowed_models entries cannot be empty") + if len(cleaned) != len(set(cleaned)): + raise ValueError("allowed_models entries must be unique") + self.allowed_models = cleaned + return self + + @model_validator(mode="after") + def _artifact_inputs(self) -> "ByocConfig": + if self.artifact_kind in ("code_zip", "container_source"): + if not self.upload_id: + raise ValueError( + f"byoc artifact_kind={self.artifact_kind} requires upload_id " + "(upload the zip via POST /api/agents/uploads first)" + ) + if self.image_uri: + raise ValueError("image_uri applies to artifact_kind=container_image only") + else: # container_image + if not self.image_uri: + raise ValueError("byoc artifact_kind=container_image requires image_uri") + if self.upload_id: + raise ValueError("upload_id applies to the zip artifact kinds only") + if parse_ecr_image_uri(self.image_uri) is None: + raise ValueError( + "image_uri must be a private ECR image in this account " + "(.dkr.ecr..amazonaws.com/:); " + "public registries are refused" + ) + if self.artifact_kind == "code_zip": + entry = self.entrypoint + if not entry.endswith(".py") or entry.startswith("/") or ".." in entry: + raise ValueError("entrypoint must be a relative .py path inside the zip") + return self + class AgentSpec(BaseModel): name: str = Field(pattern=r"^[a-z][a-z0-9-]{2,47}$") @@ -208,7 +319,9 @@ class AgentSpec(BaseModel): # including those written before this field existed — reads back as the # Claude Agent SDK instead of being ambiguous. agent_sdk: AgentSdk = "claude_agent_sdk" - system_prompt: str = Field(min_length=1, max_length=20000) + # Non-empty for every method except byoc (enforced in _byoc_constraints): + # BYOC code carries its own prompt — the platform has no template to put one in. + system_prompt: str = Field(default="", max_length=20000) # Durable production defaults applied by experiment promotion. Config # bundles may override these per request while an A/B test is active. tool_description_overrides: dict[str, str] = Field( @@ -264,6 +377,71 @@ class AgentSpec(BaseModel): protocol: Literal["http", "a2a"] = "http" # AgentCard skills served by the A2A server and published to the Registry a2a_skills: list[A2ASkill] = Field(default_factory=list, max_length=20) + # Bring-your-own-code settings — required iff method="byoc" + byoc: ByocConfig | None = None + + @model_validator(mode="after") + def _byoc_constraints(self) -> "AgentSpec": + """BYOC scope for v1 — and the non-byoc system_prompt floor. + + The platform does not generate BYOC code, so it cannot wire toolkits, + skills, tools or knowledge bases into it — those are refused rather than + silently ignored. ``system_prompt`` stays mandatory for every other + method (it used to be ``min_length=1`` on the field; the floor moved here + so BYOC — whose code owns its own prompt — can omit it). + """ + if self.method != "byoc": + if not self.system_prompt: + raise ValueError("system_prompt must not be empty") + if self.byoc is not None: + raise ValueError("byoc settings apply to method='byoc' only") + return self + if self.byoc is None: + raise ValueError("method='byoc' requires the byoc settings block") + if self.protocol != "http": + raise ValueError("byoc agents speak the HTTP runtime contract only in v1") + for field_name in ("tools", "toolkits", "skills", "knowledge_bases"): + if getattr(self, field_name): + raise ValueError( + f"{field_name} are not supported by the byoc method in v1 — " + "the platform does not generate this agent's code, so it " + "cannot wire them in; configure them inside your own code" + ) + if self.code or self.code_bundle: + raise ValueError( + "byoc deploys the uploaded artifact — code/code_bundle are not used" + ) + if self.requirements: + raise ValueError( + "byoc resolves requirements.txt from inside the uploaded zip — " + "spec.requirements is not used" + ) + models = self.byoc.allowed_models + if models is not None: + # model_id is the PRIMARY model and must equal allowed_models[0]: + # a client that sends only the list gets the first entry as primary; + # a client that sends both gets its model_id moved to the front so + # the two fields can never disagree about which model is primary. + if "model_id" not in self.model_fields_set: + self.model_id = models[0] + elif self.model_id not in models: + raise ValueError( + f"model_id {self.model_id!r} must be one of byoc.allowed_models " + "— it is the primary model (env MODEL_ID)" + ) + elif models[0] != self.model_id: + models.remove(self.model_id) + models.insert(0, self.model_id) + return self + + @property + def allowed_model_ids(self) -> list[str]: + """Every model this agent's execution role may invoke; entry [0] is the + primary (= ``model_id``). Only byoc can carry more than one — every other + method (and every byoc spec without the list) reads back as [model_id].""" + if self.byoc and self.byoc.allowed_models: + return list(self.byoc.allowed_models) + return [self.model_id] @model_validator(mode="after") def _a2a_constraints(self) -> "AgentSpec": diff --git a/backend/app/schemas/requirements.py b/backend/app/schemas/requirements.py index a35b91bc..bcb22acb 100644 --- a/backend/app/schemas/requirements.py +++ b/backend/app/schemas/requirements.py @@ -31,6 +31,9 @@ from pathlib import Path from typing import Any +from app.core.runtime_target import TARGET_PYTHON as _TARGET_PYTHON +from app.core.runtime_target import uv_platform as _uv_platform + # name[extra1,extra2]==version, optionally followed by ; markers _PINNED_RE = re.compile( r"""^ @@ -116,9 +119,9 @@ def assert_all_pinned(entries: list[str]) -> None: # the option that keeps the feature and the guarantee. # --------------------------------------------------------------------------- -# The target the deploy pipeline installs for (mirrors zip_runtime.build_zip). -_TARGET_PYTHON = "3.13" -_TARGET_PLATFORM = "aarch64-manylinux2014" +# The target the deploy pipeline installs for is the same single definition +# `zip_runtime.build_zip` resolves against: `_TARGET_PYTHON` / `_uv_platform` +# from app/core/runtime_target.py, imported above. _NAME_EXTRAS_RE = re.compile( r"^(?P[A-Za-z0-9][A-Za-z0-9._-]*)\s*(?:\[(?P[^\]]+)\])?" @@ -185,7 +188,7 @@ def resolve_pins( [ "uv", "pip", "compile", str(src), "--quiet", "--python-version", _TARGET_PYTHON, - "--python-platform", _TARGET_PLATFORM, + "--python-platform", _uv_platform(), # Keep conversion-time pins inside the same wheel-only artifact # set the package stage can install for AgentCore Runtime. "--only-binary=:all:", diff --git a/backend/app/services/agent_iam.py b/backend/app/services/agent_iam.py index d8914028..80801520 100644 --- a/backend/app/services/agent_iam.py +++ b/backend/app/services/agent_iam.py @@ -194,6 +194,17 @@ def model_resources(model_id: str, ctx: RoleContext) -> list[str]: return ["arn:aws:bedrock:*::foundation-model/*"] +def allowed_model_resources(spec: AgentSpec, ctx: RoleContext) -> list[str]: + """Union of `model_resources` over every model the spec permits, deduped in + order. One entry for every method except byoc, whose ``allowed_models`` list + may authorize several — each still scoped to its exact id, never widened.""" + return list(dict.fromkeys( + arn + for model_id in spec.allowed_model_ids + for arn in model_resources(model_id, ctx) + )) + + def _uses_gateway(spec: AgentSpec) -> bool: """Whether anything in the spec needs an AgentCore workload token. @@ -275,12 +286,12 @@ def policy_document(spec: AgentSpec, ctx: RoleContext, *, system_preset: bool = """ statements: list[dict[str, Any]] = [] - # ---- models: always needed, scoped to the configured id ---- + # ---- models: always needed, scoped to the configured id(s) ---- statements.append({ "Sid": "BedrockModels", "Effect": "Allow", "Action": ["bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream"], - "Resource": model_resources(spec.model_id, ctx), + "Resource": allowed_model_resources(spec, ctx), }) if spec.model_source == "mantle": @@ -403,12 +414,23 @@ def policy_document(spec: AgentSpec, ctx: RoleContext, *, system_preset: bool = }) # ---- container image pull ---- - if spec.method == "container": + byoc_kind = spec.byoc.artifact_kind if spec.byoc else None + if spec.method == "container" or byoc_kind in ("container_source", "container_image"): + # byoc container_image may name any repo in this account; scope to it + # rather than the shared launchpad-agents repo + if byoc_kind == "container_image" and spec.byoc and spec.byoc.image_uri: + repo_name = spec.byoc.image_uri.split(".amazonaws.com/", 1)[1] + repo_name = repo_name.split("@", 1)[0].rsplit(":", 1)[0] + repo_arn = ( + f"arn:aws:ecr:{ctx.region}:{ctx.account_id}:repository/{repo_name}" + ) + else: + repo_arn = ctx.ecr_repo_arn statements.append({ "Sid": "EcrPull", "Effect": "Allow", "Action": ["ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage"], - "Resource": [ctx.ecr_repo_arn], + "Resource": [repo_arn], }) statements.append({ # UNSCOPABLE: ecr:GetAuthorizationToken takes no resource by design. diff --git a/backend/app/services/agent_versions.py b/backend/app/services/agent_versions.py index 3fd5ca30..cd236135 100644 --- a/backend/app/services/agent_versions.py +++ b/backend/app/services/agent_versions.py @@ -27,7 +27,7 @@ ResourceKind = Literal["runtime", "harness"] -RUNTIME_METHODS = {"zip_runtime", "studio", "container"} +RUNTIME_METHODS = {"zip_runtime", "studio", "container", "byoc"} DEFAULT_ENDPOINT = "DEFAULT" CANARY_ENDPOINTS = ("stable", "treatment") diff --git a/backend/app/services/agentcore/runtime.py b/backend/app/services/agentcore/runtime.py index d02e8e9c..e8801d22 100644 --- a/backend/app/services/agentcore/runtime.py +++ b/backend/app/services/agentcore/runtime.py @@ -39,17 +39,20 @@ def create_code_runtime( role_arn: str, environment: dict[str, str] | None = None, protocol: str | None = None, + python_version: str | None = None, + entrypoint: str | None = None, + instrument: bool = True, ) -> dict[str, Any]: - """CreateAgentRuntime from a zip on S3, instrumented via ADOT.""" + """CreateAgentRuntime from a zip on S3, instrumented via ADOT. + + ``python_version``/``entrypoint`` default to the platform artifact contract + (PYTHON_3_13, main.py); BYOC passes the user's choices through and disables + the ADOT launcher (user zips don't necessarily vendor the distro).""" params: dict[str, Any] = { "agentRuntimeName": runtime_name, - "agentRuntimeArtifact": { - "codeConfiguration": { - "code": {"s3": {"bucket": s3_bucket, "prefix": s3_key}}, - "runtime": "PYTHON_3_13", - "entryPoint": ["opentelemetry-instrument", "main.py"], - } - }, + "agentRuntimeArtifact": _code_artifact( + s3_bucket, s3_key, python_version, entrypoint, instrument + ), "networkConfiguration": {"networkMode": "PUBLIC"}, "roleArn": role_arn, } @@ -107,12 +110,24 @@ def create_container_runtime( return client.create_agent_runtime(**params) -def _code_artifact(s3_bucket: str, s3_key: str) -> dict[str, Any]: +def _code_artifact( + s3_bucket: str, + s3_key: str, + python_version: str | None = None, + entrypoint: str | None = None, + instrument: bool = True, +) -> dict[str, Any]: + """``instrument=False`` drops the opentelemetry-instrument launcher — BYOC + zips only carry it when the member's own requirements install the distro, + and an absent launcher fails the runtime at start.""" + entry = [entrypoint or "main.py"] + if instrument: + entry.insert(0, "opentelemetry-instrument") return { "codeConfiguration": { "code": {"s3": {"bucket": s3_bucket, "prefix": s3_key}}, - "runtime": "PYTHON_3_13", - "entryPoint": ["opentelemetry-instrument", "main.py"], + "runtime": python_version or "PYTHON_3_13", + "entryPoint": entry, } } @@ -126,6 +141,9 @@ def update_code_runtime( role_arn: str, environment: dict[str, str] | None = None, protocol: str | None = None, + python_version: str | None = None, + entrypoint: str | None = None, + instrument: bool = True, ) -> dict[str, Any]: """UpdateAgentRuntime with a new zip artifact — publishes a new version in place (same agentRuntimeId/ARN; the DEFAULT endpoint auto-rolls to it). @@ -134,7 +152,9 @@ def update_code_runtime( resets an omitted protocolConfiguration back to HTTP (probed live).""" params: dict[str, Any] = { "agentRuntimeId": runtime_id, - "agentRuntimeArtifact": _code_artifact(s3_bucket, s3_key), + "agentRuntimeArtifact": _code_artifact( + s3_bucket, s3_key, python_version, entrypoint, instrument + ), "networkConfiguration": {"networkMode": "PUBLIC"}, "roleArn": role_arn, } @@ -429,6 +449,74 @@ def _runtime_payload_events(payload: Any) -> Iterator[dict[str, Any]]: yield {"event": "delta", "data": {"text": str(text)}} if "result" in payload: yield {"event": "complete", "data": {"text": str(payload.get("result", ""))}} + elif not (payload.keys() & _KNOWN_PAYLOAD_KEYS): + text = _free_form_payload_text(payload) + if text: + yield {"event": "complete", "data": {"text": text}} + + +# Keys that mark a payload as one of the shapes handled above (Launchpad's own +# delta/tool/complete envelope, BedrockAgentCoreApp's {"result"} body, Converse +# stream events, runtime error wrappers). Anything else is user code answering +# its own JSON — the Runtime HTTP contract only requires JSON or SSE and never +# names a key (the devguide's own example is {"response", "status"}). +_KNOWN_PAYLOAD_KEYS = frozenset( + { + "result", + "event", + "error", + "contentBlockStart", + "contentBlockDelta", + "contentBlockStop", + "messageStart", + "messageStop", + "metadata", + "runtimeClientError", + "internalServerException", + } +) +# Conventional text keys, in preference order: the devguide example, then the +# names BYOC code in the wild actually uses (measured 2026-09-18: a CrewAI +# agent answering {"answer", "session_id", "turns", "latency_ms"} rendered as +# an empty reply with no error). +_FREE_FORM_TEXT_KEYS = ( + "response", + "answer", + "output", + "output_text", + "text", + "message", + "content", + "completion", + "reply", +) +_FREE_FORM_DUMP_LIMIT = 4000 + + +def _free_form_payload_text(payload: dict[str, Any]) -> str: + """Text for a JSON body that follows none of the known shapes. + + Takes the first conventional key holding a non-empty string; a nested + ``{"text": ...}`` block (Converse content-block style) under such a key + also counts. Otherwise the whole body is shown as compact JSON so the + operator sees exactly what the agent answered instead of a blank turn. + """ + for key in _FREE_FORM_TEXT_KEYS: + value = payload.get(key) + if isinstance(value, dict): + value = value.get("text") + if isinstance(value, str) and value.strip(): + return value + if not payload: + return "" + logger.warning( + "runtime answered JSON with no conventional text key; showing raw body (keys=%s)", + sorted(payload.keys()), + ) + dumped = json.dumps(payload, ensure_ascii=False) + if len(dumped) > _FREE_FORM_DUMP_LIMIT: + dumped = dumped[:_FREE_FORM_DUMP_LIMIT] + "…" + return dumped def _normalized_runtime_events(payloads: Iterable[Any]) -> Iterator[dict[str, Any]]: diff --git a/backend/app/services/byoc_uploads.py b/backend/app/services/byoc_uploads.py new file mode 100644 index 00000000..ff804782 --- /dev/null +++ b/backend/app/services/byoc_uploads.py @@ -0,0 +1,360 @@ +"""BYOC artifact staging: zip validation, detection and S3 storage. + +The upload endpoint (`POST /api/agents/uploads`) streams the member's zip to a +temp file, validates the archive WITHOUT executing anything in it, stores it to +the workspace artifacts bucket under ``byoc/{workspace_id}/{upload_id}/source.zip`` +next to a ``manifest.json`` (detection summary + provenance), and returns the +summary. The deploy pipeline later downloads the object by ``upload_id`` and +re-validates on extraction — the S3 object is admin-writable in principle, so +package-time checks are defense in depth, not duplication. + +Limits follow the AgentCore direct-code artifact caps (250 MiB zip / 750 MiB +uncompressed); entry-shape rules mirror ``skill_ingest``'s safe extractor +(no absolute paths, no ``..`` traversal, no symlinks, bounded entry count). +""" + +from __future__ import annotations + +import json +import re +import stat +import uuid +import zipfile +from pathlib import Path, PurePosixPath +from typing import Any + +from fastapi import Request +from fastapi.responses import JSONResponse + +from app.core.errors import AppError, NotFoundError +from app.core.runtime_target import TARGET_PYTHON +from app.services import requirements_txt +from app.services.workspace import WorkspaceContext + +# AgentCore direct-code artifact limits (also enforced for container_source zips +# — CodeBuild contexts have no service cap this small, but one build contract is +# simpler to explain than two). +MAX_ZIP_BYTES = 250 * 1024 * 1024 +MAX_UNCOMPRESSED_BYTES = 750 * 1024 * 1024 +MAX_ENTRIES = 20_000 +# Content-Length includes multipart framing; 1 MiB of headroom mirrors the +# skill-lab guard. +UPLOAD_REQUEST_MAX_BYTES = MAX_ZIP_BYTES + 1024 * 1024 + +UPLOAD_PATH = "/api/agents/uploads" +UPLOAD_ID_RE = re.compile(r"^[A-Za-z0-9_-]+$") + +_CHUNK = 1024 * 1024 +# requirements.txt larger than this is not a requirements file +_REQUIREMENTS_MAX_BYTES = 256 * 1024 +# Only .py members this size or smaller are content-scanned for the SDK markers; +# bigger ones are almost certainly vendored artifacts, not the user's entrypoint. +_SDK_SCAN_MAX_BYTES = 1024 * 1024 +_SDK_SCAN_MAX_FILES = 400 +_SDK_MARKERS = (b"BedrockAgentCoreApp", b"@app.entrypoint") + + +def _error(code: str, message: str, status: int = 422) -> AppError: + return AppError(f"byoc.{code}", message, status_code=status) + + +def new_upload_id() -> str: + return uuid.uuid4().hex + + +def source_key(workspace_id: str, upload_id: str) -> str: + return f"byoc/{workspace_id}/{upload_id}/source.zip" + + +def manifest_key(workspace_id: str, upload_id: str) -> str: + return f"byoc/{workspace_id}/{upload_id}/manifest.json" + + +async def upload_body_limit_middleware(request: Request, call_next: Any) -> Any: + """Reject a known-oversize BYOC upload before Starlette parses the multipart + body — the same exact-route pattern as the skill-lab asset guard. Chunked + requests (no Content-Length) fall through to the streamed per-file cap in + :func:`stage_upload`.""" + if request.method == "POST" and request.url.path == UPLOAD_PATH: + raw_length = request.headers.get("content-length") + try: + length = int(raw_length) if raw_length is not None else None + except ValueError: + length = None + if length is not None and length > UPLOAD_REQUEST_MAX_BYTES: + return JSONResponse( + status_code=413, + content={ + "code": "byoc.upload_request_too_large", + "message": "BYOC upload exceeds the 250 MiB zip limit", + "detail": None, + }, + ) + return await call_next(request) + + +def _is_symlink(info: zipfile.ZipInfo) -> bool: + return stat.S_ISLNK(info.external_attr >> 16) + + +def _reject_unsafe_name(name: str) -> None: + norm = name.replace("\\", "/") + if norm.startswith("/"): + raise _error("zip_entry_unsafe", f"zip entry '{name}' uses an absolute path") + if len(norm) >= 2 and norm[1] == ":": # Windows drive letter (C:/...) + raise _error("zip_entry_unsafe", f"zip entry '{name}' uses an absolute path") + if ".." in PurePosixPath(norm).parts: + raise _error("zip_entry_unsafe", f"zip entry '{name}' escapes the archive root") + + +def _root_prefix(names: list[str]) -> str: + """'' when files live at the archive root, else the single top-level + directory ('myagent/') every entry sits under — the normalization that lets + `zip -r agent.zip myagent/` and zipping the directory contents both work.""" + tops = {name.split("/", 1)[0] for name in names} + if len(tops) != 1: + return "" + top = next(iter(tops)) + # a single file at the root ("main.py") is the root itself, not a dir + if all("/" not in name for name in names): + return "" + return f"{top}/" if all(name.startswith(f"{top}/") for name in names) else "" + + +def validate_and_detect(path: Path) -> dict[str, Any]: + """Validate the archive shape and report what's inside — never extracts. + + Returns ``{entries_count, uncompressed_bytes, root_prefix, detected:{...}}``. + Raises AppError (422) on any safety violation. + """ + try: + archive = zipfile.ZipFile(path) + except zipfile.BadZipFile as exc: + raise _error("zip_invalid", "uploaded file is not a valid zip archive") from exc + + with archive as zf: + infos = [info for info in zf.infolist() if not info.is_dir()] + if not infos: + raise _error("zip_empty", "the zip contains no files") + if len(infos) > MAX_ENTRIES: + raise _error( + "zip_too_many_entries", + f"the zip has more than {MAX_ENTRIES} entries", + ) + total = 0 + for info in infos: + if _is_symlink(info): + raise _error( + "zip_entry_unsafe", f"zip entry '{info.filename}' is a symlink — refused" + ) + _reject_unsafe_name(info.filename) + total += info.file_size + if total > MAX_UNCOMPRESSED_BYTES: + raise _error( + "zip_uncompressed_too_large", + "the zip expands beyond the 750 MiB uncompressed limit", + ) + + names = [info.filename for info in infos] + root = _root_prefix(names) + rel = [n[len(root):] for n in names] + + candidates = sorted( + (n for n in rel if n.endswith(".py") and "/" not in n), + # main.py / app.py first — the overwhelmingly common entrypoints + key=lambda n: (n not in ("main.py", "app.py"), n), + ) + detected = { + "entrypoint_candidates": candidates[:50], + "has_requirements": "requirements.txt" in rel, + "has_dockerfile": "Dockerfile" in rel, + "agentcore_sdk_detected": _scan_for_sdk(zf, root, rel), + } + return { + "entries_count": len(infos), + "uncompressed_bytes": total, + "root_prefix": root, + "detected": detected, + } + + +def _requirements_text(path: Path, root: str) -> str | None: + """The zip's root requirements.txt content, or None (absent / oversized).""" + with zipfile.ZipFile(path) as zf: + try: + info = zf.getinfo(f"{root}requirements.txt") + except KeyError: + return None + if info.file_size > _REQUIREMENTS_MAX_BYTES: + return None + return zf.read(info).decode("utf-8", errors="replace") + + +def check_requirements( + path: Path, root: str, python_version: str = "PYTHON_3_13" +) -> dict[str, Any]: + """Dry-resolve the zip's requirements.txt against the deploy target, so the + wizard surfaces an unresolvable file before a deploy is even attempted: + ``{status: ok|failed|skipped, package_count, error}``. Nothing from the zip + is executed — the resolver only reads index metadata.""" + text = _requirements_text(path, root) + if text is None: + return {"status": "skipped", "package_count": None, + "error": "no requirements.txt in the zip"} + return requirements_txt.preresolve( + text, + python_version=requirements_txt.pip_python_version(python_version or TARGET_PYTHON), + hints=requirements_txt.RESOLVE_FIX_HINTS, + ) + + +def _scan_for_sdk(zf: zipfile.ZipFile, root: str, rel_names: list[str]) -> bool: + """True when any small root-adjacent .py member mentions the AgentCore SDK + entrypoint contract. A *reading* scan only — nothing is imported or run.""" + scanned = 0 + for rel in rel_names: + if not rel.endswith(".py") or rel.count("/") > 1: + continue + info = zf.getinfo(root + rel) + if info.file_size > _SDK_SCAN_MAX_BYTES: + continue + data = zf.read(info) + if any(marker in data for marker in _SDK_MARKERS): + return True + scanned += 1 + if scanned >= _SDK_SCAN_MAX_FILES: + break + return False + + +def stage_upload( + workspace: WorkspaceContext, + *, + filename: str, + tmp_zip: Path, + sha256: str, + size_bytes: int, + uploaded_by: str, + uploaded_at: str, + python_version: str = "PYTHON_3_13", + s3_client: Any = None, +) -> dict[str, Any]: + """Validate the staged temp zip, store object + manifest to S3, return the + manifest. The caller has already streamed the request body to ``tmp_zip`` + (enforcing the 250 MiB cap) and computed its digest.""" + bucket = workspace.resources.get("artifacts_bucket") + if not bucket: + raise RuntimeError( + "artifacts_bucket missing from this workspace's resource map — run its bootstrap" + ) + report = validate_and_detect(tmp_zip) + if report["detected"]["has_requirements"]: + report["detected"]["requirements"] = check_requirements( + tmp_zip, report["root_prefix"], python_version + ) + else: + report["detected"]["requirements"] = { + "status": "skipped", "package_count": None, + "error": "no requirements.txt in the zip", + } + upload_id = new_upload_id() + manifest = { + "upload_id": upload_id, + "workspace_id": workspace.id, + "sha256": sha256, + "size_bytes": size_bytes, + "original_filename": filename, + "uploaded_by": uploaded_by, + "uploaded_at": uploaded_at, + **report, + } + s3 = s3_client or workspace.client("s3") + s3.upload_file(str(tmp_zip), bucket, source_key(workspace.id, upload_id)) + s3.put_object( + Bucket=bucket, + Key=manifest_key(workspace.id, upload_id), + Body=json.dumps(manifest, ensure_ascii=False).encode("utf-8"), + ContentType="application/json", + ) + return manifest + + +def get_manifest( + workspace: WorkspaceContext, upload_id: str, s3_client: Any = None +) -> dict[str, Any]: + """The stored manifest, or 404. The key embeds the caller's workspace id, so + another workspace's upload_id is indistinguishable from a missing one.""" + if not UPLOAD_ID_RE.fullmatch(upload_id or ""): + raise NotFoundError("byoc.upload_not_found", "upload not found") + bucket = workspace.resources.get("artifacts_bucket") + if not bucket: + raise RuntimeError( + "artifacts_bucket missing from this workspace's resource map — run its bootstrap" + ) + s3 = s3_client or workspace.client("s3") + try: + body = s3.get_object(Bucket=bucket, Key=manifest_key(workspace.id, upload_id)) + except Exception as exc: # NoSuchKey and friends → uniform 404 + if type(exc).__name__ in ("NoSuchKey", "ClientError", "ResourceNotFoundException"): + raise NotFoundError("byoc.upload_not_found", "upload not found") from exc + raise + return json.loads(body["Body"].read()) + + +def download_upload( + workspace: WorkspaceContext, + workspace_id: str, + upload_id: str, + dest: Path, + s3_client: Any = None, +) -> None: + bucket = workspace.resources.get("artifacts_bucket") + if not bucket: + raise RuntimeError( + "artifacts_bucket missing from this workspace's resource map — run its bootstrap" + ) + s3 = s3_client or workspace.client("s3") + dest.parent.mkdir(parents=True, exist_ok=True) + s3.download_file(bucket, source_key(workspace_id, upload_id), str(dest)) + + +def extract_zip(zip_path: Path, dest: Path) -> Path: + """Safely extract a validated BYOC zip; returns the effective source root + (``dest`` or the single top-level directory inside it). + + Runs the same shape checks as upload-time validation — the pipeline may be + resuming from an object that was re-written after validation.""" + report = validate_and_detect(zip_path) + dest_root = dest.resolve() + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + if info.is_dir(): + continue + target = (dest / info.filename).resolve() + if not (target == dest_root or dest_root in target.parents): + raise _error( + "zip_entry_unsafe", f"zip entry '{info.filename}' escapes the extract root" + ) + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info) as src, open(target, "wb") as out: + while chunk := src.read(_CHUNK): + out.write(chunk) + root = report["root_prefix"] + return dest / root.rstrip("/") if root else dest + + +def delete_upload_objects( + workspace: WorkspaceContext, workspace_id: str, upload_id: str, s3_client: Any = None +) -> None: + """Best-effort removal of the staged zip + manifest (agent delete path).""" + if not UPLOAD_ID_RE.fullmatch(upload_id or ""): + return + bucket = workspace.resources.get("artifacts_bucket") + if not bucket: + return + s3 = s3_client or workspace.client("s3") + for key in (source_key(workspace_id, upload_id), manifest_key(workspace_id, upload_id)): + try: + s3.delete_object(Bucket=bucket, Key=key) + except Exception: # noqa: BLE001 — cleanup must never block a delete + pass diff --git a/backend/app/services/invoke.py b/backend/app/services/invoke.py index e7c4e891..c8cd12b3 100644 --- a/backend/app/services/invoke.py +++ b/backend/app/services/invoke.py @@ -33,12 +33,17 @@ # Runtime methods whose generated entrypoint emits the delta/tool/complete # envelope over SSE (see `rt._runtime_payload_events`). Shared with chat so the # advertised `mode` and the actual invoke path can't drift apart. -NATIVE_STREAM_METHODS = frozenset({"container", "zip_runtime"}) +# byoc is included on the strength of the parser's documented fallback: user +# code that answers a JSON {"result": ...} body (BedrockAgentCoreApp's default) +# is folded into one delta, and code that streams SSE streams for real. +NATIVE_STREAM_METHODS = frozenset({"container", "zip_runtime", "byoc"}) # Methods whose agent ARN is an AgentCore *Runtime* — the only resource with a # session-stop operation. A managed Harness (deployed or imported) has none: # neither `bedrock-agentcore` nor `bedrock-agentcore-control` models an # operation that names both Harness and Session. -RUNTIME_SESSION_METHODS = frozenset({"zip_runtime", "studio", "container", DISCOVERED_METHOD}) +RUNTIME_SESSION_METHODS = frozenset( + {"zip_runtime", "studio", "container", "byoc", DISCOVERED_METHOD} +) def _runtime_user_id( @@ -237,7 +242,7 @@ def invoke_agent_text( actor_id=actor_id, **harness_kwargs, ) - if agent.method in ("zip_runtime", "studio", "container", DISCOVERED_METHOD): + if agent.method in ("zip_runtime", "studio", "container", "byoc", DISCOVERED_METHOD): # A2A-protocol runtimes speak JSON-RPC; the A2A server owns # conversation state (no actor_id/memory envelope) and can't be canaried if (agent.spec or {}).get("protocol") == "a2a": diff --git a/backend/app/services/observability.py b/backend/app/services/observability.py index 3a43e0fe..4e560e1e 100644 --- a/backend/app/services/observability.py +++ b/backend/app/services/observability.py @@ -1405,7 +1405,9 @@ def _eval_run_for_session( # Runtime methods write no memory events during eval runs, but their ADOT # sidecar streams per-span gen_ai content records into the runtime log group # (stream otel-rt-logs) — the same content StartBatchEvaluation reads. -RUNTIME_LOG_METHODS = {"zip_runtime", "studio", "container"} +# byoc included: its runtime writes the same log group; whether the member's own +# code emits gen_ai content records is up to their instrumentation. +RUNTIME_LOG_METHODS = {"zip_runtime", "studio", "container", "byoc"} def _part_list_text(raw: Any) -> str | None: diff --git a/backend/app/services/requirements_txt.py b/backend/app/services/requirements_txt.py new file mode 100644 index 00000000..f4c9b4eb --- /dev/null +++ b/backend/app/services/requirements_txt.py @@ -0,0 +1,287 @@ +"""User `requirements.txt` handling for the zip deploy paths (byoc `code_zip` +and platform zip runtimes): parsing, the supply-chain boundary, resolver-failure +summaries, and the upload-time dry resolve. + +Parsing follows the pip requirements-file format — backslash continuations are +joined, inline comments stripped, blank lines tolerated, environment markers +passed through untouched. `--hash=` options are dropped: the platform re-locks +the file against the deploy target and generates its own hashes, so hashes +computed for another platform's wheels would only make every build fail. + +Everything that reaches outside the platform's package index is refused — +`-r`/`-c` includes, editable/local paths, direct URLs and VCS references, +`--index-url`/`--extra-index-url`/`--find-links`. The build installs with the +platform's index and nothing else; a requirements file must not be able to +widen that boundary. +""" + +import re +import subprocess +import tempfile +from collections.abc import Callable, Sequence +from pathlib import Path +from typing import Any + +from app.core.runtime_target import TARGET_PYTHON, uv_platform + +MAX_REQUIREMENT_ENTRIES = 500 + +PRERESOLVE_TIMEOUT_S = 90 + +# Where a resolve can go from here when the index has no fitting wheel — the +# same three options at upload time and at deploy time. +RESOLVE_FIX_HINTS = ( + "switch to the container path (Dockerfile build), which installs for the " + "image itself", + "or vendor the dependencies inside the zip and disable requirement " + "installation", +) + +# pip's rule: a comment starts at `#` preceded by whitespace or line start. +_COMMENT_RE = re.compile(r"(^|\s)#.*$") +_HASH_OPT_RE = re.compile(r"\s--hash(=|\s+)\S+") + +# option → why it is refused. Matched on the first whitespace-delimited token. +_REFUSED_OPTIONS: dict[str, str] = { + "-r": "nested requirements files are not supported — inline the entries", + "--requirement": "nested requirements files are not supported — inline the entries", + "-c": "constraints files are not supported — inline the entries", + "--constraint": "constraints files are not supported — inline the entries", + "-e": "editable installs cannot run on the managed runtime", + "--editable": "editable installs cannot run on the managed runtime", + "-i": "the platform installs from its own package index only", + "--index-url": "the platform installs from its own package index only", + "--extra-index-url": "the platform installs from its own package index only", + "--find-links": "the platform installs from its own package index only", + "-f": "the platform installs from its own package index only", + "--no-index": "the platform installs from its own package index only", +} + +_URL_PREFIXES = ("http://", "https://", "ftp://", "git+", "hg+", "svn+", "bzr+", "file:") + + +def pip_python_version(python_version: str) -> str: + """``PYTHON_3_13`` (the AgentCore runtime enum) → ``3.13`` (pip/uv shape). + Values already in pip shape pass through.""" + return python_version.removeprefix("PYTHON_").replace("_", ".") + + +class RequirementsFileError(ValueError): + """One offending line, with the line content and an actionable reason.""" + + def __init__(self, line: str, reason: str) -> None: + self.line = line + self.reason = reason + super().__init__(f"requirements.txt entry {line!r}: {reason}") + + +def _logical_lines(text: str) -> list[str]: + """Join backslash line continuations the way pip does (drop `\\` + newline).""" + lines: list[str] = [] + pending = "" + for raw in text.splitlines(): + joined = pending + raw + if joined.endswith("\\"): + pending = joined[:-1] + continue + pending = "" + lines.append(joined) + if pending: + lines.append(pending) + return lines + + +def _reject(line: str) -> None: + token = line.split()[0].split("=", 1)[0] + reason = _REFUSED_OPTIONS.get(token) + if line.startswith("-"): + raise RequirementsFileError( + line, reason or "pip options are not supported in an uploaded requirements.txt" + ) + candidate = line.split(";", 1)[0].strip() + target = candidate.split("@", 1)[1].strip() if " @ " in candidate else candidate + if target.startswith(_URL_PREFIXES) or "://" in target: + raise RequirementsFileError( + line, + "direct URL / VCS requirements are refused — the platform installs " + "from its own package index only", + ) + if candidate.startswith((".", "/", "~", "\\")): + raise RequirementsFileError( + line, "local paths cannot be installed — name an index package instead" + ) + + +def parse_requirements_txt(text: str, max_entries: int = MAX_REQUIREMENT_ENTRIES) -> list[str]: + """The file's requirement entries, normalized and boundary-checked. + + Returns PEP 508 requirement strings (markers intact, `--hash` options + dropped); the resolver downstream is what validates each entry's grammar. + Raises :class:`RequirementsFileError` on anything outside the boundary. + """ + entries: list[str] = [] + for logical in _logical_lines(text): + line = _COMMENT_RE.sub("", logical).strip() + if not line: + continue + line = _HASH_OPT_RE.sub("", line).strip() + if not line: + continue + _reject(line) + if re.search(r"\s--?[A-Za-z]", line): # ` --global-option=…` and friends + raise RequirementsFileError( + line, "per-requirement pip options are not supported" + ) + entries.append(line) + if len(entries) > max_entries: + raise RequirementsFileError( + f"(entry {max_entries + 1})", + f"requirements.txt lists more than {max_entries} entries", + ) + return entries + + +# --------------------------------------------------------------------------- +# Resolver-failure summaries +# +# uv's resolution errors are written for a terminal session: a derivation tree +# over the whole conflict. A deploy log or a wizard banner needs the offending +# package and the reason, not the tree — and never the caller's requirement +# list echoed back. +# --------------------------------------------------------------------------- + +# (pattern, reason template). Ordered: first match wins. +_FAILURE_PATTERNS: list[tuple[re.Pattern[str], str]] = [ + ( + re.compile(r"(?:Because\s+)?([A-Za-z0-9][A-Za-z0-9._-]*)(?:==\S+)? has no usable wheels"), + "{pkg} publishes no wheel installable on {platform} / Python {python} " + "(source builds are disabled on the platform)", + ), + ( + re.compile(r"([A-Za-z0-9][A-Za-z0-9._-]*) was not found in the package registry"), + "{pkg} does not exist on the package index", + ), + ( + re.compile(r"there is no version of ([A-Za-z0-9][A-Za-z0-9._-]*)==?(\S+?)(?:\s|$)"), + "{pkg} has no release matching the requested version", + ), + ( + # pip's shape, for the install step + re.compile(r"No matching distribution found for ([A-Za-z0-9][A-Za-z0-9._-]*)"), + "{pkg} has no distribution installable on {platform} / Python {python}", + ), +] + +_UNSATISFIABLE_RE = re.compile(r"requirements are unsatisfiable") + + +def summarize_resolver_failure( + raw: str, + *, + python_version: str = TARGET_PYTHON, + hints: Sequence[str] = (), +) -> str: + """A concise, package-naming summary of a failed uv/pip resolve. + + ``raw`` is the resolver's stderr/stdout. The output names the offending + package(s) and the reason, then the fix hints — never the input list. + """ + platform = uv_platform() + reasons: list[str] = [] + for pattern, template in _FAILURE_PATTERNS: + for match in pattern.finditer(raw): + reason = template.format(pkg=match.group(1), platform=platform, + python=python_version) + if reason not in reasons: + reasons.append(reason) + if reasons: + break + if not reasons and _UNSATISFIABLE_RE.search(raw): + reasons.append( + "the requirements conflict — no set of versions satisfies all of " + "them together" + ) + if not reasons: + # unknown shape: keep a bounded tail of the raw output rather than nothing + reasons.append((raw or "").strip()[-400:] or "resolver produced no output") + summary = "; ".join(reasons) + all_hints = list(hints) + if any("wheel" in reason or "distribution" in reason for reason in reasons): + # only wheel-availability failures are fixable by choosing another release + all_hints.insert(0, f"pin a version that ships {platform} wheels") + if not all_hints: + return f"{summary}." + return f"{summary}. Fixes: {'; '.join(all_hints)}." + + +# --------------------------------------------------------------------------- +# Upload-time dry resolve +# --------------------------------------------------------------------------- + + +def preresolve( + requirements_text: str, + *, + python_version: str = TARGET_PYTHON, + hints: Sequence[str] = (), + runner: Callable[..., Any] = subprocess.run, + timeout_s: float = PRERESOLVE_TIMEOUT_S, +) -> dict[str, Any]: + """Dry-resolve a requirements.txt against the deploy target, without + installing anything: ``{status: ok|failed|skipped, package_count, error}``. + + ``failed`` means the deploy's package stage would fail the same way; + ``skipped`` means the check could not run (timeout, no ``uv`` on PATH) and + says nothing about resolvability. + """ + try: + entries = parse_requirements_txt(requirements_text) + except RequirementsFileError as exc: + return {"status": "failed", "package_count": None, "error": str(exc)} + if not entries: + return {"status": "ok", "package_count": 0, "error": None} + + with tempfile.TemporaryDirectory(prefix="byoc-preresolve-") as tmp: + src = Path(tmp) / "requirements.in" + out = Path(tmp) / "resolved.txt" + src.write_text("\n".join(entries) + "\n", encoding="utf-8") + try: + proc = runner( + [ + "uv", "pip", "compile", str(src), "--quiet", + "--python-version", python_version, + "--python-platform", uv_platform(), + "--only-binary=:all:", + "--no-header", "--no-annotate", + "-o", str(out), + ], + capture_output=True, + text=True, + timeout=timeout_s, + ) + except subprocess.TimeoutExpired: + return { + "status": "skipped", + "package_count": None, + "error": f"resolver did not finish within {int(timeout_s)}s — " + "the deploy will run the full resolve", + } + except FileNotFoundError: + return { + "status": "skipped", + "package_count": None, + "error": "uv is not available on the control plane — " + "the deploy will run the full resolve", + } + if proc.returncode != 0: + error = summarize_resolver_failure( + (proc.stderr or proc.stdout or ""), + python_version=python_version, + hints=hints, + ) + return {"status": "failed", "package_count": None, "error": error} + count = sum( + 1 for line in out.read_text(encoding="utf-8").splitlines() + if "==" in line and not line.lstrip().startswith("#") + ) + return {"status": "ok", "package_count": count, "error": None} diff --git a/backend/scripts/ui_system_preset_settings_mock.py b/backend/scripts/ui_system_preset_settings_mock.py index 4f59cf01..32153502 100644 --- a/backend/scripts/ui_system_preset_settings_mock.py +++ b/backend/scripts/ui_system_preset_settings_mock.py @@ -73,6 +73,20 @@ } + +# Since 2026-09-18 the system-preset cards sit on the create page (/agents/new, +# under the four method cards) while the agent table is the list (/agents). +PRESETS_URL = "/agents/new" +LIST_URL = "/agents" + + +def row_action(page, name: str, action: str): + """Row actions Edit/Convert/Delete live in the row's "···" menu since + 2026-09-18 (list-first /agents page); open it, then return the item.""" + page.get_by_test_id(f"menu-{name}").click() + return page.get_by_test_id(f"{action}-{name}") + + def auth(role: str) -> dict: return { "auth_required": True, "authenticated": True, "registration_enabled": False, @@ -404,7 +418,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: ctx.add_init_script(f"window.localStorage.setItem('launchpad_workspace', '{WS_A['id']}')") page = ctx.new_page() install_routes(page, fx, unhandled) - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active") row = page.get_by_test_id(f"system-preset-{KEY}") assert "max output/call: 65536 tok" in row.get_by_test_id("preset-inference").inner_text() @@ -489,15 +503,16 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: assert fx.agent_reads and all(w == WS_A["id"] for w in fx.agent_reads), fx.agent_reads assert fx.job_reads and all(w == WS_A["id"] for w in fx.job_reads), fx.job_reads shot(page, evidence, "06-admin-launch-view") - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active", timeout_ms=20000) summary = row.get_by_test_id("preset-inference").inner_text() assert "reasoning effort: none" in summary and "40 iterations" in summary, summary assert "global.anthropic.claude-opus-5" in row.inner_text() shot(page, evidence, "07-admin-after-save") - # --- the agent table's EDIT on the system row opens the SAME page - page.get_by_test_id(f"edit-{KEY}").click() + # --- the agent table's EDIT on the system row opens the SAME editor + page.goto(f"{base}{LIST_URL}", wait_until="networkidle") + row_action(page, KEY, "edit").click() editor(page, "edit") assert page.get_by_test_id("model-select").input_value() == "global.anthropic.claude-opus-5" assert page.get_by_test_id("agent-max-iterations").input_value() == "40" @@ -552,7 +567,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: # --- a FAILED preset opens the same editor and is retried the same way fx.fail_preset(WS_A["id"], "deploy stage: UpdateHarness ValidationException") - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "failed") assert "ValidationException" in page.get_by_test_id("preset-error").inner_text() assert page.get_by_test_id(f"repair-{KEY}").count() == 0 @@ -562,7 +577,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: submit_and_confirm(page, "RE-PUBLISH") page.get_by_test_id("job-log").wait_for() assert fx.posts[-1]["body"] == {"force": True} - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active", timeout_ms=20000) # --- a slow save locks the page (single-flight) and its late 202 lands @@ -581,7 +596,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: shot(page, evidence, "08e-admin-saving-locked") fx.release_held() page.get_by_test_id("job-log").wait_for(timeout=10000) - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active", timeout_ms=20000) assert "max output/call: 1234 tok" in row.get_by_test_id("preset-inference").inner_text() @@ -589,7 +604,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: # still displays A: A's reads, save and poll stay pinned to A tab_b = ctx.new_page() install_routes(tab_b, fx, unhandled) - tab_b.goto(f"{base}/create", wait_until="networkidle") + tab_b.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") tab_b.get_by_test_id("workspace-switcher-btn").click() tab_b.get_by_test_id(f"workspace-option-{WS_B['id']}").click() tab_b.locator(f'[data-testid="system-preset-{KEY}"][data-status="not_installed"]').wait_for() @@ -618,7 +633,7 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: # --- same-tab switch from an open draft: the page remounts on B, nothing posted page.evaluate(f"localStorage.setItem('launchpad_workspace', '{WS_A['id']}')") - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active", timeout_ms=20000) page.get_by_test_id(f"settings-{KEY}").click() editor(page, "edit") @@ -636,9 +651,8 @@ def admin_scenario(browser, base: str, evidence: Path) -> dict: # --- an ORDINARY agent's EDIT is unchanged: ordinary redeploy, stored cap carried page.evaluate(f"localStorage.setItem('launchpad_workspace', '{WS_A['id']}')") - page.goto(f"{base}/create", wait_until="networkidle") - wait_status(page, "active", timeout_ms=20000) - page.get_by_test_id("edit-hr-assistant").click() + page.goto(f"{base}{LIST_URL}", wait_until="networkidle") + row_action(page, "hr-assistant", "edit").click() page.locator('[data-testid="configure-step"]:not([data-system-edit])').wait_for() assert page.get_by_test_id("agent-max-tokens").input_value() == "4096" assert page.get_by_test_id("agent-max-iterations").input_value() == "12" @@ -666,13 +680,18 @@ def member_scenario(browser, base: str, evidence: Path) -> dict: ctx.add_init_script(f"window.localStorage.setItem('launchpad_workspace', '{WS_A['id']}')") page = ctx.new_page() install_routes(page, fx, unhandled) - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active") assert_card_simplified(page) button = page.get_by_test_id(f"settings-{KEY}") assert button.inner_text().strip() == "VIEW SETTINGS", button.inner_text() - assert page.get_by_test_id(f"edit-{KEY}").is_disabled() # the table's EDIT stays off - assert not page.get_by_test_id("edit-hr-assistant").is_disabled() # ordinary rows unchanged + list_page = ctx.new_page() + install_routes(list_page, fx, unhandled) + list_page.goto(f"{base}{LIST_URL}", wait_until="networkidle") + assert row_action(list_page, KEY, "edit").is_disabled() # the table's EDIT stays off + # ordinary rows unchanged + assert not row_action(list_page, "hr-assistant", "edit").is_disabled() + list_page.close() button.click() editor(page, "review") page.get_by_test_id("preset-settings-readonly").wait_for() @@ -708,7 +727,7 @@ def race_scenario(browser, base: str, evidence: Path) -> dict: # first; the late generic response (another tab moved the shared selection to B, # so it carries B's catalog) must NOT replace the preset editor's catalog fx.kb_hold = "first" - page.goto(f"{base}/create", wait_until="domcontentloaded") + page.goto(f"{base}{PRESETS_URL}", wait_until="domcontentloaded") wait_status(page, "active") assert len(fx.held_kb) == 1, fx.kb_reads # exactly the mount-time generic fetch page.get_by_test_id(f"settings-{KEY}").click() @@ -753,10 +772,10 @@ def race_scenario(browser, base: str, evidence: Path) -> dict: # --- (2) table EDIT on the system row: its preset read is held; the user opens an # ORDINARY edit and types; the late system read must not reset that draft fx.hold_next_status = True - page.get_by_test_id(f"edit-{KEY}").click() + row_action(page, KEY, "edit").click() page.wait_for_timeout(200) assert fx.held_status is not None - page.get_by_test_id("edit-hr-assistant").click() + row_action(page, "hr-assistant", "edit").click() page.locator('[data-testid="configure-step"]:not([data-system-edit])').wait_for() page.get_by_test_id("agent-prompt").fill("UNSAVED ordinary draft") fx.release_status() @@ -769,7 +788,7 @@ def race_scenario(browser, base: str, evidence: Path) -> dict: back_to_list(page) # (2b) the same with the DETAILS view opened meanwhile: the launch view stays fx.hold_next_status = True - page.get_by_test_id(f"edit-{KEY}").click() + row_action(page, KEY, "edit").click() page.wait_for_timeout(200) assert fx.held_status is not None page.get_by_test_id(f"details-{KEY}").click() @@ -779,10 +798,10 @@ def race_scenario(browser, base: str, evidence: Path) -> dict: assert page.get_by_test_id("configure-step").count() == 0 assert page.get_by_test_id("job-log").count() == 1 # (2c) with nothing newer, the held read still opens the editor (no lost click) - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active") fx.hold_next_status = True - page.get_by_test_id(f"edit-{KEY}").click() + row_action(page, KEY, "edit").click() page.wait_for_timeout(200) fx.release_status() editor(page, "edit") @@ -802,7 +821,7 @@ def zh_screenshots(browser, base: str, evidence: Path) -> None: ) page = ctx.new_page() install_routes(page, fx, unhandled) - page.goto(f"{base}/create", wait_until="networkidle") + page.goto(f"{base}{PRESETS_URL}", wait_until="networkidle") wait_status(page, "active") assert_card_simplified(page) shot(page, evidence, "12-zh-admin-card") diff --git a/backend/tests/test_a2a_demo.py b/backend/tests/test_a2a_demo.py index d5cfd4ec..5271c490 100644 --- a/backend/tests/test_a2a_demo.py +++ b/backend/tests/test_a2a_demo.py @@ -158,7 +158,10 @@ def test_frontdesk_a2a_message_carries_context_and_request_state_is_isolated(): assert isinstance(h["_SESSION_ID"], ContextVar) -def test_frontdesk_memory_manager_scopes_actor_and_session(): +def test_frontdesk_memory_manager_scopes_actor_and_session(monkeypatch): + # REGION is read from AWS_REGION at module load — pin the default so the + # assertion doesn't track whatever region the host happens to export + monkeypatch.delenv("AWS_REGION", raising=False) h = _load_helpers() captured = {} diff --git a/backend/tests/test_agent_iam_policy.py b/backend/tests/test_agent_iam_policy.py index 0797307f..18d9355f 100644 --- a/backend/tests/test_agent_iam_policy.py +++ b/backend/tests/test_agent_iam_policy.py @@ -103,6 +103,85 @@ def test_an_empty_id_does_not_produce_a_broken_arn(self): assert agent_iam.model_resources("", CTX) == ["*"] +class TestAllowedModelResources: + """byoc `allowed_models`: the BedrockModels statement covers the UNION of the + per-model resources — each entry still scoped to its exact id, no wildcard.""" + + def _byoc(self, models, **over): + return _spec( + method="byoc", system_prompt="", + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": models}, + **over, + ) + + def test_union_over_every_entry(self): + spec = self._byoc(["global.anthropic.claude-sonnet-5", + "amazon.nova-2-lite-v1:0"]) + resources = agent_iam.allowed_model_resources(spec, CTX) + assert resources == [ + "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-5", + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/" + "global.anthropic.claude-sonnet-5", + "arn:aws:bedrock:*::foundation-model/amazon.nova-2-lite-v1:0", + ] + + def test_overlapping_entries_dedupe(self): + # a profile and the bare model it fronts share a foundation-model ARN + spec = self._byoc(["global.anthropic.claude-sonnet-5", + "anthropic.claude-sonnet-5"]) + resources = agent_iam.allowed_model_resources(spec, CTX) + assert len(resources) == len(set(resources)) == 2 + + def test_policy_statement_carries_the_union(self): + spec = self._byoc(["global.anthropic.claude-sonnet-5", + "amazon.nova-2-lite-v1:0"]) + statement = _statement(spec, "BedrockModels") + assert "arn:aws:bedrock:*::foundation-model/amazon.nova-2-lite-v1:0" in ( + statement["Resource"]) + assert "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-5" in ( + statement["Resource"]) + assert "arn:aws:bedrock:*::foundation-model/*" not in statement["Resource"] + + def test_single_model_specs_are_unchanged(self): + assert agent_iam.allowed_model_resources(_spec(), CTX) == ( + agent_iam.model_resources(_spec().model_id, CTX)) + + def test_republish_updates_the_role_policy_with_the_new_union(self): + """`ensure_role` put_role_policy's the capability policy on every provision + run — a changed allowed_models list lands on re-publish, not only create.""" + import json + from types import SimpleNamespace + + put_docs = [] + + class StubIam: + def create_role(self, **kw): + return {"Role": {"Arn": "arn:aws:iam::123456789012:role/x"}} + + def put_role_policy(self, RoleName, PolicyName, PolicyDocument): + put_docs.append(json.loads(PolicyDocument)) + + def delete_role_policy(self, **kw): + pass + + agent = SimpleNamespace(id="abcdef1234567890", name="probe", system_key=None) + for models in (["us.model.one"], ["us.model.one", "us.model.two"]): + agent_iam.ensure_role(StubIam(), agent, self._byoc(models), CTX) + first, second = ( + next(s for s in doc["Statement"] if s["Sid"] == "BedrockModels") + for doc in put_docs + ) + assert first["Resource"] == [ + "arn:aws:bedrock:*::foundation-model/model.one", + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.model.one", + ] + assert second["Resource"] == first["Resource"] + [ + "arn:aws:bedrock:*::foundation-model/model.two", + "arn:aws:bedrock:us-west-2:123456789012:inference-profile/us.model.two", + ] + + # ─── what a plain agent gets, and what it does not ─────────────────────────── class TestBaselineAgent: diff --git a/backend/tests/test_byoc.py b/backend/tests/test_byoc.py new file mode 100644 index 00000000..ecbba50e --- /dev/null +++ b/backend/tests/test_byoc.py @@ -0,0 +1,1084 @@ +"""BYOC (bring your own code): zip validation, spec validators, upload endpoint, +deployer stages and the delete path — all hermetic (AWS stubbed, no pip runs).""" + +import io +import json +import zipfile +from pathlib import Path +from types import SimpleNamespace + +import pytest +from pydantic import ValidationError + +from app.core.db import DEFAULT_WORKSPACE_ID, SessionLocal +from app.core.errors import AppError +from app.deployer import byoc as byoc_dep +from app.deployer.pipeline import StageContext +from app.models.ledger import Agent, Deployment +from app.routers import agents as agents_router +from app.schemas.agent import DEFAULT_MODEL_ID, AgentSpec +from app.services import byoc_uploads +from app.services.agentcore import runtime as rt +from tests.conftest import ws_ctx + +ECR_IMAGE = "111122223333.dkr.ecr.us-west-2.amazonaws.com/my-agents:v1" + + +# ── zip building helpers ───────────────────────────────────────────────────── + +def make_zip(path: Path, files: dict[str, bytes], symlink: str | None = None) -> Path: + with zipfile.ZipFile(path, "w") as zf: + for name, data in files.items(): + zf.writestr(name, data) + if symlink: + info = zipfile.ZipInfo(symlink) + info.external_attr = 0o120777 << 16 # symlink mode bits + zf.writestr(info, "target") + return path + + +def zip_bytes(files: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w") as zf: + for name, data in files.items(): + zf.writestr(name, data) + return buf.getvalue() + + +SDK_MAIN = ( + b"from bedrock_agentcore.runtime import BedrockAgentCoreApp\n" + b"app = BedrockAgentCoreApp()\n" + b"@app.entrypoint\n" + b"def invoke(payload):\n" + b" return {'result': payload['prompt']}\n" +) + + +# ── zip validation + detection ─────────────────────────────────────────────── + +def test_validate_and_detect_reports_shape(tmp_path): + path = make_zip(tmp_path / "a.zip", { + "main.py": SDK_MAIN, + "helper.py": b"x = 1\n", + "requirements.txt": b"requests==2.32.3\n", + "pkg/deep.py": b"", + }) + report = byoc_uploads.validate_and_detect(path) + assert report["entries_count"] == 4 + assert report["root_prefix"] == "" + detected = report["detected"] + assert detected["entrypoint_candidates"][0] == "main.py" + assert detected["has_requirements"] is True + assert detected["has_dockerfile"] is False + assert detected["agentcore_sdk_detected"] is True + + +def test_validate_and_detect_normalizes_single_top_dir(tmp_path): + path = make_zip(tmp_path / "a.zip", { + "myagent/main.py": b"print('hi')\n", + "myagent/Dockerfile": b"FROM python:3.13-slim\n", + }) + report = byoc_uploads.validate_and_detect(path) + assert report["root_prefix"] == "myagent/" + assert report["detected"]["entrypoint_candidates"] == ["main.py"] + assert report["detected"]["has_dockerfile"] is True + assert report["detected"]["agentcore_sdk_detected"] is False + + +def test_validate_rejects_zip_slip(tmp_path): + path = make_zip(tmp_path / "a.zip", {"../evil.py": b""}) + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_entry_unsafe" + + +def test_validate_rejects_absolute_path(tmp_path): + path = make_zip(tmp_path / "a.zip", {"/etc/passwd": b""}) + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_entry_unsafe" + + +def test_validate_rejects_symlink(tmp_path): + path = make_zip(tmp_path / "a.zip", {"main.py": b""}, symlink="link.py") + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_entry_unsafe" + + +def test_validate_rejects_empty_zip(tmp_path): + path = make_zip(tmp_path / "a.zip", {}) + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_empty" + + +def test_validate_rejects_not_a_zip(tmp_path): + path = tmp_path / "a.zip" + path.write_bytes(b"definitely not a zip") + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_invalid" + + +def test_validate_rejects_uncompressed_bomb(tmp_path, monkeypatch): + monkeypatch.setattr(byoc_uploads, "MAX_UNCOMPRESSED_BYTES", 1024) + path = make_zip(tmp_path / "a.zip", {"big.bin": b"0" * 4096}) + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_uncompressed_too_large" + + +def test_validate_rejects_too_many_entries(tmp_path, monkeypatch): + monkeypatch.setattr(byoc_uploads, "MAX_ENTRIES", 3) + path = make_zip(tmp_path / "a.zip", {f"f{i}.py": b"" for i in range(4)}) + with pytest.raises(AppError) as err: + byoc_uploads.validate_and_detect(path) + assert err.value.code == "byoc.zip_too_many_entries" + + +def test_extract_zip_unwraps_top_dir(tmp_path): + path = make_zip(tmp_path / "a.zip", { + "myagent/main.py": b"print('hi')\n", + "myagent/sub/mod.py": b"", + }) + root = byoc_uploads.extract_zip(path, tmp_path / "out") + assert root == tmp_path / "out" / "myagent" + assert (root / "main.py").is_file() + assert (root / "sub" / "mod.py").is_file() + + +# ── spec validators ────────────────────────────────────────────────────────── + +def _byoc_spec(**over) -> dict: + return { + "name": "byoc-agent", + "method": "byoc", + "byoc": {"artifact_kind": "code_zip", "upload_id": "u1"}, + **over, + } + + +def test_byoc_spec_allows_empty_system_prompt(): + spec = AgentSpec(**_byoc_spec()) + assert spec.system_prompt == "" + assert spec.byoc.entrypoint == "main.py" + assert spec.byoc.python_version == "PYTHON_3_13" + + +def test_non_byoc_still_requires_system_prompt(): + with pytest.raises(ValidationError, match="system_prompt"): + AgentSpec(name="a-agent", method="harness") + + +def test_non_byoc_refuses_byoc_block(): + with pytest.raises(ValidationError, match="byoc settings"): + AgentSpec( + name="a-agent", method="harness", system_prompt="s", + byoc={"artifact_kind": "code_zip", "upload_id": "u1"}, + ) + + +def test_byoc_requires_config_block(): + with pytest.raises(ValidationError, match="requires the byoc settings"): + AgentSpec(name="byoc-agent", method="byoc") + + +def test_byoc_zip_kinds_require_upload_id(): + for kind in ("code_zip", "container_source"): + with pytest.raises(ValidationError, match="upload_id"): + AgentSpec(**_byoc_spec(byoc={"artifact_kind": kind})) + + +def test_byoc_container_image_requires_private_ecr(): + with pytest.raises(ValidationError, match="image_uri"): + AgentSpec(**_byoc_spec(byoc={"artifact_kind": "container_image"})) + for bad in ( + "public.ecr.aws/foo/bar:1", + "docker.io/library/python:3.13", + "999988887777.dkr.ecr.us-west-2.amazonaws.com/repo", # no tag/digest + ): + with pytest.raises(ValidationError): + AgentSpec(**_byoc_spec(byoc={"artifact_kind": "container_image", + "image_uri": bad})) + spec = AgentSpec(**_byoc_spec(byoc={"artifact_kind": "container_image", + "image_uri": ECR_IMAGE})) + assert spec.byoc.image_uri == ECR_IMAGE + + +def test_byoc_refuses_a2a_and_attachments(): + with pytest.raises(ValidationError, match="HTTP"): + AgentSpec(**_byoc_spec(protocol="a2a")) + for field, value in ( + ("tools", [{"type": "builtin", "name": "browser"}]), + ("toolkits", ["hr_assistant"]), + ("skills", ["s3://b/skills/x/"]), + ("knowledge_bases", [{"kb_id": "KB123"}]), + ): + with pytest.raises(ValidationError, match="not supported by the byoc"): + AgentSpec(**_byoc_spec(**{field: value})) + with pytest.raises(ValidationError, match="uploaded artifact"): + AgentSpec(**_byoc_spec(code="print('x')")) + with pytest.raises(ValidationError, match="requirements.txt"): + AgentSpec(**_byoc_spec(requirements=["requests==2.32.3"])) + + +def test_byoc_allowed_models_defaults_to_model_id(): + # backward compat: every spec written before the field existed reads back + # as a single-model list headed by spec.model_id + spec = AgentSpec(**_byoc_spec(model_id="global.anthropic.claude-haiku-4-5")) + assert spec.byoc.allowed_models is None + assert spec.allowed_model_ids == ["global.anthropic.claude-haiku-4-5"] + + +def test_byoc_allowed_models_sets_primary_when_model_id_omitted(): + spec = AgentSpec(**_byoc_spec(byoc={ + "artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one", "us.model.two"], + })) + assert spec.model_id == "us.model.one" + assert spec.allowed_model_ids == ["us.model.one", "us.model.two"] + + +def test_byoc_model_id_moves_to_the_front_of_allowed_models(): + spec = AgentSpec(**_byoc_spec( + model_id="us.model.two", + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one", "us.model.two"]}, + )) + assert spec.allowed_model_ids == ["us.model.two", "us.model.one"] + + +def test_byoc_model_id_must_be_in_allowed_models(): + with pytest.raises(ValidationError, match="must be one of byoc.allowed_models"): + AgentSpec(**_byoc_spec( + model_id="us.model.other", + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one"]}, + )) + + +def test_byoc_allowed_models_shape(): + base = {"artifact_kind": "code_zip", "upload_id": "u1"} + with pytest.raises(ValidationError, match="at least 1"): + AgentSpec(**_byoc_spec(byoc={**base, "allowed_models": []})) + with pytest.raises(ValidationError, match="unique"): + AgentSpec(**_byoc_spec(byoc={**base, + "allowed_models": ["us.model.one", "us.model.one"]})) + with pytest.raises(ValidationError, match="empty"): + AgentSpec(**_byoc_spec(byoc={**base, "allowed_models": ["us.model.one", " "]})) + with pytest.raises(ValidationError, match="at most 20"): + AgentSpec(**_byoc_spec(byoc={**base, + "allowed_models": [f"us.model.m{i}" for i in range(21)]})) + + +def test_non_byoc_refuses_allowed_models(): + # allowed_models lives inside the byoc block, which every other method refuses + with pytest.raises(ValidationError, match="byoc settings"): + AgentSpec( + name="a-agent", method="zip_runtime", system_prompt="s", + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one"]}, + ) + + +def test_non_byoc_allowed_model_ids_is_the_single_model(): + spec = AgentSpec(name="a-agent", method="harness", system_prompt="s", + model_id="us.model.one") + assert spec.allowed_model_ids == ["us.model.one"] + + +def test_byoc_entrypoint_shape(): + with pytest.raises(ValidationError, match="entrypoint"): + AgentSpec(**_byoc_spec(byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "entrypoint": "../main.py"})) + with pytest.raises(ValidationError, match="entrypoint"): + AgentSpec(**_byoc_spec(byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "entrypoint": "run.sh"})) + + +# ── upload endpoint ────────────────────────────────────────────────────────── + +class StubS3: + def __init__(self): + self.objects: dict[tuple[str, str], bytes] = {} + + def upload_file(self, filename, bucket, key): + self.objects[(bucket, key)] = Path(filename).read_bytes() + + def download_file(self, bucket, key, filename): + Path(filename).write_bytes(self.objects[(bucket, key)]) + + def put_object(self, Bucket, Key, Body, **_kw): + self.objects[(Bucket, Key)] = Body if isinstance(Body, bytes) else Body.encode() + + def get_object(self, Bucket, Key): + if (Bucket, Key) not in self.objects: + raise _no_such_key() + return {"Body": io.BytesIO(self.objects[(Bucket, Key)])} + + def delete_object(self, Bucket, Key): + self.objects.pop((Bucket, Key), None) + + +def _no_such_key(): + exc_type = type("NoSuchKey", (Exception,), {}) + return exc_type("missing") + + +@pytest.fixture +def stub_s3(monkeypatch): + from app.services import workspace as workspace_mod + + s3 = StubS3() + monkeypatch.setattr( + workspace_mod.WorkspaceContext, + "client", + lambda self, name, **_kw: s3 if name == "s3" else pytest.fail(f"client {name}"), + ) + return s3 + + +@pytest.fixture(autouse=True) +def stub_preresolve(monkeypatch): + """Keep the suite hermetic: the upload-time requirements pre-resolve runs + the real uv against the package index; stand in a canned success. Tests of + the check itself use `requirements_txt.preresolve` with a stub runner.""" + monkeypatch.setattr( + byoc_uploads, + "check_requirements", + lambda path, root, python_version="PYTHON_3_13": { + "status": "ok", "package_count": 1, "error": None + }, + ) + + +def test_upload_endpoint_stages_and_reads_back(client, stub_s3): + data = zip_bytes({"main.py": SDK_MAIN, "requirements.txt": b"requests==2.32.3\n"}) + res = client.post( + "/api/agents/uploads", files={"file": ("agent.zip", data, "application/zip")} + ) + assert res.status_code == 201, res.text + body = res.json() + assert body["upload_id"] + assert body["size_bytes"] == len(data) + assert body["detected"]["agentcore_sdk_detected"] is True + assert body["detected"]["entrypoint_candidates"] == ["main.py"] + assert body["uploaded_by"] # the resolved console identity + key = ("launchpad-artifacts-test", f"byoc/default/{body['upload_id']}/source.zip") + assert stub_s3.objects[key] == data + + detail = client.get(f"/api/agents/uploads/{body['upload_id']}") + assert detail.status_code == 200 + assert detail.json()["sha256"] == body["sha256"] + + +def test_upload_endpoint_reports_the_requirements_check(client, stub_s3, monkeypatch): + """The manifest carries the pre-resolve verdict, keyed to the python_version + the wizard sent — a failed resolve surfaces before any deploy is attempted.""" + seen = {} + + def fake_check(path, root, python_version="PYTHON_3_13"): + seen["python_version"] = python_version + return {"status": "failed", "package_count": None, + "error": "google-re2 publishes no wheel installable …"} + + monkeypatch.setattr(byoc_uploads, "check_requirements", fake_check) + data = zip_bytes({"main.py": SDK_MAIN, "requirements.txt": b"google-re2==1.0\n"}) + res = client.post( + "/api/agents/uploads?python_version=PYTHON_3_11", + files={"file": ("agent.zip", data, "application/zip")}, + ) + assert res.status_code == 201, res.text + assert seen["python_version"] == "PYTHON_3_11" + reqs = res.json()["detected"]["requirements"] + assert reqs["status"] == "failed" + assert "google-re2" in reqs["error"] + + +def test_upload_endpoint_skips_the_check_without_requirements(client, stub_s3): + data = zip_bytes({"main.py": SDK_MAIN}) + res = client.post( + "/api/agents/uploads", files={"file": ("agent.zip", data, "application/zip")} + ) + assert res.status_code == 201 + assert res.json()["detected"]["requirements"]["status"] == "skipped" + + +def test_upload_endpoint_refuses_unknown_python_version(client, stub_s3): + res = client.post( + "/api/agents/uploads?python_version=PYTHON_2_7", + files={"file": ("agent.zip", zip_bytes({"main.py": SDK_MAIN}), "application/zip")}, + ) + assert res.status_code == 422 + assert res.json()["code"] == "byoc.invalid_python_version" + + +def test_upload_endpoint_refuses_non_zip(client, stub_s3): + res = client.post( + "/api/agents/uploads", files={"file": ("agent.tar", b"x", "application/x-tar")} + ) + assert res.status_code == 400 + assert res.json()["code"] == "byoc.invalid_upload" + + +def test_upload_endpoint_refuses_invalid_archive(client, stub_s3): + res = client.post( + "/api/agents/uploads", files={"file": ("agent.zip", b"not a zip", "application/zip")} + ) + assert res.status_code == 422 + assert res.json()["code"] == "byoc.zip_invalid" + + +def test_upload_endpoint_enforces_stream_cap(client, stub_s3, monkeypatch): + monkeypatch.setattr(byoc_uploads, "MAX_ZIP_BYTES", 10) + res = client.post( + "/api/agents/uploads", + files={"file": ("agent.zip", zip_bytes({"main.py": b"x" * 100}), "application/zip")}, + ) + assert res.status_code == 413 + assert res.json()["code"] == "byoc.upload_too_large" + + +def test_upload_content_length_guard(client, stub_s3): + res = client.post( + "/api/agents/uploads", + content=b"x", + headers={ + "Content-Type": "multipart/form-data; boundary=x", + "Content-Length": str(byoc_uploads.UPLOAD_REQUEST_MAX_BYTES + 1), + }, + ) + assert res.status_code == 413 + assert res.json()["code"] == "byoc.upload_request_too_large" + + +def test_upload_detail_404_for_unknown_id(client, stub_s3): + res = client.get("/api/agents/uploads/nope123") + assert res.status_code == 404 + assert res.json()["code"] == "byoc.upload_not_found" + + +def test_create_byoc_agent_via_api(client, stub_s3, monkeypatch): + launched: list[str] = [] + monkeypatch.setattr(agents_router, "start_deploy_async", lambda jid: launched.append(jid)) + res = client.post("/api/agents", json=_byoc_spec()) + assert res.status_code == 202, res.text + assert res.json()["agent"]["method"] == "byoc" + assert launched + + +def test_create_and_redeploy_round_trip_allowed_models(client, stub_s3, monkeypatch): + monkeypatch.setattr(agents_router, "start_deploy_async", lambda jid: None) + body = _byoc_spec(byoc={ + "artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one", "us.model.two"], + }) + res = client.post("/api/agents", json=body) + assert res.status_code == 202, res.text + agent = res.json()["agent"] + assert agent["spec"]["model_id"] == "us.model.one" # primary = first entry + assert agent["spec"]["byoc"]["allowed_models"] == ["us.model.one", "us.model.two"] + + # the stored spec redeploys as read back, with the list edited + db = SessionLocal() + db.get(Agent, agent["id"]).status = "active" # simulate the finished deploy + db.commit() + db.close() + spec = agent["spec"] + spec["byoc"]["allowed_models"] = ["us.model.two", "us.model.three"] + spec["model_id"] = "us.model.two" + res = client.post(f"/api/agents/{agent['id']}/redeploy", json=spec) + assert res.status_code == 202, res.text + stored = res.json()["agent"]["spec"] + assert stored["model_id"] == "us.model.two" + assert stored["byoc"]["allowed_models"] == ["us.model.two", "us.model.three"] + + +def test_create_refuses_model_id_outside_allowed_models(client, stub_s3): + body = _byoc_spec( + model_id="us.model.other", + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one"]}, + ) + res = client.post("/api/agents", json=body) + assert res.status_code == 422 + assert "allowed_models" in res.text + + +# ── deployer stages ────────────────────────────────────────────────────────── + +RESOURCES = { + "artifacts_bucket": "bkt", + "execution_role_arn": "arn:role", + "codebuild_project": "launchpad-agent-builder", + "ecr_repo": "launchpad-agents", +} + + +def _mk_agent(spec: AgentSpec) -> tuple[str, str]: + db = SessionLocal() + agent = Agent( + workspace_id=DEFAULT_WORKSPACE_ID, name=spec.name, method="byoc", + status="deploying", spec=spec.model_dump(), + ) + db.add(agent) + db.flush() + dep = Deployment( + workspace_id=DEFAULT_WORKSPACE_ID, agent_id=agent.id, + stages=[{"name": s, "status": "pending", "detail": ""} + for s in ("generate", "package", "provision", "deploy", "register")], + ) + db.add(dep) + db.commit() + ids = agent.id, dep.id + db.close() + return ids + + +def _get_agent(agent_id: str) -> Agent: + db = SessionLocal() + agent = db.get(Agent, agent_id) + db.close() + return agent + + +def _stage_ctx(agent_id: str, deployment_id: str, workspace) -> StageContext: + return StageContext( + agent_id=agent_id, deployment_id=deployment_id, job_id="j1", workspace=workspace + ) + + +def _client_router(monkeypatch, clients: dict): + from app.services import workspace as workspace_mod + + monkeypatch.setattr( + workspace_mod.WorkspaceContext, + "client", + lambda self, name, **_kw: clients[name], + ) + + +def _seed_upload(s3: StubS3, upload_id: str, files: dict[str, bytes], **manifest_over): + data = zip_bytes(files) + s3.objects[("bkt", f"byoc/default/{upload_id}/source.zip")] = data + manifest = { + "upload_id": upload_id, + "workspace_id": "default", + "sha256": "ab" * 32, + "size_bytes": len(data), + "original_filename": "agent.zip", + "uploaded_by": "river", + "uploaded_at": "2026-09-17T00:00:00+00:00", + "detected": { + "entrypoint_candidates": ["main.py"], + "has_requirements": False, + "has_dockerfile": "Dockerfile" in files, + "agentcore_sdk_detected": True, + }, + **manifest_over, + } + s3.objects[("bkt", f"byoc/default/{upload_id}/manifest.json")] = json.dumps( + manifest + ).encode() + + +def test_generate_stage_stamps_provenance(monkeypatch): + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + _seed_upload(s3, "u1", {"main.py": SDK_MAIN}) + spec = AgentSpec(**_byoc_spec()) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + result = byoc_dep._stage_generate(ctx, _get_agent(agent_id)) + assert "code_zip" in result.detail + + stored = _get_agent(agent_id).spec["byoc"]["provenance"] + assert stored["uploaded_by"] == "river" + assert stored["sha256"] == "ab" * 32 + assert stored["original_filename"] == "agent.zip" + + +def test_package_stage_code_zip_no_requirements(monkeypatch, tmp_path): + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + _seed_upload(s3, "u1", {"main.py": SDK_MAIN, "helper.py": b"x=1\n"}) + spec = AgentSpec(**_byoc_spec()) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + result = byoc_dep._stage_package(ctx, _get_agent(agent_id)) + assert "code_zip" in result.detail + assert ctx.scratch["s3_key"] == "agents/byoc-agent/byoc_package.zip" + packaged = s3.objects[("bkt", "agents/byoc-agent/byoc_package.zip")] + with zipfile.ZipFile(io.BytesIO(packaged)) as zf: + assert set(zf.namelist()) == {"main.py", "helper.py"} + + +def test_package_stage_missing_entrypoint_fails(monkeypatch): + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + _seed_upload(s3, "u1", {"app.py": SDK_MAIN}) + spec = AgentSpec(**_byoc_spec()) # entrypoint defaults to main.py + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + with pytest.raises(RuntimeError, match="entrypoint 'main.py' not found"): + byoc_dep._stage_package(ctx, _get_agent(agent_id)) + + +def test_package_stage_resolves_requirements(monkeypatch): + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + _seed_upload(s3, "u1", {"main.py": SDK_MAIN, "requirements.txt": b"requests==2.32.3\n"}) + spec = AgentSpec(**_byoc_spec()) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + calls = [] + + def fake_resolve(src_root, build_dir, python_version, log, **_kw): + calls.append((src_root, python_version)) + (src_root / "requests").mkdir() + (src_root / "requests" / "__init__.py").write_text("") + return 1 + + monkeypatch.setattr(byoc_dep, "resolve_requirements_into", fake_resolve) + result = byoc_dep._stage_package(ctx, _get_agent(agent_id)) + assert calls and calls[0][1] == "PYTHON_3_13" + assert "1 deps resolved" in result.detail + packaged = s3.objects[("bkt", "agents/byoc-agent/byoc_package.zip")] + with zipfile.ZipFile(io.BytesIO(packaged)) as zf: + assert "requests/__init__.py" in zf.namelist() + + +def test_resolve_requirements_pip_args(tmp_path): + """The install resolves for the runtime target, never for this host.""" + src = tmp_path / "src" + src.mkdir() + (src / "requirements.txt").write_text("requests==2.32.3\n") + build = tmp_path / "build" + build.mkdir() + commands = [] + + def runner(args, **_kw): + commands.append(args) + if "compile" in args: + out = args[args.index("-o") + 1] + Path(out).write_text("requests==2.32.3 \\\n --hash=sha256:deadbeef\n") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + count = byoc_dep.resolve_requirements_into( + src, build, "PYTHON_3_11", lambda _m: None, pip_runner=runner + ) + assert count == 1 + compile_cmd = commands[0] + # the resolve targets the member's python and the configured platform… + assert compile_cmd[compile_cmd.index("--python-version") + 1] == "3.11" + assert "aarch64-manylinux_2_28" in compile_cmd + install = commands[-1] + assert "--require-hashes" in install + # …and the install carries the full tag ladder down to manylinux2014: pip + # does not widen --platform itself, and most wheels are tagged 2_17. + assert "manylinux_2_28_aarch64" in install + assert "manylinux2014_aarch64" in install + assert install[install.index("--python-version") + 1] == "3.11" + assert (src / "requirements.lock").exists() + + +def test_resolve_requirements_refuses_boundary_violations(tmp_path): + """An uploaded requirements.txt cannot pull from outside the platform index.""" + src = tmp_path / "src" + src.mkdir() + (src / "requirements.txt").write_text("--extra-index-url https://mirror.example\n") + + def never(args, **_kw): # pragma: no cover - must not be reached + raise AssertionError("no subprocess may run for a refused file") + + with pytest.raises(RuntimeError, match="requirements.txt was refused"): + byoc_dep.resolve_requirements_into( + src, tmp_path / "build", "PYTHON_3_13", lambda _m: None, pip_runner=never + ) + + +def test_package_stage_container_source_uses_codebuild(monkeypatch): + s3 = StubS3() + + class StubCodeBuild: + def start_build(self, **kwargs): + self.started = kwargs + return {"build": {"id": "b-1"}} + + def batch_get_builds(self, ids): + return {"builds": [{"id": ids[0], "currentPhase": "COMPLETED", + "buildStatus": "SUCCEEDED", "phases": []}]} + + class StubEcr: + def describe_images(self, repositoryName, imageIds): + return {"imageDetails": [{"imageDigest": "sha256:" + "0" * 64}]} + + codebuild = StubCodeBuild() + _client_router(monkeypatch, {"s3": s3, "codebuild": codebuild, "ecr": StubEcr()}) + monkeypatch.setattr( + "app.deployer.container.get_settings", + lambda: SimpleNamespace(image_scan_enabled=False), + ) + _seed_upload(s3, "u2", {"Dockerfile": b"FROM python:3.13-slim\n", "main.py": SDK_MAIN}) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_source", "upload_id": "u2"} + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + result = byoc_dep._stage_package(ctx, _get_agent(agent_id)) + assert "codebuild" in result.detail + assert codebuild.started["projectName"] == "launchpad-agent-builder" + assert ("bkt", "builds/byoc-agent/source.zip") in s3.objects + assert ctx.scratch["image_uri"].endswith("@sha256:" + "0" * 64) + + +def test_package_stage_container_source_ships_platform_buildspec(monkeypatch): + """The CodeBuild source zip must carry the PLATFORM buildspec — a + buildspec.yml inside the upload is overwritten, never executed.""" + s3 = StubS3() + + class StubCodeBuild: + def start_build(self, **kwargs): + return {"build": {"id": "b-1"}} + + def batch_get_builds(self, ids): + return {"builds": [{"id": ids[0], "currentPhase": "COMPLETED", + "buildStatus": "SUCCEEDED", "phases": []}]} + + class StubEcr: + def describe_images(self, repositoryName, imageIds): + return {"imageDetails": [{"imageDigest": "sha256:" + "0" * 64}]} + + _client_router(monkeypatch, {"s3": s3, "codebuild": StubCodeBuild(), "ecr": StubEcr()}) + monkeypatch.setattr( + "app.deployer.container.get_settings", + lambda: SimpleNamespace(image_scan_enabled=False), + ) + _seed_upload(s3, "u2", { + "Dockerfile": b"FROM python:3.13-slim\n", + "main.py": SDK_MAIN, + "buildspec.yml": b"version: 0.2\nphases:\n build:\n commands:\n - evil\n", + }) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_source", "upload_id": "u2"} + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + byoc_dep._stage_package(ctx, _get_agent(agent_id)) + source = s3.objects[("bkt", "builds/byoc-agent/source.zip")] + with zipfile.ZipFile(io.BytesIO(source)) as zf: + shipped = zf.read("buildspec.yml") + from app.deployer.container import platform_buildspec_path + + assert shipped == platform_buildspec_path().read_bytes() + assert b"evil" not in shipped + + +def test_package_stage_container_source_requires_dockerfile(monkeypatch): + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + _seed_upload(s3, "u2", {"main.py": SDK_MAIN}) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_source", "upload_id": "u2"} + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + with pytest.raises(RuntimeError, match="no Dockerfile"): + byoc_dep._stage_package(ctx, _get_agent(agent_id)) + + +def test_package_stage_container_image_skips(monkeypatch): + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_image", "image_uri": ECR_IMAGE} + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + result = byoc_dep._stage_package(ctx, _get_agent(agent_id)) + assert result.skipped + assert ctx.scratch["image_uri"] == ECR_IMAGE + + +def test_generate_stage_container_image_verifies_account(monkeypatch): + class StubEcr: + def describe_images(self, repositoryName, imageIds): + assert repositoryName == "my-agents" + assert imageIds == [{"imageTag": "v1"}] + return {"imageDetails": [{"imageDigest": "sha256:" + "1" * 64, + "imagePushedAt": None}]} + + _client_router(monkeypatch, {"ecr": StubEcr()}) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_image", "image_uri": ECR_IMAGE} + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + + result = byoc_dep._stage_generate(ctx, _get_agent(agent_id)) + assert "container_image" in result.detail + assert _get_agent(agent_id).spec["byoc"]["provenance"]["sha256"].startswith("sha256:") + + +def test_describe_image_refuses_other_account(): + other = "999988887777.dkr.ecr.us-west-2.amazonaws.com/repo:tag" + with pytest.raises(RuntimeError, match="this workspace deploys from"): + byoc_dep.describe_image(ws_ctx(RESOURCES), other, ecr_client=object()) + + +class StubRuntimeControl: + def __init__(self): + self.created_with = None + self.updated_with = None + self.deleted = [] + self.exceptions = SimpleNamespace( + ResourceNotFoundException=type("ResourceNotFoundException", (Exception,), {}) + ) + + def create_agent_runtime(self, **kwargs): + self.created_with = kwargs + return {"agentRuntimeId": "rt-1", "agentRuntimeArn": "arn:rt-1", + "agentRuntimeVersion": "1", "status": "CREATING"} + + def update_agent_runtime(self, **kwargs): + self.updated_with = kwargs + return {"agentRuntimeId": kwargs["agentRuntimeId"], "agentRuntimeArn": "arn:rt-1", + "agentRuntimeVersion": "2", "status": "UPDATING"} + + def get_agent_runtime(self, agentRuntimeId): + return {"agentRuntimeId": agentRuntimeId, "agentRuntimeArn": "arn:rt-1", + "agentRuntimeVersion": "1", "status": "READY"} + + def delete_agent_runtime(self, agentRuntimeId): + self.deleted.append(agentRuntimeId) + + +def test_deploy_stage_code_zip_payload(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "entrypoint": "serve.py", "python_version": "PYTHON_3_11"}, + env={"MODEL_ID": "custom.model"}, + memory={"short_term": False, "long_term": False}, + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch.update({ + "s3_bucket": "bkt", "s3_key": "agents/byoc-agent/byoc_package.zip", + "execution_role_arn": "arn:agent-role", + }) + + result = byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + assert result.detail.startswith("READY") + cfg = stub.created_with["agentRuntimeArtifact"]["codeConfiguration"] + assert cfg["runtime"] == "PYTHON_3_11" + # no ADOT launcher: user zips don't vendor opentelemetry-instrument + assert cfg["entryPoint"] == ["serve.py"] + assert cfg["code"]["s3"] == {"bucket": "bkt", + "prefix": "agents/byoc-agent/byoc_package.zip"} + assert stub.created_with["roleArn"] == "arn:agent-role" + # a user-provided MODEL_ID wins over the spec.model_id injection + assert stub.created_with["environmentVariables"] == { + "MODEL_ID": "custom.model", + "ALLOWED_MODEL_IDS": DEFAULT_MODEL_ID, # no list ⇒ the primary alone + } + assert _get_agent(agent_id).resource_id == "rt-1" + + +def test_deploy_stage_injects_model_id_when_env_has_none(monkeypatch): + # The execution role permits only spec.model_id, so the deployer must tell + # the user code which model it may call. + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec( + model_id="global.anthropic.claude-haiku-4-5", + memory={"short_term": False, "long_term": False}, + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch.update({ + "s3_bucket": "bkt", "s3_key": "agents/byoc-agent/byoc_package.zip", + "execution_role_arn": "arn:agent-role", + }) + + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + env = stub.created_with["environmentVariables"] + assert env["MODEL_ID"] == "global.anthropic.claude-haiku-4-5" + # without an allowed_models list the union is just the primary + assert env["ALLOWED_MODEL_IDS"] == "global.anthropic.claude-haiku-4-5" + + +def test_deploy_stage_injects_allowed_model_ids_list(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one", "us.model.two"]}, + memory={"short_term": False, "long_term": False}, + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch.update({ + "s3_bucket": "bkt", "s3_key": "agents/byoc-agent/byoc_package.zip", + "execution_role_arn": "arn:agent-role", + }) + + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + env = stub.created_with["environmentVariables"] + assert env["MODEL_ID"] == "us.model.one" # primary = allowed_models[0] + assert env["ALLOWED_MODEL_IDS"] == "us.model.one,us.model.two" + + +def test_deploy_stage_user_allowed_model_ids_wins(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "code_zip", "upload_id": "u1", + "allowed_models": ["us.model.one", "us.model.two"]}, + env={"ALLOWED_MODEL_IDS": "my,own,list"}, + memory={"short_term": False, "long_term": False}, + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch.update({ + "s3_bucket": "bkt", "s3_key": "agents/byoc-agent/byoc_package.zip", + "execution_role_arn": "arn:agent-role", + }) + + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + env = stub.created_with["environmentVariables"] + assert env["ALLOWED_MODEL_IDS"] == "my,own,list" + assert env["MODEL_ID"] == "us.model.one" # injection of the primary is independent + + +def test_deploy_stage_container_image_payload(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_image", "image_uri": ECR_IMAGE}, + memory={"short_term": False, "long_term": False}, + )) + agent_id, dep_id = _mk_agent(spec) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch["execution_role_arn"] = "arn:agent-role" + + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + artifact = stub.created_with["agentRuntimeArtifact"] + assert artifact == {"containerConfiguration": {"containerUri": ECR_IMAGE}} + + +def test_deploy_stage_update_mode_publishes_new_version(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + spec = AgentSpec(**_byoc_spec(memory={"short_term": False, "long_term": False})) + agent_id, dep_id = _mk_agent(spec) + db = SessionLocal() + db.get(Agent, agent_id).resource_id = "rt-1" + db.commit() + db.close() + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + ctx.scratch.update({"mode": "update", "execution_role_arn": "arn:agent-role"}) + + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + assert stub.updated_with["agentRuntimeId"] == "rt-1" + cfg = stub.updated_with["agentRuntimeArtifact"]["codeConfiguration"] + assert cfg["entryPoint"] == ["main.py"] + + +# ── delete path ────────────────────────────────────────────────────────────── + +def test_delete_removes_runtime_upload_and_images(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + s3 = StubS3() + s3.objects[("bkt", "byoc/default/u2/source.zip")] = b"z" + s3.objects[("bkt", "byoc/default/u2/manifest.json")] = b"{}" + + class StubEcr: + def __init__(self): + self.deleted = None + + def batch_delete_image(self, repositoryName, imageIds): + self.deleted = (repositoryName, imageIds) + + ecr = StubEcr() + _client_router(monkeypatch, {"s3": s3, "ecr": ecr}) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": "container_source", "upload_id": "u2"} + )) + agent = Agent( + workspace_id=DEFAULT_WORKSPACE_ID, name="byoc-agent", method="byoc", + status="active", spec=spec.model_dump(), resource_id="rt-1", version="2", + ) + + byoc_dep.delete_agent_resources(agent, ws_ctx(RESOURCES), ecr_client=ecr) + assert stub.deleted == ["rt-1"] + assert ("bkt", "byoc/default/u2/source.zip") not in s3.objects + assert ("bkt", "byoc/default/u2/manifest.json") not in s3.objects + assert ecr.deleted == ("launchpad-agents", + [{"imageTag": "byoc-agent-v1"}, {"imageTag": "byoc-agent-v2"}]) + + +def test_delete_code_zip_skips_ecr(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws=None: stub) + s3 = StubS3() + s3.objects[("bkt", "byoc/default/u1/source.zip")] = b"z" + _client_router(monkeypatch, {"s3": s3}) + spec = AgentSpec(**_byoc_spec()) + agent = Agent( + workspace_id=DEFAULT_WORKSPACE_ID, name="byoc-agent", method="byoc", + status="active", spec=spec.model_dump(), resource_id="rt-1", + ) + + byoc_dep.delete_agent_resources(agent, ws_ctx(RESOURCES)) + assert stub.deleted == ["rt-1"] + assert ("bkt", "byoc/default/u1/source.zip") not in s3.objects + + +def test_router_delete_dispatches_byoc(monkeypatch): + calls = [] + monkeypatch.setattr(agents_router.byoc_method, "delete_agent_resources", + lambda agent, ws: calls.append(agent.id)) + monkeypatch.setattr(agents_router.agent_iam, "delete_execution_role", + lambda *a, **k: True) + agent = Agent(id="x1", workspace_id=DEFAULT_WORKSPACE_ID, name="byoc-agent", + method="byoc", status="active", spec={}) + assert agents_router._delete_agent_resources(agent, ws_ctx(RESOURCES)) is True + assert calls == ["x1"] + + +# ── capability projections ─────────────────────────────────────────────────── + +def test_byoc_capabilities_degrade_as_custom_source(): + from app.optimization.service import canary_capability, experiment_capability + + agent = SimpleNamespace( + method="byoc", status="active", arn="arn:aws:...:runtime/rt-1", + spec=_byoc_spec(), system_key=None, + ) + exp = experiment_capability(agent) + assert exp["eligible"] is False + assert exp["reason_code"] == "custom-source-unverified" + can = canary_capability(agent) + assert can["eligible"] is False + assert can["reason_code"] == "custom-source-unverified" + + +def test_create_code_runtime_default_contract_unchanged(): + """Platform zips keep PYTHON_3_13 + opentelemetry-instrument main.py.""" + stub = StubRuntimeControl() + rt.create_code_runtime( + stub, runtime_name="a_1", s3_bucket="b", s3_key="k", role_arn="r" + ) + cfg = stub.created_with["agentRuntimeArtifact"]["codeConfiguration"] + assert cfg["runtime"] == "PYTHON_3_13" + assert cfg["entryPoint"] == ["opentelemetry-instrument", "main.py"] diff --git a/backend/tests/test_requirements_pinning.py b/backend/tests/test_requirements_pinning.py index 49ab7c02..7c1446a6 100644 --- a/backend/tests/test_requirements_pinning.py +++ b/backend/tests/test_requirements_pinning.py @@ -215,7 +215,7 @@ def capture(cmd, capture_output=True, text=True): return SimpleNamespace(returncode=0, stdout="", stderr="") resolve_pins(["x>=1"], [], runner=capture) - assert "aarch64-manylinux2014" in seen["cmd"] + assert "aarch64-manylinux_2_28" in seen["cmd"] # runtime_target default assert "3.13" in seen["cmd"] assert "--only-binary=:all:" in seen["cmd"] diff --git a/backend/tests/test_requirements_txt.py b/backend/tests/test_requirements_txt.py new file mode 100644 index 00000000..fce2c2c0 --- /dev/null +++ b/backend/tests/test_requirements_txt.py @@ -0,0 +1,248 @@ +"""User requirements.txt handling (T10 hardening): the pip-format parser and +its supply-chain rejections, the resolver-failure summarizer, the configurable +resolution platform, and the upload-time pre-resolve — all hermetic (the +resolver subprocess is stubbed).""" + +import subprocess +from types import SimpleNamespace + +import pytest + +from app.core import runtime_target +from app.core.config import get_settings +from app.services.requirements_txt import ( + MAX_REQUIREMENT_ENTRIES, + RequirementsFileError, + parse_requirements_txt, + pip_python_version, + preresolve, + summarize_resolver_failure, +) + +# ── parser: the pip requirements-file format ───────────────────────────────── + +def test_parser_joins_continuations_and_drops_hashes(): + text = ( + "requests==2.32.3 \\\n" + " --hash=sha256:" + "a" * 64 + " \\\n" + " --hash=sha256:" + "b" * 64 + "\n" + ) + assert parse_requirements_txt(text) == ["requests==2.32.3"] + + +def test_parser_strips_comments_and_blank_lines(): + text = "# header\n\nrequests==2.32.3 # pinned\n \n# trailer\n" + assert parse_requirements_txt(text) == ["requests==2.32.3"] + + +def test_parser_keeps_environment_markers(): + text = 'tomli==2.0.1 ; python_version < "3.11"\n' + assert parse_requirements_txt(text) == ['tomli==2.0.1 ; python_version < "3.11"'] + + +def test_parser_allows_unpinned_and_extras(): + """BYOC zips are not held to the spec.requirements pinning rule — the + hashed lock the package stage compiles is what makes the build reproducible.""" + assert parse_requirements_txt("chromadb\nuvicorn[standard]>=0.30\n") == [ + "chromadb", "uvicorn[standard]>=0.30", + ] + + +@pytest.mark.parametrize( + "line,reason", + [ + ("-r extra.txt", "nested requirements"), + ("--requirement extra.txt", "nested requirements"), + ("-c constraints.txt", "constraints files"), + ("-e .", "editable"), + ("--editable ./pkg", "editable"), + ("--index-url https://mirror.example/simple", "own package index"), + ("--extra-index-url https://mirror.example/simple", "own package index"), + ("--find-links ./wheels", "own package index"), + ("--no-index", "own package index"), + ("https://example.com/pkg.whl", "package index only"), + ("git+https://github.com/org/repo@main", "package index only"), + ("pkg @ https://example.com/pkg.whl", "package index only"), + ("file:./vendored", "package index only"), + ("./local-dir", "local paths"), + ("/abs/path", "local paths"), + ("requests==2.32.3 --global-option=x", "pip options"), + ], +) +def test_parser_rejects_the_supply_chain_boundary(line, reason): + with pytest.raises(RequirementsFileError, match=reason): + parse_requirements_txt(line + "\n") + + +def test_parser_caps_entry_count(): + text = "\n".join(f"pkg{i}==1.0" for i in range(MAX_REQUIREMENT_ENTRIES + 1)) + with pytest.raises(RequirementsFileError, match="more than"): + parse_requirements_txt(text) + + +def test_pip_python_version_shapes(): + assert pip_python_version("PYTHON_3_13") == "3.13" + assert pip_python_version("3.12") == "3.12" + + +# ── resolver-failure summaries ─────────────────────────────────────────────── + +def test_summarizer_names_the_wheelless_package(): + raw = ( + " × No solution found when resolving dependencies:\n" + " ╰─▶ Because google-re2==1.1.20240702 has no usable wheels and you " + "require google-re2==1.1.20240702, we can conclude that your " + "requirements are unsatisfiable.\n" + ) + msg = summarize_resolver_failure(raw) + assert "google-re2" in msg + assert "no wheel installable" in msg + assert "aarch64-manylinux_2_28" in msg # current target named in the reason + assert "we can conclude" not in msg # the derivation tree stays out + + +def test_summarizer_names_the_missing_package(): + raw = "Because nosuchpkg was not found in the package registry and you require…" + msg = summarize_resolver_failure(raw) + assert "nosuchpkg does not exist on the package index" in msg + + +def test_summarizer_names_the_missing_version(): + raw = "Because there is no version of requests==999.0 and you require requests==999.0" + msg = summarize_resolver_failure(raw) + assert "requests has no release matching the requested version" in msg + + +def test_summarizer_reports_conflicts_without_echoing_requirements(): + raw = ( + "Because pkg-a==1.0 depends on shared<2 and pkg-b==2.0 depends on " + "shared>=2, we can conclude that your requirements are unsatisfiable." + ) + msg = summarize_resolver_failure(raw) + assert "conflict" in msg + + +def test_summarizer_appends_caller_hints(): + raw = "Because x==1 has no usable wheels …" + msg = summarize_resolver_failure(raw, hints=("try the container path",)) + assert msg.rstrip(".").endswith("try the container path") + + +def test_summarizer_bounds_unknown_output(): + msg = summarize_resolver_failure("mystery " * 500) + assert len(msg) < 600 + + +# ── resolution platform: default + override ────────────────────────────────── + +def test_platform_default_is_manylinux_2_28(monkeypatch): + get_settings.cache_clear() + try: + assert runtime_target.uv_platform() == "aarch64-manylinux_2_28" + platforms = runtime_target.pip_platforms() + # pip does not widen --platform tags itself, so the ladder must carry + # every level from the target down to 2014 — else the install refuses + # the manylinux_2_17 wheels most projects publish. + assert platforms[0] == "manylinux_2_28_aarch64" + assert "manylinux_2_17_aarch64" in platforms + assert platforms[-1] == "manylinux2014_aarch64" + finally: + get_settings.cache_clear() + + +def test_platform_env_override(monkeypatch): + monkeypatch.setenv("LAUNCHPAD_RUNTIME_PYTHON_PLATFORM", "manylinux2014") + get_settings.cache_clear() + try: + assert runtime_target.uv_platform() == "aarch64-manylinux2014" + assert runtime_target.pip_platforms() == [ + "manylinux_2_17_aarch64", "manylinux2014_aarch64", + ] + finally: + monkeypatch.delenv("LAUNCHPAD_RUNTIME_PYTHON_PLATFORM") + get_settings.cache_clear() + + +def test_platform_setting_refuses_non_manylinux(monkeypatch): + monkeypatch.setenv("LAUNCHPAD_RUNTIME_PYTHON_PLATFORM", "macosx_11_0") + get_settings.cache_clear() + try: + with pytest.raises(Exception, match="pattern"): + get_settings() + finally: + monkeypatch.delenv("LAUNCHPAD_RUNTIME_PYTHON_PLATFORM") + get_settings.cache_clear() + + +# ── upload-time pre-resolve ────────────────────────────────────────────────── + +def _ok_runner(cmd, **_kw): + from pathlib import Path + + out = Path(cmd[cmd.index("-o") + 1]) + out.write_text("requests==2.32.3\nurllib3==2.2.2\n", encoding="utf-8") + return SimpleNamespace(returncode=0, stdout="", stderr="") + + +def test_preresolve_ok_counts_locked_packages(): + result = preresolve("requests==2.32.3\n", runner=_ok_runner) + assert result == {"status": "ok", "package_count": 2, "error": None} + + +def test_preresolve_ok_on_effectively_empty_file(): + result = preresolve("# nothing but comments\n\n", runner=_ok_runner) + assert result == {"status": "ok", "package_count": 0, "error": None} + + +def test_preresolve_failed_carries_the_summary(): + def failing(cmd, **_kw): + return SimpleNamespace( + returncode=1, stdout="", + stderr="Because google-re2==1.0 has no usable wheels …", + ) + + result = preresolve("google-re2==1.0\n", runner=failing) + assert result["status"] == "failed" + assert "google-re2" in result["error"] + assert result["package_count"] is None + + +def test_preresolve_failed_on_boundary_violation_without_running_uv(): + def never(cmd, **_kw): # pragma: no cover - must not be reached + raise AssertionError("resolver must not run for a refused file") + + result = preresolve("-e .\n", runner=never) + assert result["status"] == "failed" + assert "editable" in result["error"] + + +def test_preresolve_skipped_on_timeout(): + def timing_out(cmd, timeout=None, **_kw): + raise subprocess.TimeoutExpired(cmd, timeout) + + result = preresolve("requests==2.32.3\n", runner=timing_out, timeout_s=5) + assert result["status"] == "skipped" + assert "did not finish" in result["error"] + + +def test_preresolve_skipped_when_uv_is_missing(): + def missing(cmd, **_kw): + raise FileNotFoundError("uv") + + result = preresolve("requests==2.32.3\n", runner=missing) + assert result["status"] == "skipped" + assert "uv" in result["error"] + + +def test_preresolve_targets_the_requested_python(monkeypatch): + seen = {} + + def capture(cmd, **_kw): + seen["cmd"] = cmd + return _ok_runner(cmd) + + preresolve("requests==2.32.3\n", python_version="3.11", runner=capture) + cmd = seen["cmd"] + assert cmd[cmd.index("--python-version") + 1] == "3.11" + assert cmd[cmd.index("--python-platform") + 1] == "aarch64-manylinux_2_28" + assert "--only-binary=:all:" in cmd diff --git a/backend/tests/test_runtime_endpoints.py b/backend/tests/test_runtime_endpoints.py index 93e0e43a..149515ba 100644 --- a/backend/tests/test_runtime_endpoints.py +++ b/backend/tests/test_runtime_endpoints.py @@ -226,3 +226,51 @@ def poster(url, content, headers): poster=poster, signer=lambda *a: None, ) assert gw.SESSION_HEADER not in captured["headers"] + + +# ─── free-form JSON bodies (Runtime contract names no key) ─────────────────── +def test_invoke_runtime_text_reads_conventional_text_keys(): + # The devguide's own example body and a measured BYOC body (CrewAI agent, + # 2026-09-18) both rendered as an empty turn before this fallback existed. + for body, expected in [ + (b'{"response": "devguide example", "status": "success"}', "devguide example"), + ( + b'{"answer": "\xe6\x82\xa8\xe5\xa5\xbd", "session_id": "s", "turns": 1, ' + b'"latency_ms": 8630}', + "您好", + ), + (b'{"output": {"text": "nested block"}}', "nested block"), + ]: + out = rt.invoke_runtime_text(StubDataPlane(body), "arn:rt-1", "hi") + assert out["text"] == expected, body + + +def test_invoke_runtime_text_prefers_result_over_other_keys(): + stub = StubDataPlane(b'{"result": "primary", "answer": "ignored"}') + assert rt.invoke_runtime_text(stub, "arn:rt-1", "hi")["text"] == "primary" + + +def test_invoke_runtime_text_shows_unknown_json_instead_of_blank(): + stub = StubDataPlane(b'{"summary": "abc", "rows": [1, 2]}') + out = rt.invoke_runtime_text(stub, "arn:rt-1", "hi") + assert out["text"] == '{"summary": "abc", "rows": [1, 2]}' + + +def test_invoke_runtime_text_free_form_error_key_still_raises(): + with pytest.raises(RuntimeError, match="缺少 prompt"): + rt.invoke_runtime_text( + StubDataPlane(b'{"error": "\xe7\xbc\xba\xe5\xb0\x91 prompt", "session_id": "s"}'), + "arn:rt-1", + "hi", + ) + + +def test_runtime_payload_events_ignores_converse_bookkeeping_events(): + # Converse stream events without text must not be dumped as JSON. + for payload in [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 1}}}, + ]: + assert list(rt._runtime_payload_events(payload)) == [], payload diff --git a/backend/tests/test_zip_runtime_deployer.py b/backend/tests/test_zip_runtime_deployer.py index 4bf044b8..43c02df0 100644 --- a/backend/tests/test_zip_runtime_deployer.py +++ b/backend/tests/test_zip_runtime_deployer.py @@ -83,7 +83,7 @@ def test_build_zip_resolves_a_hashed_lock_for_the_deploy_target(tmp_path: Path): ) cmd = fake_pip_ok.compile_cmd assert "--generate-hashes" in cmd - assert "aarch64-manylinux2014" in cmd + assert "aarch64-manylinux_2_28" in cmd # runtime_target default assert "3.13" in cmd assert "--only-binary=:all:" in cmd diff --git a/docs/api.md b/docs/api.md index bc455245..052a3d44 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,6 +102,56 @@ denied`, `AWS is throttling this request`, `AWS resource conflict`) and `detail` carries only `aws_error_code` — the raw AWS text names the deployment's role ARN, instance id and operation, which stay on the console side of the API-key boundary. +## Console Agents API — BYOC uploads + +The `byoc` creation method deploys member-written code. The two zip artifact +kinds (`code_zip`, `container_source`) stage their archive here first; the +returned `upload_id` goes into the create body's `spec.byoc`. + +| Method | Path | Result | +|---|---|---| +| `POST` | `/api/agents/uploads?python_version=PYTHON_3_13` | `perm:agents.deploy` — `multipart/form-data`, single part `file`, `.zip` only, ≤250 MiB (≤750 MiB uncompressed, ≤20k entries; zip-slip/absolute paths/symlinks refused). Stores `byoc/{workspace_id}/{upload_id}/source.zip` + `manifest.json` in the artifacts bucket → `201` `{upload_id, sha256, size_bytes, original_filename, uploaded_by, uploaded_at, entries_count, uncompressed_bytes, detected: {entrypoint_candidates[], has_requirements, has_dockerfile, agentcore_sdk_detected, requirements: {status: ok\|failed\|skipped, package_count, error}}}` — `requirements` is an upload-time dry resolve of the zip's requirements.txt against the deploy target (linux/aarch64 + the optional `python_version`, default PYTHON_3_13); `failed` means the deploy's package stage would fail the same way, `skipped` (no requirements.txt, resolver timeout ~90 s, `uv` unavailable) says nothing either way | +| `GET` | `/api/agents/uploads/{upload_id}` | member — the stored manifest (same shape); another workspace's upload_id answers 404 | + +Error codes: `byoc.invalid_upload` (400, missing/non-zip part or empty file), +`byoc.invalid_python_version` (422), +`byoc.upload_too_large` / `byoc.upload_request_too_large` (413), +`byoc.zip_invalid`, `byoc.zip_empty`, `byoc.zip_entry_unsafe`, +`byoc.zip_too_many_entries`, `byoc.zip_uncompressed_too_large` (422), +`byoc.upload_not_found` (404). + +The zip's `requirements.txt` follows the pip file format (backslash +continuations, inline comments, environment markers all honoured); `--hash=` +options are dropped because the platform re-locks the file against its own +deploy target with fresh hashes. Refused with a clear error: `-r`/`-c` +includes, `-e`/editable installs, local paths, direct URL/VCS entries, index +options (`--index-url`/`--extra-index-url`/`--find-links` — the platform +installs from its own index only), and more than 500 entries. + +`POST /api/agents` with `method: "byoc"` takes `spec.byoc`: +`{artifact_kind: code_zip|container_source|container_image, upload_id?, +image_uri?, entrypoint? (code_zip, default main.py), python_version? +(PYTHON_3_10…PYTHON_3_13, default PYTHON_3_13), install_requirements? (default +true), invoke_contract? (launchpad_prompt|raw), allowed_models? (1–20 unique +Bedrock foundation-model or inference-profile ids)}` — the zip kinds require +`upload_id`, `container_image` requires a private-ECR `image_uri` in the +workspace's account+region. `system_prompt` is optional for this method (it +serves as a description); tools/toolkits/skills/knowledge_bases and protocol +`a2a` are refused in v1. The server stamps `spec.byoc.provenance` from the +upload manifest during deploy. + +**Allowed models.** The per-agent execution role scopes `bedrock:InvokeModel` +to exactly `byoc.allowed_models`, so user code calling any other model gets +`AccessDeniedException` at runtime. Entry `[0]` is the **primary** model and +must equal `spec.model_id`: send only `allowed_models` and the server sets +`model_id` to the first entry; send both and `model_id` must be in the list (it +is moved to the front). A spec without `allowed_models` — including every spec +written before the field existed — behaves as `[spec.model_id]`. Re-publish +rewrites the role policy, so an edited list takes effect on the next deploy. +The deployer passes the primary id to the runtime as env `MODEL_ID` and the +full list as env `ALLOWED_MODEL_IDS` (comma-separated, primary first) — for +either variable, a value already in `spec.env` wins. + ## Console Agents API — versions and endpoints `GET /api/agents/{agent_id}/versions` is the read-only AWS view behind the agent diff --git a/docs/api.zh-CN.md b/docs/api.zh-CN.md index 46d3412d..279f9835 100644 --- a/docs/api.zh-CN.md +++ b/docs/api.zh-CN.md @@ -420,6 +420,51 @@ period_not_allowed | description_too_long | dimension_keys_immutable`:1–10 重新发布之后应当按「结束会话」——AgentCore 会把存活的会话钉在首次服务它的版本上, 验证新版本需要一个全新的会话。 +## 控制台 Agent API——BYOC 上传 / Console Agents API: BYOC uploads + +`byoc` 创建方式部署成员自己编写的代码。两种 zip 构件类型(`code_zip`、 +`container_source`)先在此暂存归档;返回的 `upload_id` 填入创建请求体的 +`spec.byoc`。 + +| Method | Path | Result | +|---|---|---| +| `POST` | `/api/agents/uploads?python_version=PYTHON_3_13` | `perm:agents.deploy`——`multipart/form-data`,单个名为 `file` 的部件,仅限 `.zip`,≤250 MiB(解压后 ≤750 MiB、条目 ≤2 万;zip-slip/绝对路径/符号链接会被拒绝)。存入制品桶 `byoc/{workspace_id}/{upload_id}/source.zip` + `manifest.json` → `201` `{upload_id, sha256, size_bytes, original_filename, uploaded_by, uploaded_at, entries_count, uncompressed_bytes, detected: {entrypoint_candidates[], has_requirements, has_dockerfile, agentcore_sdk_detected, requirements: {status: ok\|failed\|skipped, package_count, error}}}`——`requirements` 是对 zip 内 requirements.txt 针对部署目标(linux/aarch64 + 可选 `python_version`,默认 PYTHON_3_13)的上传期干跑解析;`failed` 表示部署的 package 阶段会以同样方式失败,`skipped`(无 requirements.txt、解析超时约 90 秒、`uv` 不可用)不代表任何结论 | +| `GET` | `/api/agents/uploads/{upload_id}` | member——已存储的清单(同一形状);其他工作区的 upload_id 返回 404 | + +错误码:`byoc.invalid_upload`(400,缺少部件/非 zip/空文件)、 +`byoc.invalid_python_version`(422)、 +`byoc.upload_too_large` / `byoc.upload_request_too_large`(413)、 +`byoc.zip_invalid`、`byoc.zip_empty`、`byoc.zip_entry_unsafe`、 +`byoc.zip_too_many_entries`、`byoc.zip_uncompressed_too_large`(422)、 +`byoc.upload_not_found`(404)。 + +zip 内的 `requirements.txt` 按 pip 文件格式解析(反斜杠续行、行内注释、环境标记 +均被支持);`--hash=` 选项会被丢弃——平台会针对自己的部署目标重新锁定并生成新的 +hash。以下内容会被明确报错拒绝:`-r`/`-c` 引用、`-e`/可编辑安装、本地路径、直接 +URL/VCS 条目、索引选项(`--index-url`/`--extra-index-url`/`--find-links`——平台 +只从自己的索引安装),以及超过 500 条的清单。 + +`POST /api/agents` 使用 `method: "byoc"` 时携带 `spec.byoc`: +`{artifact_kind: code_zip|container_source|container_image, upload_id?, +image_uri?, entrypoint?(code_zip,默认 main.py), python_version? +(PYTHON_3_10…PYTHON_3_13,默认 PYTHON_3_13), install_requirements?(默认 +true), invoke_contract?(launchpad_prompt|raw), allowed_models?(1–20 个不重复的 +Bedrock 基础模型或推理配置文件 ID)}`——zip 类型必须提供 +`upload_id`,`container_image` 必须提供本工作区账户+区域内的私有 ECR +`image_uri`。该方式的 `system_prompt` 可选(作为描述使用);v1 拒绝 +tools/toolkits/skills/knowledge_bases 与 `a2a` 协议。部署时服务端会把 +`spec.byoc.provenance` 写入 spec(来自上传清单)。 + +**允许的模型。** 按 Agent 的执行角色把 `bedrock:InvokeModel` 精确限定到 +`byoc.allowed_models` 这些模型,用户代码调用其他模型会在运行时收到 +`AccessDeniedException`。第 `[0]` 个条目是**主模型**,必须等于 `spec.model_id`: +只发送 `allowed_models` 时服务端把 `model_id` 设为第一个条目;两者都发送时 +`model_id` 必须在列表中(会被移到最前)。没有 `allowed_models` 的 spec——包括该 +字段出现之前写入的所有行——按 `[spec.model_id]` 处理。重新发布会重写角色策略, +因此编辑后的列表在下次部署生效。部署器把主模型 ID 以环境变量 `MODEL_ID`、完整 +列表以环境变量 `ALLOWED_MODEL_IDS`(逗号分隔,主模型在前)传入运行时——两个变量 +只要 `spec.env` 已自行设置就以用户值优先。 + ## 控制台 Agent API——版本与端点 / Console Agents API `GET /api/agents/{agent_id}/versions` 是 Agent 详情「版本与端点」面板背后的只读 AWS 视图。它对该 Agent diff --git a/docs/architecture.md b/docs/architecture.md index 65146e30..8a01a0ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,7 +97,7 @@ real, runnable code in this repo. ## The unified five-stage deploy pipeline -All three creation methods converge into the same ordered stages, defined in +All creation methods converge into the same ordered stages, defined in `backend/app/deployer/pipeline.py`: ``` @@ -109,13 +109,13 @@ progress is persisted on the `Deployment` row and mirrored as JSONL events into the `Job` log, so a restarted backend resumes from the first non-succeeded stage (`resume_pending_jobs()` runs on startup). -| Stage | 方式B — harness | zip_runtime / 方式C — studio | 方式A — container | -|---|---|---|---| -| **generate** | Build `CreateHarness` request from the AgentSpec | Render the Strands template (studio: adapt user code verbatim) | Assemble ARM64 build context (Dockerfile + `main.py` + `.claude` scaffold) | -| **package** | *skipped* (no artifact) | resolve → hashed lock → `--require-hashes` install of ARM64 wheels → zip → S3 | zip context → S3 → CodeBuild (docker build+push) → ECR → resolve digest → scan gate | -| **provision** | Reuse the shared execution role | Reuse the shared execution role | Reuse the shared execution role | -| **deploy** | `CreateHarness` + poll READY | `CreateAgentRuntime` + poll READY | `CreateAgentRuntime(containerConfiguration)` + poll READY | -| **register** | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | +| Stage | 方式B — harness | zip_runtime / 方式C — studio | 方式A — container | byoc — bring your own code | +|---|---|---|---|---| +| **generate** | Build `CreateHarness` request from the AgentSpec | Render the Strands template (studio: adapt user code verbatim) | Assemble ARM64 build context (Dockerfile + `main.py` + `.claude` scaffold) | *No code generated.* Verify the staged upload (or the ECR image) and stamp server-verified provenance (sha256, uploader, timestamp) onto the spec | +| **package** | *skipped* (no artifact) | resolve → hashed lock → `--require-hashes` install of ARM64 wheels → zip → S3 | zip context → S3 → CodeBuild (docker build+push) → ECR → resolve digest → scan gate | `code_zip`: download → safe-extract → verify entrypoint → resolve the zip's `requirements.txt` for linux/aarch64 (hashed lock) → zip → S3. `container_source`: verify Dockerfile → same CodeBuild → ECR → digest → scan gate as 方式A. `container_image`: *skipped* | +| **provision** | Reuse the shared execution role | Reuse the shared execution role | Reuse the shared execution role | Per-agent least-privilege role (same machinery) | +| **deploy** | `CreateHarness` + poll READY | `CreateAgentRuntime` + poll READY | `CreateAgentRuntime(containerConfiguration)` + poll READY | `CreateAgentRuntime` — `codeConfiguration` (user's Python version + entrypoint, no ADOT launcher) or `containerConfiguration` — + poll READY | +| **register** | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | A2A registry record, auto-submitted; skipped when Registry was explicitly unavailable at bootstrap | Same shared register stage — byoc agents are runtime-backed for chat/versions/observability | Typical timings: harness ≈ 30 s, zip ≈ 1–3 min (incl. pip), container ≈ 2–4 min (observed: 1.7 min CodeBuild + seconds to READY) (via CodeBuild). See [troubleshooting.md](troubleshooting.md). @@ -189,14 +189,27 @@ whether what runs is still what was built. Both live in the `package` stage. over the declared list — which is what this used to be — installs whatever the index serves at that moment, including for the platform's own ranged pins, and leaves no record. The stage now runs `uv pip compile --generate-hashes` for the -deploy target (aarch64, Python 3.13, named once in `zip_runtime.py` so the resolve -and the install cannot disagree) with `--only-binary=:all:`, then installs those -same wheel-only candidates with `--require-hashes`. Without the matching binary -constraint, the resolver can lock an sdist-only release that the Runtime's -ARM64/manylinux2014 binary-only install rejects. A substituted or re-uploaded -distribution fails the build. The lock ships inside the zip as -`requirements.lock`, so the artifact carries its own bill of materials. There -is deliberately no fallback: a resolve failure fails the stage. +deploy target (aarch64, Python 3.13, defined once in +`app/core/runtime_target.py` so the resolve and the install cannot disagree) +with `--only-binary=:all:`, then installs those same wheel-only candidates with +`--require-hashes`. Without the matching binary constraint, the resolver can +lock an sdist-only release that the Runtime's ARM64 binary-only install +rejects. A substituted or re-uploaded distribution fails the build. The lock +ships inside the zip as `requirements.lock`, so the artifact carries its own +bill of materials. There is deliberately no fallback: a resolve failure fails +the stage. + +The resolution target is **`manylinux_2_28` / aarch64** by default. The +AgentCore Runtime direct-code environment was measured (2026-09-18, from inside +a deployed PYTHON_3_13 agent) as Amazon Linux 2023 on aarch64 with glibc 2.34, +so it loads any manylinux wheel up to `manylinux_2_34`; the official docs' +`manylinux2014` recommendation is safe but rejects packages that only publish +`manylinux_2_26`/`2_28` aarch64 wheels (e.g. `google-re2`, a `chromadb` +dependency). The level is configurable via `runtime_python_platform` +(`LAUNCHPAD_RUNTIME_PYTHON_PLATFORM`); `manylinux2014` is the documented +fallback should a runtime image ever report an older glibc. Because pip treats +`--platform` tags as exact strings, the install passes the whole tag ladder +from the configured level down to `manylinux2014`. Caller-supplied `spec.requirements` must additionally be pinned at *schema* validation (`app/schemas/requirements.py`), so the console rejects a range before a @@ -228,16 +241,34 @@ tag policy so this cannot drift into a broken re-publish. Not covered: SBOM generation, provenance/attestation, signing, approved-mirror enforcement, and skill *content* review. Immutable is not the same as trusted. +### Agent management routes + +Since 2026-09-18 the module is list-first (`/create` and `/create?view=discover` +redirect; the query string is kept so Registry's `?gateway=` / `?skill=` prefill +still lands on the wizard): + +| Route | View | +|---|---| +| `/agents` | landing: `+ New Agent` / `Import existing Runtime`, a stats strip (total / running / deploying / failed, derived from the loaded list) and the agent table (name → detail, CHAT + DETAILS visible, EDIT / CONVERT / DELETE in a per-row `···` menu, FAILED rows carry the error as tooltip + VIEW REASON) | +| `/agents/new` | the 3-step wizard; step 1 is the four method cards below, a button to the import page, and the system-preset cards (install / configure stay where they were, under the cards) | +| `/agents/import` | discovery of existing Runtime / Harness resources | +| `/agents/:id` | the agent's detail (the wizard's step-3 view: launch sequence, versions, BYOC provenance, conversion notes; live polling while deploying; OPEN CHAT / OBSERVABILITY / EDIT links). A deploy started on `/agents/new` navigates here when it goes active | +| `/agents/:id/edit` | the wizard preloaded for a re-publish (system presets open the shared editor, Studio agents go to `/create/studio?agent=`) | + ### Creation entrances -The `/create` picker shows four cards, in this order: +The `/agents/new` picker shows four cards, in this order: | # | Card | `AgentSpec.method` | What it is | |---|---|---|---| | 1 | **Managed Harness** | `harness` | 方式B — declarative, no build artifact | | 2 | **Strands Studio** | `zip_runtime` | 方式C — Strands template on the zip fast path; the card's nested link opens the `/create/studio` canvas, which deploys as method `studio` | | 3 | **Other Agent SDK** | `container` | 方式A — bring your own agent SDK, packaged as an ARM64 container via CodeBuild | -| 4 | **Discover existing runtimes and harnesses** | — | not a deploy method (see below) | +| 4 | **Bring Your Own Code** | `byoc` | user-written agent code uploaded as a zip (direct-code runtime or Dockerfile → CodeBuild) or referenced as an existing private-ECR image — see [BYOC](#byoc--bring-your-own-code) | + +Discovery of existing runtimes and harnesses is not a deploy method: it has its +own page at `/agents/import` (see below), reachable from the list header and +from a button next to NEXT on step 1. The third card is a **category**, not one SDK. `AgentSpec.agent_sdk` records which SDK a container agent packages, and the wizard exposes it as a @@ -248,6 +279,87 @@ no stored-spec migration. There is deliberately **no dispatch** on the field yet `app/deployer/container.py` and `app/templates/claude_sdk_agent/` stay unconditional until the category has a second member. +### BYOC — bring your own code + +The fourth card deploys code the member's developers wrote themselves — already +wrapped with the AgentCore SDK (`BedrockAgentCoreApp` + `@app.entrypoint`) or +any HTTP server satisfying the runtime contract (ARM64, port 8080, +`POST /invocations` + `GET /ping`, payload `{"prompt", "actor_id"}`). The +**response** is whatever the code answers — the Runtime HTTP contract requires +JSON or SSE and names no key. Chat, the public `/v1` API and evaluation replays +all read it through one parser (`services/agentcore/runtime.py::_runtime_payload_events`): +`{"result": …}` (BedrockAgentCoreApp's convention, preferred) or the +delta/tool/complete SSE envelope stream for real; any other JSON body is shown +by its first conventional text key (`response`, `answer`, `output`, `text`, +`message`, `content`, `completion`, `reply` — a nested `{"text"}` block under +one of them also counts), and a body with none of those is rendered as compact +JSON rather than a blank turn (measured 2026-09-18: a CrewAI agent answering +`{"answer", "session_id", "turns"}` produced an empty reply with no error). +`{"error": …}` is surfaced as a failed turn. Three +artifact kinds, one `spec.byoc` block (`backend/app/schemas/agent.py::ByocConfig`): + +| `artifact_kind` | Input | Path to Runtime | +|---|---|---| +| `code_zip` | zip of Python source (staged via `POST /api/agents/uploads`) | S3 → `CreateAgentRuntime(codeConfiguration)` with the member's Python version + entrypoint; the platform resolves the zip's `requirements.txt` into the bundle for linux/aarch64 (hashed lock, wheels only — nothing is executed) | +| `container_source` | zip carrying a Dockerfile | the shared `launchpad-agent-builder` CodeBuild project (ARM64) → ECR `launchpad-agents:{name}-v{version}` → `containerConfiguration`, including the digest pin and image-scan gate the container method uses | +| `container_image` | an existing image URI | verified with `ecr.describe_images` — must live in this workspace's account+region; public registries and other accounts are refused — then deployed as-is | + +**Security model.** Developers need no IAM: they hand a zip to whoever holds the +`perm:agents.deploy` console permission (uploads carry the same permission). +Each agent gets its own least-privilege execution role (`services/agent_iam.py`); +BYOC container kinds additionally get `ecr:BatchGetImage`/`GetDownloadUrlForLayer` +scoped to the image's repository. The role's `bedrock:InvokeModel` statement +covers exactly `spec.byoc.allowed_models` (1–20 ids; absent ⇒ `[spec.model_id]`) +— the union of each entry's foundation-model + inference-profile ARNs, deduped, +never a wildcard. Entry `[0]` is the primary (= `spec.model_id`); the deployer +injects it as env `MODEL_ID` and the full list as `ALLOWED_MODEL_IDS` +(comma-separated) so the code knows what it may call — `spec.env` values win. +Re-publish rewrites the role policy, so an edited list lands with the deploy. Uploads are workspace-scoped under +`byoc/{workspace_id}/{upload_id}/` in the artifacts bucket, and the server stamps +provenance (sha256, size, filename, uploader, time) onto the spec — the console +renders it on the agent detail view. + +**What is validated / what is not.** The upload gate enforces archive safety +(zip-slip, absolute paths, symlinks, ≤250 MiB zip / ≤750 MiB uncompressed / +≤20k entries — the AgentCore direct-code caps) and *reports* detection +(entrypoint candidates, requirements.txt, Dockerfile, AgentCore-SDK markers). +When the zip carries a `requirements.txt`, the upload also dry-resolves it +against the deploy target for the selected Python version (`?python_version=`) +and reports `detected.requirements: {status: ok|failed|skipped, package_count, +error}` — so the wizard flags an unresolvable file before a deploy is +attempted. `skipped` (resolver timeout, `uv` unavailable) says nothing either +way; the deploy still runs the authoritative resolve. + +**requirements.txt rules (`code_zip`).** The file is parsed per the pip +requirements-file format — backslash continuations, inline comments, blank +lines and environment markers are all honoured. `--hash=` options are dropped: +the platform re-locks the file against its own deploy target and generates +fresh hashes (`requirements.lock` inside the artifact). List direct +dependencies from the package index only; pins are optional (the hashed lock is +what makes the build reproducible). Refused with a clear error, because a +requirements file must not widen the platform-index-only supply-chain boundary: +`-r`/`-c` includes, `-e`/editable, local paths, direct URLs and VCS references, +`--index-url`/`--extra-index-url`/`--find-links`, and more than 500 entries. +When a dependency ships no compatible aarch64 wheel, the error names the +package and the alternatives: pin a release that does, use the Dockerfile +(`container_source`) path, or vendor the packages inside the zip with +`install_requirements=false`. +The platform does **not** review or scan the code itself; `container_source` +images do pass the existing ECR scan gate. User code is never executed on the +Launchpad host — package-time work is extraction and a wheels-only pip install +into the bundle directory. For `container_source`, the platform's own +`buildspec.yml` is always injected into the CodeBuild source zip, **overwriting +any buildspec the upload carries** — the member controls the Dockerfile only, +never the build recipe. + +**v1 scope.** HTTP protocol only (no A2A); no toolkits/skills/knowledge +bases/tools on the spec (the platform does not generate this code, so it cannot +wire them — configure capabilities inside your own code); `system_prompt` is +optional and serves as a description. Config-bundle experiments and canary +candidates degrade with `custom-source-unverified`, exactly like other +custom-source runtimes. Samples: [`samples/byoc/`](../samples/byoc/README.md); +lab walkthrough: [docs/lab/13-byoc.md](lab/13-byoc.md). + ### Recommendation trace source `RECOMMEND` reads either a rolling `RECOMMEND_LOOKBACK_DAYS` (7) CloudWatch window — @@ -1733,7 +1845,7 @@ the wizard shows it the SDK choice in place of the Model source control. ### Existing Runtime and Harness discovery -`/create?view=discover` is an onboarding path alongside the three creation +`/agents/import` is an onboarding path alongside the three creation methods, not a deploy method. `GET /api/agents/discovery` follows every Runtime list page in the configured Region and performs one detail read per resource. The backend returns only an allow-listed projection: Runtime identity, name, @@ -1791,7 +1903,7 @@ Evaluation, experiments, and harness→zip conversion stay keyed on Every `UpdateAgentRuntime` / `UpdateHarness` publishes an immutable new version; the `DEFAULT` endpoint auto-follows the latest while named endpoints (the target canary's `stable`/`treatment`) pin one. The ledger only remembers the version a -Launchpad deploy minted (`Agent.version`), so the agent detail on `/create` +Launchpad deploy minted (`Agent.version`), so the agent detail on `/agents/:id` (details mode) carries a **VERSIONS & ENDPOINTS** panel backed by `GET /api/agents/{agent_id}/versions`. The route resolves the row to one resource family — `zip_runtime`/`studio`/`container` and imported rows whose diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 15fdf041..40ba2dcc 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -68,7 +68,7 @@ English: [architecture.md](architecture.md) ## 统一的五阶段部署管道 -三种创建方式统一收敛到同一组有序阶段,定义在 `backend/app/deployer/pipeline.py`: +所有创建方式统一收敛到同一组有序阶段,定义在 `backend/app/deployer/pipeline.py`: ``` generate → package → provision → deploy → register @@ -78,13 +78,13 @@ generate → package → provision → deploy → register `Deployment` 行上,并作为 JSONL 事件镜像进 `Job` 日志,因此重启后的后端会从第一个 未成功的阶段继续(启动时执行 `resume_pending_jobs()`)。 -| 阶段 | 方式B — harness | zip_runtime / 方式C — studio | 方式A — container | -|---|---|---|---| -| **generate** | 从 AgentSpec 构建 `CreateHarness` 请求 | 渲染 Strands 模板(studio:原样适配用户代码) | 组装 ARM64 构建上下文(Dockerfile + `main.py` + `.claude` 脚手架) | -| **package** | *跳过*(无产物) | 解析 → 带 hash 的 lock → `--require-hashes` 安装 ARM64 wheels → zip → S3 | zip 上下文 → S3 → CodeBuild(docker build+push)→ ECR → 解析 digest → 扫描闸门 | -| **provision** | 复用共享执行角色 | 复用共享执行角色 | 复用共享执行角色 | -| **deploy** | `CreateHarness` + 轮询 READY | `CreateAgentRuntime` + 轮询 READY | `CreateAgentRuntime(containerConfiguration)` + 轮询 READY | -| **register** | A2A 注册记录,自动提交 | A2A 注册记录,自动提交 | A2A 注册记录,自动提交 | +| 阶段 | 方式B — harness | zip_runtime / 方式C — studio | 方式A — container | byoc — 自带代码 | +|---|---|---|---|---| +| **generate** | 从 AgentSpec 构建 `CreateHarness` 请求 | 渲染 Strands 模板(studio:原样适配用户代码) | 组装 ARM64 构建上下文(Dockerfile + `main.py` + `.claude` 脚手架) | *不生成代码。* 校验已暂存的上传(或 ECR 镜像),并把服务端核验的溯源信息(sha256、上传者、时间)写入 spec | +| **package** | *跳过*(无产物) | 解析 → 带 hash 的 lock → `--require-hashes` 安装 ARM64 wheels → zip → S3 | zip 上下文 → S3 → CodeBuild(docker build+push)→ ECR → 解析 digest → 扫描闸门 | `code_zip`:下载 → 安全解压 → 校验入口文件 → 为 linux/aarch64 解析 zip 内的 `requirements.txt`(带 hash 锁定)→ zip → S3;`container_source`:校验 Dockerfile → 与方式A 相同的 CodeBuild → ECR → digest → 扫描闸门;`container_image`:*跳过* | +| **provision** | 复用共享执行角色 | 复用共享执行角色 | 复用共享执行角色 | 按 Agent 的最小权限角色(同一套机制) | +| **deploy** | `CreateHarness` + 轮询 READY | `CreateAgentRuntime` + 轮询 READY | `CreateAgentRuntime(containerConfiguration)` + 轮询 READY | `CreateAgentRuntime`——`codeConfiguration`(用户选择的 Python 版本与入口,不带 ADOT 启动器)或 `containerConfiguration`——+ 轮询 READY | +| **register** | A2A 注册记录,自动提交 | A2A 注册记录,自动提交 | A2A 注册记录,自动提交 | 同一个共享 register 阶段——byoc Agent 是 Runtime 型,聊天/版本/可观测按 Runtime 处理 | 典型耗时:harness ≈ 30 秒,zip ≈ 1–3 分钟(含 pip),container ≈ 2–4 分钟(实测:CodeBuild 1.7 分钟 + 数秒即 READY) (经 CodeBuild)。见 [troubleshooting.zh-CN.md](troubleshooting.zh-CN.md)。 @@ -146,9 +146,20 @@ Canary 与 A/B 候选版本沿用**生产当前所在的角色**,取自 `GetAgen **依赖先解析、再锁定、再校验安装。** 过去这里只有一次针对声明列表的 `pip install`,它 装的是那一刻索引提供的任何版本(平台自带的范围写法也一样),而且不留任何记录。现在该 阶段先用 `uv pip compile --generate-hashes` 针对部署目标解析(aarch64、Python 3.13,在 -`zip_runtime.py` 里只写一次,以保证解析与安装不会各说各话),再用 `--require-hashes` -安装。被替换或重新上传过的发行包会让构建失败。lock 以 `requirements.lock` 随 zip 下发, -产物自带物料清单。这里刻意没有回退路径:解析失败就是阶段失败。 +`app/core/runtime_target.py` 里只写一次,以保证解析与安装不会各说各话),再用 +`--require-hashes` 安装。被替换或重新上传过的发行包会让构建失败。lock 以 +`requirements.lock` 随 zip 下发,产物自带物料清单。这里刻意没有回退路径:解析失败就是 +阶段失败。 + +解析目标默认是 **`manylinux_2_28` / aarch64**。实测(2026-09-18,在一个已部署的 +PYTHON_3_13 直连代码 Agent 内部)AgentCore Runtime 环境为 Amazon Linux 2023、 +aarch64、glibc 2.34,因此最高可加载 `manylinux_2_34` 的 wheel;官方文档推荐的 +`manylinux2014` 安全但更窄——只发布 `manylinux_2_26`/`2_28` aarch64 wheel 的包 +(如 `chromadb` 依赖的 `google-re2`)在该目标下无解。级别可经 +`runtime_python_platform`(`LAUNCHPAD_RUNTIME_PYTHON_PLATFORM`)配置;若未来某个 +运行时镜像报告更旧的 glibc,`manylinux2014` 是文档化的回退值。由于 pip 把 +`--platform` 标签当作精确字符串处理,安装时会传入从配置级别一路降到 +`manylinux2014` 的完整标签阶梯。 调用方提供的 `spec.requirements` 还会在 **schema** 校验阶段被要求固定版本 (`app/schemas/requirements.py`),因此控制台会在构建启动前就拒掉范围写法。平台自带的 @@ -173,16 +184,32 @@ agent 全部卡死。而读不到的扫描——未启用扫描、API 报错、 未覆盖:SBOM 生成、provenance/attestation、签名、受信镜像源强制,以及 skill **内容** 审查。不可变不等于可信。 +### Agent 管理路由 + +自 2026-09-18 起该模块以列表为首页(`/create` 与 `/create?view=discover` 重定向; +查询串保留,注册表的 `?gateway=` / `?skill=` 预填仍落到向导): + +| 路由 | 视图 | +|---|---| +| `/agents` | 首页:「新建 Agent」/「导入现有 Runtime」按钮、统计条(总数 / 运行中 / 部署中 / 失败,由已加载列表推导)与 Agent 表格(名称链接到详情,CHAT + DETAILS 可见,编辑 / 转换 / 删除收进每行的「···」菜单,失败行以悬浮显示错误并提供「查看原因」) | +| `/agents/new` | 三步向导;第一步是下文四张方法卡、一个通往导入页的按钮,以及系统预设卡片(安装 / 配置方式不变,位于方法卡下方) | +| `/agents/import` | 发现现有 Runtime / Harness 资源 | +| `/agents/:id` | Agent 详情(向导第三步视图:启动序列、版本、BYOC 来源、转换说明;部署中实时轮询;打开对话 / 可观测性 / 编辑链接)。在 `/agents/new` 发起的部署转为 active 后自动跳到这里 | +| `/agents/:id/edit` | 预载该 Agent 的向导用于重新发布(系统预设打开共享编辑器,Studio Agent 转到 `/create/studio?agent=`) | + ### 创建入口 -`/create` 的入口卡片共四张,顺序如下: +`/agents/new` 的入口卡片共四张,顺序如下: | # | 卡片 | `AgentSpec.method` | 说明 | |---|---|---|---| | 1 | **托管 Harness** | `harness` | 方式B —— 声明式,无构建产物 | | 2 | **Strands Studio** | `zip_runtime` | 方式C —— Strands 模板走 zip 快速通道;卡片内嵌链接进入 `/create/studio` 画布,画布以 `studio` 方式部署 | | 3 | **其他 Agent SDK** | `container` | 方式A —— 自带 Agent SDK,经 CodeBuild 打包为 ARM64 容器 | -| 4 | **发现现有 Runtime 与 Harness** | — | 不是部署方式(见下文) | +| 4 | **自带代码** | `byoc` | 开发者自己编写的 Agent 代码——上传 zip(直连代码运行时或 Dockerfile → CodeBuild),或引用本账户私有 ECR 中的现有镜像——见下文 BYOC 小节 | + +发现现有 Runtime 与 Harness 不是部署方式:它有独立页面 `/agents/import`(见下文), +可从列表页头部和第一步 NEXT 旁的按钮进入。 第三张卡片是一个**类别**,而不是某一个 SDK。`AgentSpec.agent_sdk` 记录容器 Agent 打包的是哪个 SDK,向导把它作为配置步骤上的二级选项。它是只有一个成员的 @@ -191,6 +218,70 @@ spec 也能被无歧义地读回,将来新增第二个 SDK 无需迁移已存 sp 该字段做分派**:在类别出现第二个成员之前,`app/deployer/container.py` 与 `app/templates/claude_sdk_agent/` 保持无条件实现。 +### BYOC —— 自带代码 + +第四张卡片部署成员开发者自己编写的代码——已用 AgentCore SDK +(`BedrockAgentCoreApp` + `@app.entrypoint`)包装,或任何满足运行时契约的 HTTP +服务(ARM64、8080 端口、`POST /invocations` + `GET /ping`、负载 +`{"prompt", "actor_id"}`)。**响应**由代码自行决定——Runtime HTTP 契约只要求 +JSON 或 SSE,并不规定键名。对话、公开 `/v1` API 与评估回放共用同一个解析器 +(`services/agentcore/runtime.py::_runtime_payload_events`):`{"result": …}` +(BedrockAgentCoreApp 的约定,推荐)或 delta/tool/complete SSE 信封按真流式处理; +其他 JSON 体取第一个常见文本键(`response`、`answer`、`output`、`text`、 +`message`、`content`、`completion`、`reply`,其下嵌套的 `{"text"}` 块同样算) +显示;一个都没有的则原样渲染为紧凑 JSON,而不是空白一轮(2026-09-18 实测: +CrewAI agent 返回 `{"answer", "session_id", "turns"}` 曾显示为空回复且无报错)。 +`{"error": …}` 作为失败轮次呈现。三种构件类型,同一个 `spec.byoc` 配置块 +(`backend/app/schemas/agent.py::ByocConfig`): + +| `artifact_kind` | 输入 | 到 Runtime 的路径 | +|---|---|---| +| `code_zip` | Python 源码 zip(经 `POST /api/agents/uploads` 暂存) | S3 → `CreateAgentRuntime(codeConfiguration)`,使用成员选择的 Python 版本与入口文件;平台把 zip 内的 `requirements.txt` 按 linux/aarch64 解析进包内(带 hash 锁定,只装 wheel——不执行任何用户代码) | +| `container_source` | 含 Dockerfile 的 zip | 共享的 `launchpad-agent-builder` CodeBuild 项目(ARM64)→ ECR `launchpad-agents:{name}-v{version}` → `containerConfiguration`,含与容器方式相同的 digest 固定与镜像扫描闸门 | +| `container_image` | 现有镜像 URI | 用 `ecr.describe_images` 核验——必须位于本工作区的账户+区域;公共镜像仓库与其他账户会被拒绝——然后按原样部署 | + +**安全模型。** 开发者无需任何 IAM:他们把 zip 交给持有 `perm:agents.deploy` +控制台权限的人(上传接口使用同一权限)。每个 Agent 拥有独立的最小权限执行角色 +(`services/agent_iam.py`);BYOC 容器类型额外获得按镜像仓库收敛的 +`ecr:BatchGetImage`/`GetDownloadUrlForLayer`。角色的 `bedrock:InvokeModel` +语句精确覆盖 `spec.byoc.allowed_models`(1–20 个 ID;缺省 ⇒ `[spec.model_id]`) +——即每个条目的基础模型 + 推理配置文件 ARN 的并集,去重,绝不使用通配符。第 +`[0]` 个条目是主模型(= `spec.model_id`);部署器把它以环境变量 `MODEL_ID`、完整 +列表以 `ALLOWED_MODEL_IDS`(逗号分隔)注入运行时,让代码知道自己可以调用什么—— +`spec.env` 中的用户值优先。重新发布会重写角色策略,编辑后的列表随部署生效。上传对象按工作区隔离,存放在制品桶的 +`byoc/{workspace_id}/{upload_id}/` 前缀下;服务端把溯源信息(sha256、大小、文件名、 +上传者、时间)写入 spec,控制台在 Agent 详情页展示。 + +**校验什么/不校验什么。** 上传闸门强制归档安全(zip-slip、绝对路径、符号链接、 +zip ≤250 MiB/解压后 ≤750 MiB/条目 ≤2 万——即 AgentCore 直连代码上限),并*报告* +检测结果(候选入口、requirements.txt、Dockerfile、AgentCore SDK 标记)。当 zip 带有 +`requirements.txt` 时,上传还会按所选 Python 版本(`?python_version=`)对部署目标做一次 +干跑解析,并报告 `detected.requirements: {status: ok|failed|skipped, package_count, +error}`——向导因此能在部署前就标出无法解析的文件。`skipped`(解析超时、`uv` 不可用) +不代表任何结论;部署仍会执行权威解析。 + +**requirements.txt 规则(`code_zip`)。** 文件按 pip requirements 文件格式解析—— +反斜杠续行、行内注释、空行与环境标记都被支持。`--hash=` 选项会被丢弃:平台针对自己的 +部署目标重新锁定并生成新的 hash(产物内的 `requirements.lock`)。只列出来自软件包索引 +的直接依赖;固定版本可选(可复现性由 hash 锁提供)。以下内容会被明确报错拒绝,因为 +requirements 文件不能扩大"仅平台索引"这一供应链边界:`-r`/`-c` 引用、`-e`/可编辑安装、 +本地路径、直接 URL 与 VCS 引用、`--index-url`/`--extra-index-url`/`--find-links`,以及 +超过 500 条的清单。当某个依赖没有兼容的 aarch64 wheel 时,错误会点名该包并给出出路: +换一个发布了对应 wheel 的版本、改走 Dockerfile(`container_source`)路径,或把依赖直接 +打进 zip 并设 `install_requirements=false`。平台 +**不**审查、不扫描代码本身;`container_source` 的镜像仍会经过现有的 ECR 扫描闸门。 +用户代码永远不会在 Launchpad 主机上执行——打包阶段只做解压和 wheel-only 的 pip +安装到包目录。对于 `container_source`,平台始终把自己的 `buildspec.yml` 注入 +CodeBuild 源码包,**覆盖上传中自带的任何 buildspec**——成员只控制 Dockerfile, +永远不控制构建配方。 + +**v1 范围。** 仅 HTTP 协议(不支持 A2A);spec 上不支持 +toolkits/skills/knowledge_bases/tools(平台不生成这份代码,无法接线——请在你自己的 +代码里配置能力);`system_prompt` 可选,作为描述使用。配置包实验与金丝雀候选按 +`custom-source-unverified` 降级,与其他自带源码的运行时一致。示例见 +[`samples/byoc/`](../samples/byoc/README.md);实验手册见 +[docs/lab/13-byoc.md](lab/13-byoc.md)。 + ### 推荐的 trace 来源 `RECOMMEND` 读取两者之一:默认是滚动的 `RECOMMEND_LOOKBACK_DAYS`(7)天 CloudWatch 窗口, @@ -942,7 +1033,7 @@ A2A zip Agent 使用另一个没有 Mantle 分支的模板,因此向导会将其 ### 发现既有 Runtime 与 Harness -`/create?view=discover` 是与三种创建方式并列的一条接入路径,而不是一种部署方式。 +`/agents/import` 是与三种创建方式并列的一条接入路径,而不是一种部署方式。 `GET /api/agents/discovery` 会跟完所配置 Region 中 Runtime 列表的每一页,并对每个资源做一次 详情读取。后端只返回白名单投影:Runtime 标识、名称、描述、协议、制品类型、authorizer 类型、 AWS 状态/版本以及最近更新时间。环境变量值、制品位置、执行角色与 authorizer 配置从不离开 @@ -985,7 +1076,7 @@ harness 的后端 runtime 通过既有的 ARN 联接解析出它的归属,重 每次 `UpdateAgentRuntime` / `UpdateHarness` 都会发布一个不可变的新版本;`DEFAULT` 端点 自动跟随最新版本,而命名端点(目标金丝雀的 `stable`/`treatment`)固定在某一版本。台账只记得 -Launchpad 部署时铸造的那个版本(`Agent.version`),所以 `/create` 的 Agent 详情(details 模式) +Launchpad 部署时铸造的那个版本(`Agent.version`),所以 `/agents/:id` 的 Agent 详情 带有一个由 `GET /api/agents/{agent_id}/versions` 支撑的**版本与端点**面板。该路由把台账行解析到 唯一一个资源族——`zip_runtime`/`studio`/`container` 以及 `spec.discovery.resource_type` 缺省或为 `runtime` 的导入行 → `ListAgentRuntimeVersions` + `ListAgentRuntimeEndpoints`;`harness` 以及 @@ -1346,7 +1437,7 @@ localhost"更窄:uvicorn 的 proxy-header 中间件(默认 `forwarded_allow_ips= 实际效果是 `member` 接近只读。在数据**尚未**按用户隔离的前提下这是有意为之:所有已登录 账户看到同一批 agent、知识库与链路,因此一个能部署的成员同时也能修改其他人的资源。 -仅管理员可用的模块(`/users`、`/create`、Studio 画布、注册表的注册/编辑)会渲染"需要 +仅管理员可用的模块(`/users`、`/agents`、Studio 画布、注册表的注册/编辑)会渲染"需要 管理员权限"面板而不是发出请求;`auth.forbidden` 也映射进了 `apiErrors` i18n 块,因此 任何漏加门禁的界面仍会显示本地化的原因。 diff --git a/docs/lab/02-deploy-runtime.md b/docs/lab/02-deploy-runtime.md index b7919e2f..febff722 100644 --- a/docs/lab/02-deploy-runtime.md +++ b/docs/lab/02-deploy-runtime.md @@ -14,7 +14,7 @@ ## 2.0 为什么主线 Agent 用 ZIP 通道 平台有三种创建方式,能力**不等价**。后面章节要用的高级能力对方式有硬性要求,先看下面这张表 -(这也是本实验要建两个 Agent 的原因)。表格列序与 `/create` 页上的卡片顺序一致;页面上还有 +(这也是本实验要建两个 Agent 的原因)。表格列序与 `/agents/new` 页上的卡片顺序一致;页面上还有 第四张卡片 `发现现有 Runtime`,它不是创建方式,而是把账号里已存在的 Runtime 纳管进来: | 能力 | 托管 Harness(方式B) | Strands ZIP(方式C · 表单) | Strands 画布(方式C · Studio) | 其他 Agent SDK · 容器(方式A) | @@ -53,7 +53,7 @@ ## 2.1 进入创建向导 -1. **打开** 控制台 → `02 Agent 管理`(`/create`)。 +1. **打开** 控制台 → `02 Agent 管理`(`/agents`),点右上角 **新建 Agent**(`/agents/new`)。 2. **观察** 页面顶部的三步导航:`01 · 选择方式` → `02 · 配置` → `03 · 发射`。 3. **选择** 第二张卡片 **Strands Studio**(角标 `ZIP 通道已上线 · Studio 已上线`), 然后点右侧 **下一步 ▸**。 diff --git a/docs/lab/03-deploy-harness.md b/docs/lab/03-deploy-harness.md index 85902039..09b53735 100644 --- a/docs/lab/03-deploy-harness.md +++ b/docs/lab/03-deploy-harness.md @@ -143,7 +143,7 @@ Hooks、MCP 服务器),经 CodeBuild 打成 **ARM64** 镜像推到 ECR,再 ## 3.3 三种方式对照 -列序与 `/create` 页上的卡片顺序一致: +列序与 `/agents/new` 页上的卡片顺序一致: | | 方式B 托管 Harness | 方式C Strands ZIP | 方式A 其他 Agent SDK · 容器 | |---|---|---|---| diff --git a/docs/lab/13-byoc.md b/docs/lab/13-byoc.md new file mode 100644 index 00000000..a099dab7 --- /dev/null +++ b/docs/lab/13-byoc.md @@ -0,0 +1,106 @@ +# 第 13 章 · 部署你自己的代码(BYOC · 自带代码) + +> **目标**:把你(或你的开发者)自己编写的 Agent 代码部署到 AgentCore Runtime——开发者全程不需要任何 AWS/IAM 权限。 +> +> **前置条件**:完成[第 01 章](01-environment.md);本地能运行 `zip` 命令。 +> +> **本章将创建的 AWS 资源**:1 个 AgentCore Runtime、1 个按 Agent 的 IAM 执行角色、制品桶中的上传对象(`container_source` 还会产生 1 次 CodeBuild 构建与 1 个 ECR 镜像标签)。 + +--- + +## 13.0 什么时候用 BYOC + +前几章的三种方式都由平台生成代码。当你的团队已经用 Claude Code / Codex 写好了 +Agent——用 `bedrock-agentcore` SDK(`BedrockAgentCoreApp` + `@app.entrypoint`) +包装,或自带一个满足契约的 HTTP 服务——BYOC 让管理员直接上传部署,而不必给 +开发者发 IAM 凭证。 + +**运行时契约**(可在向导里展开"运行时契约"帮助框查看): + +| 项 | 要求 | +|---|---| +| 架构 | ARM64(aarch64) | +| 端口 | 8080 | +| 路由 | `POST /invocations` + `GET /ping` | +| 调用负载 | `{"prompt": "...", "actor_id": "..."}` | +| 响应 | JSON 或 SSE,键名不限。推荐 `{"result": "..."}`;`response` / `answer` / `output` / `text` / `message` / `content` 等常见键同样能显示;都没有时对话框原样显示 JSON;`{"error": "..."}` 显示为失败 | +| zip 上限 | ≤250 MiB(解压后 ≤750 MiB) | + +## 13.1 准备示例代码 + +仓库自带两个满足契约的示例(`samples/byoc/`): + +```bash +cd samples/byoc +zip -r hello-http.zip hello-http/ # code_zip:直连代码运行时 +zip -r hello-container.zip hello-container/ # container_source:Dockerfile 构建 +``` + +## 13.2 控制台部署(code_zip) + +1. 打开 **Agent 管理**(`/agents`)→ **新建 Agent**(`/agents/new`),选第 4 张卡片 **自带代码**,点 **NEXT**。 +2. 构件类型保持 **代码 zip**;把 `hello-http.zip` 拖进上传框。 +3. 上传完成后会显示检测摘要:入口候选(`main.py`)、requirements.txt、 + AgentCore SDK 标记。若没有检测到 SDK 标记,会出现黄色提示——确认你的代码 + 自行实现了 `POST /invocations`。若 zip 带有 requirements.txt,上传时还会 + 针对运行时目标做一次**干跑解析**:绿色表示可解析(并显示包数量),红色则 + 给出具体原因(哪个包、为什么)——这样无需等到部署失败才发现依赖问题。 + 切换 Python 版本会自动重新检查。 +4. 入口文件选 `main.py`,Python 版本保持 3.13;可按需添加环境变量。 +5. 在 **允许的模型** 列表里添加你的代码要调用的模型(1–20 个,可从目录选择 + 或输入自定义 ID)。执行角色只允许调用列表中的这些模型 ID——你的代码调用 + 其他模型会收到 AccessDenied。第一个条目是**主模型**(可用「设为主模型」 + 调整顺序):平台把它以环境变量 `MODEL_ID`、完整列表以 `ALLOWED_MODEL_IDS` + (逗号分隔)传入运行时(若你自行添加了同名环境变量,则以你的值为准)。 + 示例代码正是从 `MODEL_ID` 读取模型 ID,所以无需额外配置。 +6. 填名称(如 `byoc-hello`),点 **LAUNCH**。流水线阶段与其他方式相同: + generate(核验上传与溯源)→ package(解析 requirements → zip → S3)→ + provision(按 Agent 角色)→ deploy(CreateAgentRuntime)→ register。 +7. 部署完成后到 **Chat** 发一句话验证;**Observability** 与 **VERSIONS & + ENDPOINTS** 面板与其他 Runtime 型 Agent 一致。 + +### requirements.txt 怎么写 + +- 只列**直接依赖**、且只来自公共软件包索引;固定版本(`==`)可选——平台会把 + 文件解析成带 hash 的锁定清单(`requirements.lock`,随产物下发),可复现性由 + 锁提供,不要求你手工固定。 +- 按 pip 文件格式解析:反斜杠续行、行内注释、空行、环境标记(`; python_version + < "3.12"`)都被支持。**`--hash=` 选项会被丢弃**:平台针对自己的部署目标重新 + 锁定并生成新的 hash,别的平台算出的 wheel hash 只会让构建失败。 +- 会被明确报错拒绝(供应链边界——平台只从自己的索引安装):`-r`/`-c` 引用、 + `-e`/可编辑安装、本地路径、直接 URL 与 VCS 引用(`git+…`)、 + `--index-url`/`--extra-index-url`/`--find-links`,以及超过 500 条的清单。 +- 解析目标默认是 **linux/aarch64 + `manylinux_2_28`**(实测运行时为 Amazon + Linux 2023、glibc 2.34,2026-09-18;官方文档推荐的 `manylinux2014` 是保守 + 回退值,可用 `LAUNCHPAD_RUNTIME_PYTHON_PLATFORM` 配置)。平台**从不构建源码 + 包**——某个依赖如果没有兼容的 aarch64 wheel,错误会点名它并给出三条出路: + 换一个发布了对应 wheel 的版本;改走 Dockerfile(`container_source`)路径, + 在镜像里自行安装;或把依赖直接打进 zip 并关闭「解析 requirements.txt」 + (`install_requirements=false`)。 + +## 13.3 Dockerfile 构建(container_source) + +同一向导,构件类型选 **Dockerfile 构建**,上传 `hello-container.zip`。 +package 阶段会走与方式A 相同的 CodeBuild(ARM64)→ ECR → digest 固定 → +镜像扫描闸门。构建配方由平台持有:平台的 `buildspec.yml` 会被注入 CodeBuild +源码包并**覆盖你 zip 里自带的任何 buildspec**——你只控制 Dockerfile。 + +## 13.4 现有镜像(container_image) + +如果镜像已经在本账户本区域的私有 ECR 里,选 **现有 ECR 镜像**,粘贴 +`.dkr.ecr..amazonaws.com/:`。平台会用 +`ecr.describe_images` 核验镜像存在且属于本工作区——公共镜像与其他账户会被拒绝。 + +## 13.5 溯源与治理 + +- Agent 详情页的 **BYOC 构件** 面板展示:构件类型、模型 ID(执行角色唯一 + 允许调用的模型)、sha256、大小、上传者、上传时间(或镜像 URI)。 +- v1 限制:仅 HTTP 协议;spec 上不支持工具/技能/知识库(在你自己的代码里 + 配置);配置包实验与金丝雀按 `custom-source-unverified` 降级。 +- 平台**不**审查代码内容;zip 归档安全(zip-slip、符号链接、大小上限)在上传 + 与打包两处都强制。 + +## 13.6 清理 + +删除 Agent 会一并删除 Runtime、按 Agent 的执行角色、暂存的上传对象,以及 +`container_source` 构建出的 ECR 镜像标签。 diff --git a/docs/lab/README.md b/docs/lab/README.md index 511022a7..42f9a09a 100644 --- a/docs/lab/README.md +++ b/docs/lab/README.md @@ -41,6 +41,7 @@ | 10 | [Runtime 金丝雀](10-canary.md) | 候选版本铸造、真实流量分档放量、每档证据门禁 | | 11 | [治理](11-governance.md) | Gateway 纳管标签、Cedar LOG_ONLY 策略、决策与审计 | | 12 | [收尾与资源清理](12-wrapup-cleanup.md) | 资源清单、清理顺序、成本提示 | +| 13(可选) | [部署你自己的代码(BYOC)](13-byoc.md) | 上传代码 zip / Dockerfile 构建 / 现有 ECR 镜像,开发者无需 IAM | 标为**可选**的章节是支线,讲的是怎么把 Agent 接进外部系统。跳过它不影响后续章节, 第 07 章起用到的 trace、数据集、实验对象全部来自第 02–05 章。 diff --git a/docs/studio-integration.md b/docs/studio-integration.md index c9a93352..114ad645 100644 --- a/docs/studio-integration.md +++ b/docs/studio-integration.md @@ -54,7 +54,7 @@ studio canvas ──generate code──▶ Deploy via Launchpad ▼ platform pipeline (zip fast path) generate – adapt_studio_code(): verbatim module + entrypoint wrapper - package – pip (manylinux2014_aarch64) → zip → S3 + package – pip (aarch64 manylinux wheels) → zip → S3 provision – shared execution role deploy – CreateAgentRuntime → poll READY register – A2A registry record, auto-submitted @@ -233,7 +233,7 @@ disabled until a flow exists. | full code > 200000 chars | toast error, no POST | | invalid canvas connection | `onInvalidConnection` callback → toast (never `alert()`) | | redeploy with changed name/method | backend 400 (client locks name field instead) | -| `?agent=` id missing/non-studio | toast + redirect `/create` | +| `?agent=` id missing/non-studio | toast + redirect `/agents` | ### 5. Good/Base/Bad Cases diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 436bef17..d262a5f7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,5 +1,5 @@ import { lazy } from "react"; -import { BrowserRouter, Route, Routes } from "react-router-dom"; +import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; import { ToastProvider } from "./components"; import { Shell } from "./layout/Shell"; @@ -22,6 +22,21 @@ const Chat = lazy(() => import("./pages/Chat").then((m) => ({ default: m.Chat }) const CreateAgent = lazy(() => import("./pages/CreateAgent").then((m) => ({ default: m.CreateAgent })), ); + +/** + * The pre-2026-09-18 management page lived at `/create` (wizard + list on one + * route, discovery under `?view=discover`). Old links in docs, bookmarks and + * assistant texts keep working: the query string rides along so Registry's + * `?gateway=` / `?skill=` prefill still lands on the wizard. + */ +function LegacyCreateRedirect() { + const { search } = useLocation(); + const params = new URLSearchParams(search); + if (params.get("view") === "discover") return ; + params.delete("view"); + const rest = params.toString(); + return ; +} const CreateAgentStudio = lazy(() => import("./pages/CreateAgentStudio").then((m) => ({ default: m.CreateAgentStudio })), ); @@ -59,9 +74,14 @@ export default function App() { }> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/methodChipMeta.ts b/frontend/src/components/methodChipMeta.ts index 8ae28399..bb848819 100644 --- a/frontend/src/components/methodChipMeta.ts +++ b/frontend/src/components/methodChipMeta.ts @@ -6,6 +6,7 @@ export const METHOD_CHIP: Record request<{ ok: boolean }>(`/api/users/${id}`, { method: "DELETE" }), + /** Stage a BYOC source zip; the returned upload_id goes into spec.byoc. */ + uploadByocArtifact: (file: File, pythonVersion?: ByocPythonVersion) => { + const form = new FormData(); + form.append("file", file); + const query = pythonVersion ? `?python_version=${pythonVersion}` : ""; + return requestForm(`/api/agents/uploads${query}`, form); + }, + getByocUpload: (uploadId: string) => + request(`/api/agents/uploads/${encodeURIComponent(uploadId)}`), createAgent: (spec: AgentSpecInput) => request<{ agent: AgentInfo; job_id: string; deployment_id: string }>("/api/agents", { method: "POST", diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 3444bb96..34fc4c9b 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -273,6 +273,13 @@ "desc": "Scan AgentCore Runtime and managed Harness resources in this Region and add agent-like resources to Launchpad without redeploying them.", "spec2": "HTTP + A2A + Harness inventory import", "spec3": "externally owned · no AWS mutation" + }, + "byoc": { + "badge": "YOUR CODE · NO IAM NEEDED", + "title": "Bring Your Own Code", + "desc": "Upload agent code your developers wrote — a zip for the direct-code runtime or a Dockerfile build context. The platform packages, deploys and operates it.", + "spec2": "SDK contract: BedrockAgentCoreApp / POST /invocations", + "spec3": "per-agent execution role · developers need no AWS access" } }, "discovery": { @@ -412,7 +419,48 @@ "a2aSkillName": "skill name", "a2aSkillDesc": "what this skill does — the routing surface other agents match on", "a2aSkillTags": "tags, comma-separated", - "a2aSkillAdd": "add skill" + "a2aSkillAdd": "add skill", + "titleByoc": "Configure — Bring Your Own Code", + "byocKind": "Artifact kind", + "byocKindName": { + "code_zip": "Code zip", + "container_source": "Dockerfile build", + "container_image": "Existing ECR image" + }, + "byocKindDesc": { + "code_zip": "A zip of Python source; deployed on the direct-code runtime (S3 → CreateAgentRuntime).", + "container_source": "A zip carrying a Dockerfile; built ARM64 on CodeBuild and pushed to ECR.", + "container_image": "An image already in this account's private ECR; no build." + }, + "byocUpload": "Source zip", + "byocDrop": "Drop a .zip here or click to choose (≤250 MiB)", + "byocUploading": "Uploading and validating…", + "byocEntries": "entries", + "byocNoSdkWarn": "No BedrockAgentCoreApp/@app.entrypoint marker detected — your entrypoint must serve POST /invocations + GET /ping on :8080 itself.", + "byocNoDockerfileWarn": "No Dockerfile detected at the zip root — a Dockerfile build needs one.", + "byocReqsOk": "requirements.txt resolves for the runtime target — {{count}} packages.", + "byocReqsFailed": "requirements.txt does not resolve for the runtime target — the deploy would fail at the package stage:", + "byocImageUri": "ECR image URI", + "byocImageHint": "A private ECR image in this account and region (ARM64). Public registries are refused.", + "byocEntrypoint": "Entrypoint", + "byocPython": "Python version", + "byocInstallReqs": "resolve requirements.txt (linux/aarch64)", + "byocRaw": "raw invoke contract", + "byocRawHint": "Acknowledges that your code handles the invoke payload itself; the platform still sends {prompt, actor_id}.", + "byocModels": "Allowed models", + "byocModelPrimary": "primary → MODEL_ID", + "byocModelMakePrimary": "set as primary", + "byocModelAdd": "Add a model from the catalog…", + "byocModelAddCustom": "Add custom ID", + "byocModelsHint": "The agent's execution role permits invoking exactly these models — your code gets AccessDenied on any other id. The platform passes the primary (first) model to your runtime as env MODEL_ID and the full list as ALLOWED_MODEL_IDS (comma-separated); values you set yourself in the environment variables win.", + "byocDescription": "Description", + "byocDescriptionPlaceholder": "What this agent does (shown in registry/chat)", + "byocEnv": "Environment variables", + "byocEnvKey": "Variable name", + "byocEnvValue": "Value", + "byocEnvAdd": "Add variable", + "byocContract": "Runtime contract", + "byocContractBody": "ARM64 (aarch64) · listens on port 8080 · POST /invocations + GET /ping\nInvoke payload: {\"prompt\": \"...\", \"actor_id\": \"...\"}\nZip ≤ 250 MiB (≤ 750 MiB uncompressed) · code zip runs on the managed Python runtime\nThe bedrock-agentcore SDK (BedrockAgentCoreApp + @app.entrypoint) satisfies all of this." }, "launchPanel": { "title": "LAUNCH PLAN", @@ -465,7 +513,7 @@ "details": "DETAILS", "delete": "DELETE", "remove": "REMOVE", - "empty": "No agents yet — create one above.", + "empty": "No agents yet — use “+ New Agent” to create one.", "deleted": "Agent deleted.", "confirmDeleteTitle": "Delete agent?", "confirmDelete": "This removes “{{name}}” and its AWS resource. This cannot be undone.", @@ -480,7 +528,15 @@ "filterMethodAll": "ALL METHODS", "filterStatusAll": "ALL STATUSES", "searchPlaceholder": "Search agent name / ID…", - "noMatch": "NO AGENTS MATCH THE CURRENT FILTERS" + "noMatch": "NO AGENTS MATCH THE CURRENT FILTERS", + "byocTitle": "BYOC artifact", + "byocKind": "kind", + "byocModels": "allowed models", + "byocModelPrimary": "primary → MODEL_ID", + "byocImage": "image", + "byocEntrypoint": "entrypoint", + "byocSize": "size", + "byocUploadedBy": "uploaded by" }, "system": { "title": "SYSTEM PRESETS", @@ -4815,5 +4871,40 @@ "evaluate": "5 · Evaluation", "experiment": "6 · A/B testing", "feedback": "7 · Production feedback" + }, + "agents": { + "meta": "LIST · STATUS · RE-PUBLISH — CREATE ON ITS OWN PAGE", + "newAgent": "New Agent", + "importRuntime": "Import existing Runtime", + "breadcrumb": "Agent management breadcrumb", + "crumbNew": "New", + "crumbEdit": "Edit {{name}}", + "crumbDetail": "Details", + "noDetails": "“{{name}}” has no deployment to show.", + "stats": { + "total": "AGENTS", + "active": "RUNNING", + "deploying": "DEPLOYING", + "failed": "FAILED" + }, + "empty": { + "title": "No agents yet", + "cta": "Create your first Agent" + }, + "moreActions": "More actions for {{name}}", + "viewReason": "VIEW REASON", + "time": { + "justNow": "just now", + "minutes_one": "{{count}} min ago", + "minutes_other": "{{count}} min ago", + "hours_one": "{{count}} hour ago", + "hours_other": "{{count}} hours ago", + "days_one": "{{count}} day ago", + "days_other": "{{count}} days ago" + }, + "detail": { + "chat": "OPEN CHAT", + "observability": "OBSERVABILITY" + } } } diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 53348a11..1e9beb75 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -273,6 +273,13 @@ "desc": "扫描当前区域的 AgentCore Runtime 与托管 Harness 资源,将 Agent 类型的资源纳入 Launchpad,不会重新部署。", "spec2": "导入 HTTP + A2A + Harness 资源清单", "spec3": "外部所有 · 不修改 AWS 资源" + }, + "byoc": { + "badge": "自带代码 · 无需 IAM", + "title": "自带代码", + "desc": "上传开发者自己编写的 Agent 代码——直连代码运行时的 zip 包,或含 Dockerfile 的构建上下文。平台负责打包、部署与运维。", + "spec2": "SDK 契约:BedrockAgentCoreApp / POST /invocations", + "spec3": "按 Agent 独立执行角色·开发者无需 AWS 权限" } }, "discovery": { @@ -412,7 +419,48 @@ "a2aSkillName": "技能名称", "a2aSkillDesc": "技能说明——其他 Agent 据此匹配路由", "a2aSkillTags": "标签,逗号分隔", - "a2aSkillAdd": "添加技能" + "a2aSkillAdd": "添加技能", + "titleByoc": "配置——自带代码", + "byocKind": "构件类型", + "byocKindName": { + "code_zip": "代码 zip", + "container_source": "Dockerfile 构建", + "container_image": "现有 ECR 镜像" + }, + "byocKindDesc": { + "code_zip": "Python 源码 zip 包;部署到直连代码运行时(S3 → CreateAgentRuntime)。", + "container_source": "含 Dockerfile 的 zip 包;在 CodeBuild 上构建 ARM64 镜像并推送到 ECR。", + "container_image": "本账户私有 ECR 中已有的镜像;无需构建。" + }, + "byocUpload": "源码 zip", + "byocDrop": "拖放 .zip 到此处,或点击选择(≤250 MiB)", + "byocUploading": "正在上传并校验……", + "byocEntries": "个条目", + "byocNoSdkWarn": "未检测到 BedrockAgentCoreApp/@app.entrypoint 标记——你的入口必须自行在 :8080 端口提供 POST /invocations 与 GET /ping。", + "byocNoDockerfileWarn": "未在 zip 根目录检测到 Dockerfile——Dockerfile 构建需要它。", + "byocReqsOk": "requirements.txt 可为运行时目标解析——共 {{count}} 个包。", + "byocReqsFailed": "requirements.txt 无法为运行时目标解析——部署将在 package 阶段失败:", + "byocImageUri": "ECR 镜像 URI", + "byocImageHint": "本账户本区域的私有 ECR 镜像(ARM64)。公共镜像仓库会被拒绝。", + "byocEntrypoint": "入口文件", + "byocPython": "Python 版本", + "byocInstallReqs": "解析 requirements.txt(linux/aarch64)", + "byocRaw": "raw 调用契约", + "byocRawHint": "表示你的代码自行处理调用负载;平台仍发送 {prompt, actor_id}。", + "byocModels": "允许的模型", + "byocModelPrimary": "主模型 → MODEL_ID", + "byocModelMakePrimary": "设为主模型", + "byocModelAdd": "从目录添加模型…", + "byocModelAddCustom": "添加自定义 ID", + "byocModelsHint": "该 Agent 的执行角色仅允许调用这些模型——你的代码调用其他模型 ID 会收到 AccessDenied。平台会把主模型(第一个)以环境变量 MODEL_ID、完整列表以 ALLOWED_MODEL_IDS(逗号分隔)传入你的运行时;如果你在环境变量中自行设置了同名变量,则以你的值为准。", + "byocDescription": "描述", + "byocDescriptionPlaceholder": "该 Agent 的用途(展示在注册表/聊天中)", + "byocEnv": "环境变量", + "byocEnvKey": "变量名", + "byocEnvValue": "值", + "byocEnvAdd": "添加变量", + "byocContract": "运行时契约", + "byocContractBody": "ARM64(aarch64)·监听 8080 端口·POST /invocations + GET /ping\n调用负载:{\"prompt\": \"...\", \"actor_id\": \"...\"}\nzip ≤ 250 MiB(解压后 ≤ 750 MiB)·代码 zip 运行在托管 Python 运行时上\n使用 bedrock-agentcore SDK(BedrockAgentCoreApp + @app.entrypoint)即可满足以上全部要求。" }, "launchPanel": { "title": "部署计划", @@ -465,7 +513,7 @@ "details": "详情", "delete": "删除", "remove": "移除", - "empty": "还没有 Agent——请在上方创建。", + "empty": "还没有 Agent——点击「新建 Agent」创建。", "deleted": "已删除 Agent。", "confirmDeleteTitle": "删除该 Agent?", "confirmDelete": "这会移除“{{name}}”及其 AWS 资源,且不可撤销。", @@ -480,7 +528,15 @@ "filterMethodAll": "全部创建方式", "filterStatusAll": "全部状态", "searchPlaceholder": "搜索 Agent 名称 / ID……", - "noMatch": "没有匹配当前筛选条件的 Agent" + "noMatch": "没有匹配当前筛选条件的 Agent", + "byocTitle": "BYOC 构件", + "byocKind": "类型", + "byocModels": "允许的模型", + "byocModelPrimary": "主模型 → MODEL_ID", + "byocImage": "镜像", + "byocEntrypoint": "入口", + "byocSize": "大小", + "byocUploadedBy": "上传者" }, "system": { "title": "系统预置", @@ -4815,5 +4871,40 @@ "evaluate": "5 · 评估", "experiment": "6 · A/B 测试", "feedback": "7 · 生产反馈" + }, + "agents": { + "meta": "列表 · 状态 · 重新发布 — 创建流程已移至独立页面", + "newAgent": "新建 Agent", + "importRuntime": "导入现有 Runtime", + "breadcrumb": "Agent 管理路径", + "crumbNew": "新建", + "crumbEdit": "编辑 {{name}}", + "crumbDetail": "详情", + "noDetails": "「{{name}}」没有可展示的部署。", + "stats": { + "total": "AGENT 总数", + "active": "运行中", + "deploying": "部署中", + "failed": "失败" + }, + "empty": { + "title": "还没有 Agent", + "cta": "创建第一个 Agent" + }, + "moreActions": "{{name}} 的更多操作", + "viewReason": "查看原因", + "time": { + "justNow": "刚刚", + "minutes_one": "{{count}} 分钟前", + "minutes_other": "{{count}} 分钟前", + "hours_one": "{{count}} 小时前", + "hours_other": "{{count}} 小时前", + "days_one": "{{count}} 天前", + "days_other": "{{count}} 天前" + }, + "detail": { + "chat": "打开对话", + "observability": "可观测性" + } } } diff --git a/frontend/src/pages/AssistantNextSteps.tsx b/frontend/src/pages/AssistantNextSteps.tsx index 21b8b459..411b80d5 100644 --- a/frontend/src/pages/AssistantNextSteps.tsx +++ b/frontend/src/pages/AssistantNextSteps.tsx @@ -335,7 +335,7 @@ export function AssistantNextSteps({ {t("assistantNext.chat.open")} ) : ( - + {t("assistantNext.agents")} ) @@ -500,7 +500,7 @@ export function AssistantNextSteps({ {t("assistantNext.iterate.openObs")} - + {t("assistantNext.agents")} diff --git a/frontend/src/pages/CreateAgent.tsx b/frontend/src/pages/CreateAgent.tsx index 4873bf1b..237a81b4 100644 --- a/frontend/src/pages/CreateAgent.tsx +++ b/frontend/src/pages/CreateAgent.tsx @@ -1,7 +1,9 @@ -import type { CSSProperties } from "react"; +import type { CSSProperties, ReactNode } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; -import { Link, useNavigate, useSearchParams } from "react-router-dom"; +import { Link, useNavigate, useParams, useSearchParams } from "react-router-dom"; import { ArrowLeft, Download, RefreshCw, Search } from "lucide-react"; import { useAuth } from "../auth/auth-context"; @@ -15,6 +17,7 @@ import { methodLabel, Pager, Panel, + StatTile, useToast, VersionsPanel, ViewHead, @@ -22,6 +25,11 @@ import { import type { AgentInfo, AgentSdk, + AgentSpecInput, + ByocArtifactKind, + ByocConfigInput, + ByocPythonVersion, + ByocUploadInfo, DeploymentInfo, HarnessDiscoveryCandidate, HarnessNativeTool, @@ -119,7 +127,7 @@ interface LaunchState { workspaceId?: string | null; } -type Method = "harness" | "zip_runtime" | "container"; +type Method = "harness" | "zip_runtime" | "container" | "byoc"; /** * A system-managed preset opened in the shared editor (Create → SYSTEM PRESETS → @@ -161,12 +169,19 @@ const MODEL_SOURCE_BY_METHOD: Record = { harness: DEFAULT_MODEL_SOURCE, container: CLAUDE_SDK_MODEL_SOURCE, zip_runtime: DEFAULT_MODEL_SOURCE, + // BYOC deploys the member's own code, but spec.model_id still scopes the + // execution role's bedrock:InvokeModel and reaches the runtime as env MODEL_ID. + byoc: DEFAULT_MODEL_SOURCE, }; // A2A zip agents render from a different template that has no Mantle branch, so // they stay on the Converse path regardless of the method default. const A2A_MODEL_SOURCE: ModelSource = "bedrock"; +// byoc allowed-models cap — mirrors `BYOC_ALLOWED_MODELS_MAX` (backend +// `app/schemas/agent.py`); the bound is IAM policy size, not the catalog. +const BYOC_MODELS_MAX = 20; + // The single member of the "Other Agent SDK" category (the container method). // Selected by default and, for now, the only selectable value. const DEFAULT_AGENT_SDK: AgentSdk = "claude_agent_sdk"; @@ -205,6 +220,7 @@ interface StoredSpec { efs?: { access_point_arn?: string; mount_path?: string }[]; }; network?: { subnets?: string[]; security_groups?: string[] }; + byoc?: ByocConfigInput; } interface MountRow { @@ -283,13 +299,27 @@ const mergeDiscoveryRows = ( return rows.sort((a, b) => rowName(a).localeCompare(rowName(b))); }; -export function CreateAgent() { - const [params] = useSearchParams(); +/** + * Agent management is one module on five routes (since 2026-09-18; `/create` + * redirects here): + * list `/agents` — the landing page: stats, presets, the table + * new `/agents/new` — the 3-step wizard as a page of its own + * import `/agents/import` — discovery of existing Runtime/Harness resources + * detail `/agents/:agentId` — the step-3 view of one agent (live while deploying) + * edit `/agents/:agentId/edit` — the wizard preloaded for a re-publish + * The wizard keeps its state machine; the mode only decides what step 1 shows + * and where "back"/"done" go. + */ +export type AgentsMode = "list" | "new" | "import" | "detail" | "edit"; + +export function CreateAgent({ mode }: { mode: AgentsMode }) { + const { agentId } = useParams(); // Members reach the whole module: the list, details and the discovery scan // are reads. Each mutating action gates itself on the caller's granted // agent-management permissions (default granted, revocable per user in the // Users console — mirrors route_policy's perm:agents.*). - return params.get("view") === "discover" ? : ; + if (mode === "import") return ; + return ; } function RuntimeDiscovery() { @@ -470,7 +500,7 @@ function RuntimeDiscovery() { meta={region ? t("create.discovery.region", { region }) : undefined} />
- navigate("/create")}> + navigate("/agents")}> @@ -717,7 +747,7 @@ function HarnessRow({ {t("create.discovery.reimportHint")} ) : harness.managed_agent_id ? ( - +
-
+
+ navigate("/agents/import")}> +
+ {/* System presets install from the create page (below the method cards), + as they did before the list split — configure opens the shared editor + on step 2 right here; details go to the agent's page. */}
{ - const target = agents.find((a) => a.id === agentId); - if (target) { - openDetails(target); - return; - } - // the list may lag the panel's own poll — read the row directly - void api - .getAgent(agentId) - .then((fresh) => { - openDetails({ ...fresh, deployment: fresh.deployments?.[0] }); - reloadAgents(); - }) - .catch((err) => { - toast(err instanceof ApiError ? t(`apiErrors.${err.code}`, err.message) : String(err)); - }); - }} + onDetails={(id) => navigate(`/agents/${id}`)} /> + + )} + + {step === 1 && isList && ( + <>
navigate(`/agents/${a.id}`)} onDelete={(a) => setConfirm({ kind: "delete", @@ -1928,6 +2209,7 @@ const deployLock = !canDeploy }) } onConvert={(id, name) => setConfirm({ kind: "convert", id, name })} + onCreate={canDeploy ? () => navigate("/agents/new") : undefined} /> )} @@ -1946,7 +2228,9 @@ const deployLock = !canDeploy ? "create.configure.title" : method === "container" ? "create.configure.titleContainer" - : "create.configure.titleZip", + : method === "byoc" + ? "create.configure.titleByoc" + : "create.configure.titleZip", ) } sub={ @@ -1993,6 +2277,293 @@ const deployLock = !canDeploy placeholder="hr-assistant-v3" />
+ {method === "byoc" && !systemEdit && ( + <> +
+ +
+ {(["code_zip", "container_source", "container_image"] as const).map( + (kind) => ( + + ), + )} +
+
+ {byocKind !== "container_image" && ( +
+ + { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void uploadByocZip(file); + }} + /> +
byocFileRef.current?.click()} + onDragOver={(e) => e.preventDefault()} + onDrop={(e) => { + e.preventDefault(); + const file = Array.from(e.dataTransfer.files).find((f) => + f.name.toLowerCase().endsWith(".zip"), + ); + if (file) void uploadByocZip(file); + }} + > + {byocUploading ? ( + {t("create.configure.byocUploading")} + ) : byocUpload ? ( + + {byocUpload.original_filename} ·{" "} + {(byocUpload.size_bytes / 1e6).toFixed(1)}MB · sha256{" "} + {byocUpload.sha256.slice(0, 12)}… ({byocUpload.entries_count}{" "} + {t("create.configure.byocEntries")}) + + ) : ( + {t("create.configure.byocDrop")} + )} +
+ {byocUpload && byocKind === "code_zip" && + !byocUpload.detected.agentcore_sdk_detected && ( +
+ [!] + {t("create.configure.byocNoSdkWarn")} +
+ )} + {byocUpload && byocKind === "container_source" && + !byocUpload.detected.has_dockerfile && ( +
+ [!] + {t("create.configure.byocNoDockerfileWarn")} +
+ )} + {byocUpload && byocKind === "code_zip" && byocInstallReqs && + byocUpload.detected.requirements && + byocUpload.detected.requirements.status !== "skipped" && ( + byocUpload.detected.requirements.status === "ok" ? ( +
+ + + {t("create.configure.byocReqsOk", { + count: byocUpload.detected.requirements.package_count ?? 0, + })} + +
+ ) : ( +
+ [✗] + + {t("create.configure.byocReqsFailed")}{" "} + + {byocUpload.detected.requirements.error} + + +
+ ) + )} +
+ )} + {byocKind === "container_image" && ( +
+ + setByocImageUri(e.target.value)} + placeholder="123456789012.dkr.ecr.us-west-2.amazonaws.com/my-agents:v1" + /> +
+ {t("create.configure.byocImageHint")} +
+
+ )} + {byocKind === "code_zip" && ( +
+
+ + {byocUpload && byocUpload.detected.entrypoint_candidates.length > 0 ? ( + + ) : ( + setByocEntrypoint(e.target.value)} + placeholder="main.py" + /> + )} +
+
+ + +
+
+ )} + {byocKind === "code_zip" && ( +
+
+ + +
+
+ )} +
+ + setByocDescription(e.target.value)} + placeholder={t("create.configure.byocDescriptionPlaceholder")} + /> +
+
+ + {byocEnvRows.map((row, i) => ( +
+ + setByocEnvRows((prev) => + prev.map((r, j) => (j === i ? { ...r, key: e.target.value } : r)), + ) + } + /> + + setByocEnvRows((prev) => + prev.map((r, j) => (j === i ? { ...r, value: e.target.value } : r)), + ) + } + /> + + setByocEnvRows((prev) => prev.filter((_, j) => j !== i)) + } + > + ✕ + +
+ ))} + setByocEnvRows((prev) => [...prev, { key: "", value: "" }])} + > + + {t("create.configure.byocEnvAdd")} + +
+
+ + {byocContractOpen && ( +
+ [i] + + {t("create.configure.byocContractBody")} + +
+ )} +
+ + )} {/* The container method is the "Other Agent SDK" entrance: it picks an SDK here instead of a model source. The two blocks are one choice seen from either side — the Claude Agent SDK can only drive Claude @@ -2055,53 +2626,160 @@ const deployLock = !canDeploy
)} -
- - { + const picked = e.target.value; + if (picked === CUSTOM_MODEL_OPTION) { + setCustomModel(true); + return; + } + setCustomModel(false); + setModelId(picked); + }} + > + {modelOptionsFor(modelSource, method === "container").map((option) => ( + + ))} + + + {customModel && ( + setModelId(e.target.value)} + placeholder={t("create.configure.modelCustomPlaceholder")} + /> + )} +
+ )} + {method === "byoc" && ( +
+ + {byocModels.map((model, i) => ( +
+ + {model} + {i === 0 && ( + · {t("create.configure.byocModelPrimary")} + )} + + {i > 0 && ( + + setByocModels((prev) => [ + prev[i], + ...prev.filter((_, j) => j !== i), + ]) + } + > + {t("create.configure.byocModelMakePrimary")} + + )} + {byocModels.length > 1 && ( + setByocModels((prev) => prev.filter((_, j) => j !== i))} + > + ✕ + + )} +
))} - - - {customModel && ( - setModelId(e.target.value)} - placeholder={t("create.configure.modelCustomPlaceholder")} - /> - )} -
+ + {byocModelCustomOpen && ( +
+ setByocModelDraft(e.target.value)} + placeholder={t("create.configure.modelCustomPlaceholder")} + /> + { + const id = byocModelDraft.trim(); + if (!id || byocModels.length >= BYOC_MODELS_MAX) return; + setByocModels((prev) => (prev.includes(id) ? prev : [...prev, id])); + setByocModelDraft(""); + setByocModelCustomOpen(false); + }} + > + + {t("create.configure.byocModelAddCustom")} + +
+ )} +
+ [i] + {t("create.configure.byocModelsHint")} +
+ + )} {/* Harness inference / loop knobs. Per-call output ceiling and reasoning effort map onto bedrockModelConfig; the loop bounds onto maxIterations / timeoutSeconds. Stored values round-trip untouched. */} @@ -2195,6 +2873,7 @@ const deployLock = !canDeploy {t("create.system.settings.loopNote")} )} + {method !== "byoc" && (
+ )} {/* A preset's capabilities are catalogue-owned: shown, never edited, and no skill upload/import or tool attachment is offered for it. */} {systemEdit && ( @@ -2252,7 +2932,7 @@ const deployLock = !canDeploy )} - {!systemEdit && ( + {!systemEdit && method !== "byoc" && (
)} - {!systemEdit && ( + {!systemEdit && method !== "byoc" && (
@@ -2663,6 +3343,7 @@ const deployLock = !canDeploy )}
)} + {method !== "byoc" && (
+ )} {method === "container" && (
@@ -2856,6 +3538,8 @@ const deployLock = !canDeploy )} {!systemEdit && ( <> + {method !== "byoc" && ( + <>
@@ -2910,6 +3594,8 @@ const deployLock = !canDeploy
)} + + )}
@@ -2987,6 +3673,10 @@ const deployLock = !canDeploy disabled={submitting} disabledReason={submitting ? t("create.system.settings.saving") : undefined} onClick={() => { + if (mode === "edit") { + navigate("/agents"); + return; + } setStep(1); resetForm(); }} @@ -3038,6 +3728,10 @@ const deployLock = !canDeploy agentStatus={agentStatus} detailsMode={detailsMode} onRestart={() => { + if (!isList) { + navigate("/agents"); + return; + } setStep(1); setLaunch(null); setDeployment(null); @@ -3062,10 +3756,110 @@ const deployLock = !canDeploy )} {detailsMode && launch && ( <> + {mode === "detail" && ( +
+ {(() => { + const row = agents.find((a) => a.id === launch.agentId); + return ( + <> + {row?.invoke_capability.eligible && ( + + {t("agents.detail.chat")} + + )} + + {t("agents.detail.observability")} + + {row && row.method !== "discovered_runtime" && !row.system && ( + + {t("create.list.edit")} + + )} + + ); + })()} +
+ )}
)} + {detailsMode && detailByoc && ( + <> +
+ +
+ {t("create.list.byocKind")} + {detailByoc.artifact_kind} +
+ {detailByocModels.length > 0 && ( +
+ {t("create.list.byocModels")} + + {detailByocModels.map((model, i) => ( + + {model} + {i === 0 && ( + + {" "} + · {t("create.list.byocModelPrimary")} + + )} + + ))} + +
+ )} + {detailByoc.image_uri && ( +
+ {t("create.list.byocImage")} + + {detailByoc.image_uri} + +
+ )} + {detailByoc.artifact_kind === "code_zip" && ( +
+ {t("create.list.byocEntrypoint")} + + {detailByoc.entrypoint ?? "main.py"} ·{" "} + {(detailByoc.python_version ?? "PYTHON_3_13") + .replace("PYTHON_", "Python ") + .replace("_", ".")} + +
+ )} + {detailByoc.provenance?.sha256 && ( +
+ sha256 + {detailByoc.provenance.sha256.slice(0, 16)}… +
+ )} + {(detailByoc.provenance?.size_bytes ?? 0) > 0 && ( +
+ {t("create.list.byocSize")} + + {((detailByoc.provenance?.size_bytes ?? 0) / 1e6).toFixed(1)}MB + {detailByoc.provenance?.original_filename + ? ` · ${detailByoc.provenance.original_filename}` + : ""} + +
+ )} + {detailByoc.provenance?.uploaded_by && ( +
+ {t("create.list.byocUploadedBy")} + + {detailByoc.provenance.uploaded_by} + {detailByoc.provenance.uploaded_at + ? ` · ${detailByoc.provenance.uploaded_at}` + : ""} + +
+ )} +
+ + )} {detailsMode && detailConversion && ( <>
@@ -3170,18 +3964,114 @@ const STATUS_TONE: Record = { failed: "crit", }; +/** "3 min ago"-style label for the UPDATED column; the absolute stamp rides on `title`. */ +function relativeTime(iso: string | null | undefined, t: TFunction) { + if (!iso) return "—"; + const then = Date.parse(iso.endsWith("Z") || /[+-]\d\d:\d\d$/.test(iso) ? iso : `${iso}Z`); + if (Number.isNaN(then)) return iso.replace("T", " ").slice(0, 16); + const s = Math.max(0, Math.round((Date.now() - then) / 1000)); + if (s < 45) return t("agents.time.justNow"); + const m = Math.round(s / 60); + if (m < 60) return t("agents.time.minutes", { count: m }); + const h = Math.round(m / 60); + if (h < 24) return t("agents.time.hours", { count: h }); + const d = Math.round(h / 24); + if (d < 30) return t("agents.time.days", { count: d }); + return iso.replace("T", " ").slice(0, 10); +} + +/** The "···" per-row menu: Edit / Convert / Delete live here so a row shows two + * buttons. The pop-over is portalled to with fixed coordinates: the + * table sits in `.table-scroll` (overflow-x:auto ⇒ overflow-y clips too), so an + * in-flow absolute menu on the last row was cut off (reported 2026-09-18). It + * opens upward when there is no room below. */ +function RowMenu({ name, children }: { name: string; children: ReactNode }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const [pos, setPos] = useState<{ top: number; right: number } | null>(null); + const btn = useRef(null); + const pop = useRef(null); + const MENU_H = 140; // generous upper bound: 3 items + padding + const place = () => { + const r = btn.current?.getBoundingClientRect(); + if (!r) return; + const below = window.innerHeight - r.bottom; + setPos({ + top: below >= MENU_H ? r.bottom + 4 : Math.max(8, r.top - 4 - MENU_H), + right: Math.max(8, window.innerWidth - r.right), + }); + }; + useEffect(() => { + if (!open) return; + const onDoc = (e: MouseEvent) => { + const target = e.target as Node; + if (btn.current?.contains(target) || pop.current?.contains(target)) return; + setOpen(false); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setOpen(false); + }; + const onMove = () => place(); + document.addEventListener("mousedown", onDoc); + document.addEventListener("keydown", onKey); + window.addEventListener("scroll", onMove, true); + window.addEventListener("resize", onMove); + return () => { + document.removeEventListener("mousedown", onDoc); + document.removeEventListener("keydown", onKey); + window.removeEventListener("scroll", onMove, true); + window.removeEventListener("resize", onMove); + }; + }, [open]); + return ( +
+ + {open && + pos && + createPortal( +
setOpen(false)} + > + {children} +
, + document.body, + )} +
+ ); +} + function AgentList({ agents, onEdit, onDetails, onDelete, onConvert, + onCreate, }: { agents: AgentInfo[]; onEdit: (a: AgentInfo) => void; onDetails: (a: AgentInfo) => void; onDelete: (a: AgentInfo) => void; onConvert: (id: string, name: string) => void; + onCreate?: () => void; }) { const { t } = useTranslation(); const { can, isAdmin } = useAuth(); @@ -3268,7 +4158,13 @@ function AgentList({
- {a.name} + {a.deployment ? ( + + {a.name} + + ) : ( + a.name + )} {a.system && ( - - {t(`status.${a.status}`, a.status.toUpperCase())} - - - - {a.method === "discovered_runtime" ? `v${a.version ?? "—"}` : (a.revision ?? "—")} - - {(a.updated_at ?? "").replace("T", " ").slice(0, 16)} - -
- {a.method !== "discovered_runtime" && ( +
+ + {t(`status.${a.status}`, a.status.toUpperCase())} + + {a.status === "failed" && a.deployment && ( )} +
+ + + {a.method === "discovered_runtime" ? `v${a.version ?? "—"}` : (a.revision ?? "—")} + + + {relativeTime(a.updated_at, t)} + + +
{a.invoke_capability.eligible && ( {t("create.list.chat")} )} - {a.method === "harness" && a.status === "active" && ( - - )} {a.deployment && ( )} - )} - + {a.method === "harness" && a.status === "active" && ( + + )} + +
@@ -3369,7 +4283,19 @@ function AgentList({ {rows.length === 0 && ( - {t(agents.length ? "create.list.noMatch" : "create.list.empty")} + {agents.length ? ( + t("create.list.noMatch") + ) : ( +
+ {t("agents.empty.title")} + {t("create.list.empty")} + {onCreate && ( + + + {t("agents.empty.cta")} + + )} +
+ )} )} diff --git a/frontend/src/pages/CreateAgentAssistant.tsx b/frontend/src/pages/CreateAgentAssistant.tsx index 4de5bf45..552dd633 100644 --- a/frontend/src/pages/CreateAgentAssistant.tsx +++ b/frontend/src/pages/CreateAgentAssistant.tsx @@ -961,7 +961,7 @@ export function CreateAgentAssistant() { description={t("assistantPage.description")} />
- + {t("assistantPage.backToCreate")}
@@ -1020,7 +1020,7 @@ export function CreateAgentAssistant() {
navigate("/create")} + onClick={() => navigate("/agents")} data-testid="assistant-go-presets" > {t("assistantPage.goToPresets")} @@ -2251,7 +2251,7 @@ function Outcome({
{approval.agent_id && ( - + {t("assistantPage.openAgent")} )} diff --git a/frontend/src/pages/CreateAgentStudio.tsx b/frontend/src/pages/CreateAgentStudio.tsx index 3b85f532..b9556163 100644 --- a/frontend/src/pages/CreateAgentStudio.tsx +++ b/frontend/src/pages/CreateAgentStudio.tsx @@ -206,7 +206,7 @@ export function CreateAgentStudio() { if (cancelled) return; if (agent.method !== "studio") { toast(t("studio.toast.notStudioAgent")); - navigate("/create"); + navigate("/agents"); return; } setEditAgent(agent); @@ -229,7 +229,7 @@ export function CreateAgentStudio() { .catch(() => { if (cancelled) return; toast(t("studio.toast.loadFailed")); - navigate("/create"); + navigate("/agents"); }); return () => { cancelled = true; @@ -423,7 +423,7 @@ export function CreateAgentStudio() { job={job} agentStatus={agentStatus} detailsMode={false} - onRestart={() => navigate("/create")} + onRestart={() => navigate("/agents")} /> {agentStatus === "active" && ( <> @@ -433,7 +433,7 @@ export function CreateAgentStudio() { {t("studio.published.openChat")} ▸ - + {t("studio.published.backToAgents")}
@@ -476,7 +476,7 @@ export function CreateAgentStudio() { flexWrap: "wrap", }} > - + ◂ {t("studio.toolbar.agents")} {editing && ( diff --git a/frontend/src/pages/EvaluationOnline.tsx b/frontend/src/pages/EvaluationOnline.tsx index 731d7ab1..6e929b98 100644 --- a/frontend/src/pages/EvaluationOnline.tsx +++ b/frontend/src/pages/EvaluationOnline.tsx @@ -95,6 +95,7 @@ const ELIGIBLE_METHODS = new Set([ "studio", "container", "harness", + "byoc", ]); const TRANSIENT = new Set(["CREATING", "UPDATING", "DELETING"]); const FAILED = new Set(["CREATE_FAILED", "UPDATE_FAILED", "ERROR"]); diff --git a/frontend/src/pages/Overview.tsx b/frontend/src/pages/Overview.tsx index 3355bf5c..066dc914 100644 --- a/frontend/src/pages/Overview.tsx +++ b/frontend/src/pages/Overview.tsx @@ -299,7 +299,7 @@ export function Overview() { {t("common.loading")} ) : ( - + {t("overview.feed.empty")} ) diff --git a/frontend/src/pages/Registry.tsx b/frontend/src/pages/Registry.tsx index 6bda5d4a..9db44426 100644 --- a/frontend/src/pages/Registry.tsx +++ b/frontend/src/pages/Registry.tsx @@ -409,7 +409,7 @@ export function Registry() { const openInWizard = (record: RegistryRecord) => { if (record.type === "MCP") { - navigate(`/create?gateway=${encodeURIComponent(record.name)}`); + navigate(`/agents/new?gateway=${encodeURIComponent(record.name)}`); return; } if (record.type === "AGENT_SKILLS") { @@ -425,7 +425,7 @@ export function Registry() { } catch { /* fall back to the record name */ } - navigate(`/create?skill=${encodeURIComponent(path)}`); + navigate(`/agents/new?skill=${encodeURIComponent(path)}`); } }; diff --git a/frontend/src/pages/assistant/CreationProgress.tsx b/frontend/src/pages/assistant/CreationProgress.tsx index be0b32bb..2dd88b41 100644 --- a/frontend/src/pages/assistant/CreationProgress.tsx +++ b/frontend/src/pages/assistant/CreationProgress.tsx @@ -77,7 +77,7 @@ export function CreationProgress({
{t(`assistantProgress.hints.${hint}`)} - {approved && {t("assistantProgress.manage")}} + {approved && {t("assistantProgress.manage")}}
{t("assistantProgress.later")} diff --git a/frontend/src/pages/assistant/PreparationPanel.tsx b/frontend/src/pages/assistant/PreparationPanel.tsx index 140a1732..b9175198 100644 --- a/frontend/src/pages/assistant/PreparationPanel.tsx +++ b/frontend/src/pages/assistant/PreparationPanel.tsx @@ -221,7 +221,7 @@ export function PreparationPanel({
{t(locked ? "assistantPreparation.lockedTitle" : "assistantPreparation.beforeCreateTitle")}

{t(locked ? "assistantPreparation.lockedHint" : "assistantPreparation.beforeCreateHint")}

- {locked && + {locked && {t("assistantPreparation.manageAgent")} }
diff --git a/frontend/src/theme/app.css b/frontend/src/theme/app.css index 3b287038..507df6ee 100644 --- a/frontend/src/theme/app.css +++ b/frontend/src/theme/app.css @@ -1042,3 +1042,27 @@ footer .sep{color:var(--line-2)} .fishbone-details{margin-top:10px} .fishbone-details summary{cursor:pointer;font-size:10.5px;letter-spacing:.1em} .fishbone-details table{margin-top:8px} + +/* Agent management — list landing (/agents) and wizard/detail breadcrumb */ +.agents-head{display:flex;align-items:flex-start;justify-content:space-between;gap:16px;flex-wrap:wrap} +.agents-head .vhead{flex:1 1 320px;min-width:0} +.agents-head-actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding-top:6px} +.agents-head-actions .btn{display:inline-flex;align-items:center;gap:6px} +.agents-crumb{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--ink-3);margin-bottom:10px} +.agents-crumb a{color:var(--ink-2);text-decoration:none} +.agents-crumb a:hover{color:var(--amber)} +.agent-name-link{color:inherit;text-decoration:none;border-bottom:1px dotted var(--line-2)} +.agent-name-link:hover{color:var(--amber);border-bottom-color:var(--amber)} +.rowmenu{position:relative;display:inline-block} +.rowmenu-pop{z-index:60;min-width:150px;background:var(--panel);border:1px solid var(--line-2);box-shadow:0 12px 32px -12px rgba(0,0,0,.6);display:flex;flex-direction:column;padding:4px} +.rowmenu-item{font-family:var(--mono);font-size:10px;letter-spacing:.08em;text-align:left;color:var(--ink);background:transparent;border:0;padding:7px 10px;cursor:pointer;white-space:nowrap} +.rowmenu-item:hover:not(:disabled){background:var(--amber-soft);color:var(--amber)} +.rowmenu-item:disabled{opacity:.35;cursor:not-allowed} +.rowmenu-item.danger:hover:not(:disabled){color:var(--red,#ff6b6b)} +.agents-empty{display:flex;flex-direction:column;align-items:center;gap:10px;padding:28px 0} +.agents-empty b{font-family:var(--sans,inherit);font-size:14px;letter-spacing:0;text-transform:none;color:var(--ink)} +.agents-detail-actions{display:flex;gap:10px;flex-wrap:wrap;margin-top:14px} +.agents-detail-actions .btn{text-decoration:none} +/* method cards: equal height, spec list pinned to the bottom so the four align */ +.method{display:flex;flex-direction:column} +.method .m-specs{margin-top:auto} diff --git a/infra/spoke/launchpad-workspace-role.yaml b/infra/spoke/launchpad-workspace-role.yaml index 8f4a3b01..b6db3fb0 100644 --- a/infra/spoke/launchpad-workspace-role.yaml +++ b/infra/spoke/launchpad-workspace-role.yaml @@ -247,6 +247,18 @@ Resources: - ecr:DescribeRepositories Resource: "*" + # BYOC `container_image` deploys an image the member already pushed + # to ANY private repository of this account (the deployer verifies + # it with DescribeImages before CreateAgentRuntime and refuses other + # accounts / public registries). Read-only; the agent's own + # execution role receives the pull grant, scoped to that repository. + - Sid: EcrDescribeImagesForByoc + Effect: Allow + Action: + - ecr:DescribeImages + Resource: + Fn::Sub: "arn:${AWS::Partition}:ecr:*:${AWS::AccountId}:repository/*" + # ── CodeBuild ────────────────────────────────────────────────── - Sid: CodeBuildLaunchpad Effect: Allow diff --git a/samples/byoc/README.md b/samples/byoc/README.md new file mode 100644 index 00000000..f9709e95 --- /dev/null +++ b/samples/byoc/README.md @@ -0,0 +1,74 @@ +# BYOC samples — Bring Your Own Code + +Two minimal agents that satisfy the Launchpad BYOC runtime contract: + +- ARM64 (aarch64) · port **8080** · `POST /invocations` + `GET /ping` +- invoke payload: `{"prompt": "...", "actor_id": "..."}` +- response: any JSON (or SSE). `{"result": "..."}` is the convention the + samples follow; `response` / `answer` / `output` / `text` / `message` / + `content` are read too, a body with none of them is shown verbatim as JSON, + and `{"error": "..."}` renders as a failed turn. +- the `bedrock-agentcore` SDK (`BedrockAgentCoreApp` + `@app.entrypoint`) + implements all of the above. + +Both call Bedrock Converse with the model id from env `MODEL_ID` +(default `us.anthropic.claude-3-5-haiku-20241022-v1:0`) and fall back to an +echo when the account has no model access, so they deploy and chat regardless. + +Launchpad sets `MODEL_ID` for you: the agent's execution role may invoke only +the models in the wizard's **Allowed models** list (`spec.byoc.allowed_models` +via the API; without the list, just `spec.model_id`). The deployer injects the +primary (first) model as env `MODEL_ID` and the full list as env +`ALLOWED_MODEL_IDS` (comma-separated) unless you set them yourself — so these +samples always call a permitted model with no extra configuration. An agent +that switches models at runtime should pick from `ALLOWED_MODEL_IDS`. + +## hello-http — artifact kind `code_zip` + +Python source + `requirements.txt`; Launchpad resolves the requirements for +linux/aarch64 at deploy time and runs the zip on the managed Python runtime. + +**requirements.txt guidance.** List direct index dependencies only; version +pins are optional — Launchpad compiles the file into a hashed lock +(`requirements.lock`, shipped in the artifact), which is what makes the build +reproducible. The pip file format is honoured (continuations, comments, +environment markers), but `--hash=` options are dropped: the platform re-locks +against its own deploy target — linux/aarch64, `manylinux_2_28` by default (the +runtime is Amazon Linux 2023 / glibc 2.34, measured 2026-09-18; the docs' +`manylinux2014` remains the conservative fallback via +`LAUNCHPAD_RUNTIME_PYTHON_PLATFORM`) — and generates fresh hashes. Refused, so +the file cannot reach outside the platform's package index: `-r`/`-c` includes, +editable installs, local paths, direct URL/VCS entries, and +`--index-url`/`--extra-index-url`/`--find-links`. Source builds never run: a +dependency with no compatible aarch64 wheel fails with the package named — pin +a release that ships one, switch to the `container_source` path, or vendor the +packages in the zip and set `install_requirements=false`. The upload response +(`detected.requirements`) reports the resolve verdict before you deploy. + +```bash +cd samples/byoc/hello-http +zip -r ../hello-http.zip . # zip the directory CONTENTS +# — or zip the directory itself; Launchpad normalizes a single top-level dir: +cd samples/byoc && zip -r hello-http.zip hello-http/ +``` + +Upload the zip in **Create → Bring Your Own Code → Code zip** (entrypoint +`main.py`), or via the API — see `docs/api.md` (`POST /api/agents/uploads`). + +## hello-container — artifact kind `container_source` + +The same agent with a `Dockerfile`; Launchpad builds it ARM64 on CodeBuild, +pushes to ECR and deploys the image. + +```bash +cd samples/byoc +zip -r hello-container.zip hello-container/ +``` + +Upload in **Create → Bring Your Own Code → Dockerfile build**. + +## artifact kind `container_image` + +No sample needed — push any image satisfying the contract to a private ECR +repository in the workspace account/region and paste its URI +(`.dkr.ecr..amazonaws.com/:`). diff --git a/samples/byoc/hello-container/Dockerfile b/samples/byoc/hello-container/Dockerfile new file mode 100644 index 00000000..bf9decf9 --- /dev/null +++ b/samples/byoc/hello-container/Dockerfile @@ -0,0 +1,13 @@ +# hello-container — the same agent as hello-http, packaged as a container. +# AgentCore Runtime containers run linux/arm64; Launchpad's CodeBuild project +# builds with --platform linux/arm64, so this base needs no platform pin here. +FROM python:3.13-slim + +WORKDIR /app +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt +COPY main.py . + +# Runtime contract: POST /invocations + GET /ping on :8080 +EXPOSE 8080 +CMD ["python", "main.py"] diff --git a/samples/byoc/hello-container/main.py b/samples/byoc/hello-container/main.py new file mode 100644 index 00000000..708216c7 --- /dev/null +++ b/samples/byoc/hello-container/main.py @@ -0,0 +1,42 @@ +"""hello-http — minimal BYOC agent for AgentCore Launchpad (code_zip kind). + +Satisfies the Launchpad invoke contract with the bedrock-agentcore SDK: +`BedrockAgentCoreApp` serves POST /invocations + GET /ping on :8080, and the +`@app.entrypoint` receives the payload Launchpad sends — +``{"prompt": "...", "actor_id": "..."}``. + +Answers with Bedrock Converse (model id from env ``MODEL_ID``); falls back to a +plain echo when the account has no access to the model, so the sample deploys +and chats even before any model access is granted. +""" + +import os + +import boto3 +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-3-5-haiku-20241022-v1:0") + +app = BedrockAgentCoreApp() + + +@app.entrypoint +def invoke(payload): + prompt = str(payload.get("prompt", "")) + try: + bedrock = boto3.client("bedrock-runtime") + response = bedrock.converse( + modelId=MODEL_ID, + messages=[{"role": "user", "content": [{"text": prompt}]}], + ) + text = "".join( + block.get("text", "") + for block in response["output"]["message"]["content"] + ) + return {"result": text} + except Exception as exc: # no model access / throttling — echo instead of 500 + return {"result": f"[echo — model unavailable: {type(exc).__name__}] {prompt}"} + + +if __name__ == "__main__": + app.run() diff --git a/samples/byoc/hello-container/requirements.txt b/samples/byoc/hello-container/requirements.txt new file mode 100644 index 00000000..aba38047 --- /dev/null +++ b/samples/byoc/hello-container/requirements.txt @@ -0,0 +1,2 @@ +bedrock-agentcore==1.17.0 +boto3==1.43.83 diff --git a/samples/byoc/hello-http/main.py b/samples/byoc/hello-http/main.py new file mode 100644 index 00000000..708216c7 --- /dev/null +++ b/samples/byoc/hello-http/main.py @@ -0,0 +1,42 @@ +"""hello-http — minimal BYOC agent for AgentCore Launchpad (code_zip kind). + +Satisfies the Launchpad invoke contract with the bedrock-agentcore SDK: +`BedrockAgentCoreApp` serves POST /invocations + GET /ping on :8080, and the +`@app.entrypoint` receives the payload Launchpad sends — +``{"prompt": "...", "actor_id": "..."}``. + +Answers with Bedrock Converse (model id from env ``MODEL_ID``); falls back to a +plain echo when the account has no access to the model, so the sample deploys +and chats even before any model access is granted. +""" + +import os + +import boto3 +from bedrock_agentcore.runtime import BedrockAgentCoreApp + +MODEL_ID = os.environ.get("MODEL_ID", "us.anthropic.claude-3-5-haiku-20241022-v1:0") + +app = BedrockAgentCoreApp() + + +@app.entrypoint +def invoke(payload): + prompt = str(payload.get("prompt", "")) + try: + bedrock = boto3.client("bedrock-runtime") + response = bedrock.converse( + modelId=MODEL_ID, + messages=[{"role": "user", "content": [{"text": prompt}]}], + ) + text = "".join( + block.get("text", "") + for block in response["output"]["message"]["content"] + ) + return {"result": text} + except Exception as exc: # no model access / throttling — echo instead of 500 + return {"result": f"[echo — model unavailable: {type(exc).__name__}] {prompt}"} + + +if __name__ == "__main__": + app.run() diff --git a/samples/byoc/hello-http/requirements.txt b/samples/byoc/hello-http/requirements.txt new file mode 100644 index 00000000..aba38047 --- /dev/null +++ b/samples/byoc/hello-http/requirements.txt @@ -0,0 +1,2 @@ +bedrock-agentcore==1.17.0 +boto3==1.43.83