From 23e0016a02631b513a297d7d68e112f12878aba2 Mon Sep 17 00:00:00 2001 From: alexwuu Date: Fri, 18 Sep 2026 14:11:37 +0000 Subject: [PATCH 1/5] feat(byoc): Bring Your Own Code creation method MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fourth wizard card deploys code the member's developers wrote themselves, already wrapped with the AgentCore SDK (BedrockAgentCoreApp) or any HTTP server satisfying the Runtime contract (ARM64, :8080, POST /invocations + GET /ping). One spec.byoc block, three artifact kinds: - code_zip zip of Python source staged via POST /api/agents/uploads, resolved for linux/aarch64 and deployed as a direct-code Runtime (codeConfiguration, member's Python version + entrypoint) - container_source zip carrying a Dockerfile, built by the shared launchpad-agent-builder CodeBuild project (the platform buildspec is injected into the zip) -> ECR -> Runtime - container_image an existing image in this account's private ECR, verified with DescribeImages, deployed as-is Security model: developers need no IAM — uploads and deploys carry perm:agents.deploy. Each agent gets its own least-privilege execution role; bedrock:InvokeModel covers exactly spec.byoc.allowed_models (1–20 ids, primary injected as env MODEL_ID, full list as ALLOWED_MODEL_IDS). Uploads are workspace-scoped under byoc/{workspace_id}/{upload_id}/ with provenance stamped server-side. The spoke role gains read-only ecr:DescribeImages on the account's repositories for container_image. Wizard: upload with detected entrypoints / requirements / SDK markers, allowed-models list, BYOC provenance on the detail view. Samples under samples/byoc (hello-http, hello-container), lab chapter 13, architecture and API docs (en + zh-CN). --- AGENTS.md | 10 +- CLAUDE.md | 10 +- README.md | 14 +- README.zh-CN.md | 14 +- backend/app/core/route_policy.py | 3 + backend/app/deployer/byoc.py | 466 ++++++++ backend/app/deployer/container.py | 43 +- backend/app/evaluation/service.py | 4 +- backend/app/main.py | 6 +- backend/app/models/ledger.py | 2 +- backend/app/optimization/service.py | 22 +- backend/app/routers/agents.py | 82 +- backend/app/schemas/agent.py | 182 ++- backend/app/services/agent_iam.py | 30 +- backend/app/services/agent_versions.py | 2 +- backend/app/services/agentcore/runtime.py | 44 +- backend/app/services/byoc_uploads.py | 316 ++++++ backend/app/services/invoke.py | 11 +- backend/app/services/observability.py | 4 +- backend/tests/test_a2a_demo.py | 5 +- backend/tests/test_agent_iam_policy.py | 79 ++ backend/tests/test_byoc.py | 1007 +++++++++++++++++ docs/api.md | 41 + docs/api.zh-CN.md | 38 + docs/architecture.md | 70 +- docs/architecture.zh-CN.md | 63 +- docs/lab/13-byoc.md | 83 ++ docs/lab/README.md | 1 + frontend/src/components/methodChipMeta.ts | 1 + frontend/src/lib/api.ts | 59 +- frontend/src/locales/en/common.json | 58 +- frontend/src/locales/zh-CN/common.json | 58 +- frontend/src/pages/CreateAgent.tsx | 742 +++++++++++- frontend/src/pages/EvaluationOnline.tsx | 1 + infra/spoke/launchpad-workspace-role.yaml | 12 + samples/byoc/README.md | 53 + samples/byoc/hello-container/Dockerfile | 13 + samples/byoc/hello-container/main.py | 42 + samples/byoc/hello-container/requirements.txt | 2 + samples/byoc/hello-http/main.py | 42 + samples/byoc/hello-http/requirements.txt | 2 + 41 files changed, 3593 insertions(+), 144 deletions(-) create mode 100644 backend/app/deployer/byoc.py create mode 100644 backend/app/services/byoc_uploads.py create mode 100644 backend/tests/test_byoc.py create mode 100644 docs/lab/13-byoc.md create mode 100644 samples/byoc/README.md create mode 100644 samples/byoc/hello-container/Dockerfile create mode 100644 samples/byoc/hello-container/main.py create mode 100644 samples/byoc/hello-container/requirements.txt create mode 100644 samples/byoc/hello-http/main.py create mode 100644 samples/byoc/hello-http/requirements.txt 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/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/deployer/byoc.py b/backend/app/deployer/byoc.py new file mode 100644 index 00000000..d3f540a0 --- /dev/null +++ b/backend/app/deployer/byoc.py @@ -0,0 +1,466 @@ +"""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.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.workspace import WorkspaceContext + +from .container import ( + _image_ref, + _recorded_digest_uri, + build_and_push_image, + platform_buildspec_path, +) +from .zip_runtime import TARGET_PIP_PLATFORM, _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 + + +def _pip_python_version(python_version: str) -> str: + """PYTHON_3_13 → 3.13 (the shape pip/uv take).""" + return python_version.removeprefix("PYTHON_").replace("_", ".") + + +def _requirements_lines(path: Path) -> list[str]: + lines = [] + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if line and not line.startswith("#"): + lines.append(line) + return lines + + +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 + lock = _compile_lock(requirements, build_dir, compile_runner or pip_runner) + 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") + pip_version = _pip_python_version(python_version) + proc = pip_runner( + [ + sys.executable, "-m", "pip", "install", + "--require-hashes", "-r", str(lock), + "-t", str(src_root), + "--platform", TARGET_PIP_PLATFORM, + "--only-binary=:all:", + "--python-version", pip_version, + "--quiet", + ], + capture_output=True, + text=True, + ) + if proc.returncode != 0: + stderr = (proc.stderr or "").strip()[-2000:] + raise RuntimeError(f"pip install failed for the zip's requirements.txt: {stderr}") + # 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/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..5cb805be 100644 --- a/backend/app/routers/agents.py +++ b/backend/app/routers/agents.py @@ -1,25 +1,31 @@ -"""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 pathlib import Path from typing import Any -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Request 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.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 +47,7 @@ router = APIRouter(prefix="/api", tags=["agents"]) -SUPPORTED_METHODS = {"harness", "zip_runtime", "container", "studio"} +SUPPORTED_METHODS = {"harness", "zip_runtime", "container", "studio", "byoc"} def _agent_out(agent: Agent, deployment: Deployment | None = None) -> dict[str, Any]: @@ -114,6 +120,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 +234,72 @@ def import_discovered_runtimes( return result +@router.post("/agents/uploads", status_code=201) +async def upload_byoc_artifact( + request: Request, + 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. + """ + identity = require_identity(request) + 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 = 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"), + ) + 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/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..6b614f4b 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, } diff --git a/backend/app/services/byoc_uploads.py b/backend/app/services/byoc_uploads.py new file mode 100644 index 00000000..72ee93af --- /dev/null +++ b/backend/app/services/byoc_uploads.py @@ -0,0 +1,316 @@ +"""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.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 +# 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 _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, + 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) + 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/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..d4e51b06 --- /dev/null +++ b/backend/tests/test_byoc.py @@ -0,0 +1,1007 @@ +"""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 + + +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_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 + install = commands[-1] + assert "--require-hashes" in install + assert "manylinux2014_aarch64" in install + assert install[install.index("--python-version") + 1] == "3.11" + assert (src / "requirements.lock").exists() + + +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/docs/api.md b/docs/api.md index bc455245..f9701914 100644 --- a/docs/api.md +++ b/docs/api.md @@ -102,6 +102,47 @@ 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` | `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}}` | +| `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.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). + +`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..5177b011 100644 --- a/docs/api.zh-CN.md +++ b/docs/api.zh-CN.md @@ -420,6 +420,44 @@ 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` | `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}}` | +| `GET` | `/api/agents/uploads/{upload_id}` | member——已存储的清单(同一形状);其他工作区的 upload_id 返回 404 | + +错误码:`byoc.invalid_upload`(400,缺少部件/非 zip/空文件)、 +`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)。 + +`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..5a7c13ba 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). @@ -230,14 +230,15 @@ enforcement, and skill *content* review. Immutable is not the same as trusted. ### Creation entrances -The `/create` picker shows four cards, in this order: +The `/create` picker shows five 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) | +| 5 | **Discover existing runtimes and harnesses** | — | not a deploy method (see below) | 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 +249,55 @@ 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"}`). 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). +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 — diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 15fdf041..4b142cd8 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)。 @@ -175,14 +175,15 @@ agent 全部卡死。而读不到的扫描——未启用扫描、API 报错、 ### 创建入口 -`/create` 的入口卡片共四张,顺序如下: +`/create` 的入口卡片共五张,顺序如下: | # | 卡片 | `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 小节 | +| 5 | **发现现有 Runtime 与 Harness** | — | 不是部署方式(见下文) | 第三张卡片是一个**类别**,而不是某一个 SDK。`AgentSpec.agent_sdk` 记录容器 Agent 打包的是哪个 SDK,向导把它作为配置步骤上的二级选项。它是只有一个成员的 @@ -191,6 +192,48 @@ 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"}`)。三种构件类型,同一个 `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 标记)。平台 +**不**审查、不扫描代码本身;`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 窗口, diff --git a/docs/lab/13-byoc.md b/docs/lab/13-byoc.md new file mode 100644 index 00000000..2863000c --- /dev/null +++ b/docs/lab/13-byoc.md @@ -0,0 +1,83 @@ +# 第 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": "..."}` | +| 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. 打开 **Create**,选第 4 张卡片 **自带代码**,点 **NEXT**。 +2. 构件类型保持 **代码 zip**;把 `hello-http.zip` 拖进上传框。 +3. 上传完成后会显示检测摘要:入口候选(`main.py`)、requirements.txt、 + AgentCore SDK 标记。若没有检测到 SDK 标记,会出现黄色提示——确认你的代码 + 自行实现了 `POST /invocations`。 +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 一致。 + +## 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/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) => { + const form = new FormData(); + form.append("file", file); + return requestForm("/api/agents/uploads", 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..43fffb9f 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,46 @@ "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.", + "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", @@ -480,7 +526,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", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 53348a11..7c0d330e 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,46 @@ "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 构建需要它。", + "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": "部署计划", @@ -480,7 +526,15 @@ "filterMethodAll": "全部创建方式", "filterStatusAll": "全部状态", "searchPlaceholder": "搜索 Agent 名称 / ID……", - "noMatch": "没有匹配当前筛选条件的 Agent" + "noMatch": "没有匹配当前筛选条件的 Agent", + "byocTitle": "BYOC 构件", + "byocKind": "类型", + "byocModels": "允许的模型", + "byocModelPrimary": "主模型 → MODEL_ID", + "byocImage": "镜像", + "byocEntrypoint": "入口", + "byocSize": "大小", + "byocUploadedBy": "上传者" }, "system": { "title": "系统预置", diff --git a/frontend/src/pages/CreateAgent.tsx b/frontend/src/pages/CreateAgent.tsx index 4873bf1b..ca9eb562 100644 --- a/frontend/src/pages/CreateAgent.tsx +++ b/frontend/src/pages/CreateAgent.tsx @@ -22,6 +22,11 @@ import { import type { AgentInfo, AgentSdk, + AgentSpecInput, + ByocArtifactKind, + ByocConfigInput, + ByocPythonVersion, + ByocUploadInfo, DeploymentInfo, HarnessDiscoveryCandidate, HarnessNativeTool, @@ -119,7 +124,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 +166,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 +217,7 @@ interface StoredSpec { efs?: { access_point_arn?: string; mount_path?: string }[]; }; network?: { subnets?: string[]; security_groups?: string[] }; + byoc?: ByocConfigInput; } interface MountRow { @@ -961,6 +974,31 @@ function CreateAgentWizard() { // JSON-RPC server (serverProtocol=A2A) with configurable agent-card skills const [protocol, setProtocol] = useState<"http" | "a2a">("http"); const [a2aSkills, setA2aSkills] = useState([]); + // BYOC (bring your own code): staged upload + artifact settings + const [byocKind, setByocKind] = useState("code_zip"); + const [byocUpload, setByocUpload] = useState(null); + const [byocUploading, setByocUploading] = useState(false); + const [byocImageUri, setByocImageUri] = useState(""); + const [byocEntrypoint, setByocEntrypoint] = useState("main.py"); + const [byocPython, setByocPython] = useState("PYTHON_3_13"); + const [byocInstallReqs, setByocInstallReqs] = useState(true); + const [byocRawContract, setByocRawContract] = useState(false); + // every model the execution role will permit; [0] is the PRIMARY (= spec.model_id, + // injected as env MODEL_ID) — the whole list reaches the runtime as ALLOWED_MODEL_IDS + const [byocModels, setByocModels] = useState([ + defaultModelFor(DEFAULT_MODEL_SOURCE), + ]); + // free-text "Custom model ID…" branch of the byoc model picker + const [byocModelCustomOpen, setByocModelCustomOpen] = useState(false); + const [byocModelDraft, setByocModelDraft] = useState(""); + const [byocEnvRows, setByocEnvRows] = useState<{ key: string; value: string }[]>([]); + const [byocContractOpen, setByocContractOpen] = useState(false); + const [byocDescription, setByocDescription] = useState(""); + const byocFileRef = useRef(null); + // BYOC provenance shown on the step-3 details view of an existing agent + const [detailByoc, setDetailByoc] = useState(null); + // the models that agent's execution role permits; [0] is the primary (MODEL_ID) + const [detailByocModels, setDetailByocModels] = useState([]); // when set, the wizard edits an existing agent and the launch button re-publishes it const [editing, setEditing] = useState(null); const [detailsMode, setDetailsMode] = useState(false); @@ -1097,6 +1135,10 @@ function CreateAgentWizard() { setModelSource(source); setModelId(defaultModelFor(source)); setCustomModel(false); + // the byoc allowed-models list is seeded from the same catalog + setByocModels([defaultModelFor(source)]); + setByocModelCustomOpen(false); + setByocModelDraft(""); }; const deployLock = !canDeploy @@ -1154,6 +1196,22 @@ const deployLock = !canDeploy setVpcSgs(""); setProtocol("http"); setA2aSkills([]); + setByocKind("code_zip"); + setByocUpload(null); + setByocUploading(false); + setByocImageUri(""); + setByocEntrypoint("main.py"); + setByocPython("PYTHON_3_13"); + setByocInstallReqs(true); + setByocRawContract(false); + setByocModels([defaultModelFor(sourceForMethod(method))]); + setByocModelCustomOpen(false); + setByocModelDraft(""); + setByocEnvRows([]); + setByocContractOpen(false); + setByocDescription(""); + setDetailByoc(null); + setDetailByocModels([]); setSubmitError(null); }; @@ -1237,7 +1295,45 @@ const deployLock = !canDeploy reasoning_effort: reasoningEffort, }); - const buildSpec = () => ({ + const byocEnv = () => + Object.fromEntries( + byocEnvRows + .map((row) => [row.key.trim(), row.value] as const) + .filter(([k]) => k.length > 0), + ); + + const byocModelList = () => byocModels.map((m) => m.trim()).filter(Boolean); + + const buildByocSpec = (): AgentSpecInput => ({ + name, + method: "byoc", + // the execution role scopes bedrock:InvokeModel to exactly the allowed-models + // list; entry [0] is the primary the backend injects as env MODEL_ID (the whole + // list goes in as ALLOWED_MODEL_IDS; user env rows win for both) + model_id: byocModelList()[0], + model_source: modelSource, + // spec.system_prompt is optional for byoc; the field doubles as a description + system_prompt: byocDescription, + memory: { short_term: true, long_term: false }, + ...(Object.keys(byocEnv()).length ? { env: byocEnv() } : {}), + byoc: { + artifact_kind: byocKind, + ...(byocKind === "container_image" + ? { image_uri: byocImageUri.trim() } + : { upload_id: byocUpload?.upload_id ?? "" }), + ...(byocKind === "code_zip" + ? { + entrypoint: byocEntrypoint.trim() || "main.py", + python_version: byocPython, + install_requirements: byocInstallReqs, + } + : {}), + invoke_contract: byocRawContract ? "raw" : "launchpad_prompt", + allowed_models: byocModelList(), + }, + }); + + const buildOrdinarySpec = () => ({ name, method, model_id: modelId.trim(), // a pasted custom id may carry stray whitespace @@ -1326,6 +1422,9 @@ const deployLock = !canDeploy : {}), }); + const buildSpec = (): AgentSpecInput => + method === "byoc" ? buildByocSpec() : (buildOrdinarySpec() as AgentSpecInput); + /* ── system-preset edit: the same page, a different save ─────────────── */ const systemEdit = editing?.system ?? null; @@ -1616,6 +1715,31 @@ const deployLock = !canDeploy tags: (s.tags ?? []).join(", "), })), ); + if (agent.method === "byoc" && spec.byoc) { + setByocKind(spec.byoc.artifact_kind); + setByocImageUri(spec.byoc.image_uri ?? ""); + setByocEntrypoint(spec.byoc.entrypoint ?? "main.py"); + setByocPython(spec.byoc.python_version ?? "PYTHON_3_13"); + setByocInstallReqs(spec.byoc.install_requirements ?? true); + setByocRawContract(spec.byoc.invoke_contract === "raw"); + // a spec stored before allowed_models existed reads back as its one model + setByocModels( + spec.byoc.allowed_models?.length ? spec.byoc.allowed_models : [storedModel], + ); + setByocModelCustomOpen(false); + setByocModelDraft(""); + setByocDescription(spec.system_prompt ?? ""); + setByocEnvRows(Object.entries(spec.env ?? {}).map(([key, value]) => ({ key, value }))); + // a re-publish reuses the stored upload unless a new zip is staged + if (spec.byoc.upload_id) { + void api + .getByocUpload(spec.byoc.upload_id) + .then((info) => setByocUpload(info)) + .catch(() => { + /* manifest gone — the member must upload a fresh zip to change code */ + }); + } + } setSubmitError(null); setStep(2); }; @@ -1629,6 +1753,18 @@ const deployLock = !canDeploy setDetailSystem(agent.system ?? null); setDetailKbs(((agent.spec ?? {}) as StoredSpec).knowledge_bases ?? []); const spec = (agent.spec ?? {}) as Record; + const detailCfg = + agent.method === "byoc" ? ((spec.byoc as ByocConfigInput | undefined) ?? null) : null; + setDetailByoc(detailCfg); + setDetailByocModels( + detailCfg + ? detailCfg.allowed_models?.length + ? detailCfg.allowed_models + : spec.model_id + ? [spec.model_id as string] + : [] + : [], + ); const src = spec.source_harness as { agent_name?: string } | undefined; setDetailConversion( src?.agent_name @@ -1696,6 +1832,23 @@ const deployLock = !canDeploy [toast], ); + const uploadByocZip = async (file: File) => { + setByocUploading(true); + try { + const info = await api.uploadByocArtifact(file); + if (!alive.current) return; + setByocUpload(info); + const candidates = info.detected.entrypoint_candidates; + if (candidates.length && !candidates.includes(byocEntrypoint)) { + setByocEntrypoint(candidates[0]); + } + } catch (err) { + if (alive.current) toast(apiMsg(err)); + } finally { + if (alive.current) setByocUploading(false); + } + }; + const inspectSource = async (input: File | { url: string }) => { setSrcBusy(true); try { @@ -1756,14 +1909,23 @@ const deployLock = !canDeploy if (live) return live.attachable; return storedGatewayConfig[name] == null; }); + const byocValid = + method !== "byoc" || + (byocModels.some((m) => m.trim().length > 0) && + (byocKind === "container_image" + ? /\.dkr\.ecr\./.test(byocImageUri.trim()) + : byocUpload != null && + (byocKind !== "code_zip" || byocEntrypoint.trim().endsWith(".py")))); const configValid = - /^[a-z][a-z0-9-]{2,47}$/.test(name) && - systemPrompt.trim().length > 0 && - // catalog picks are always non-empty; guards a cleared "Custom model ID…" input - modelId.trim().length > 0 && - knobIssues.length === 0 && - fsValid && - gatewaySelectionsValid; + method === "byoc" + ? /^[a-z][a-z0-9-]{2,47}$/.test(name) && byocValid && !byocUploading + : /^[a-z][a-z0-9-]{2,47}$/.test(name) && + systemPrompt.trim().length > 0 && + // catalog picks are always non-empty; guards a cleared "Custom model ID…" input + modelId.trim().length > 0 && + knobIssues.length === 0 && + fsValid && + gatewaySelectionsValid; return (
@@ -1850,10 +2012,26 @@ const deployLock = !canDeploy {t("create.methods.otherSdk.spec3")} +
pickMethod("byoc")} + data-method="byoc" + > +
{t("create.methods.byoc.badge")}
+
+

{t("create.methods.byoc.title")}

+

{t("create.methods.byoc.desc")}

+
+ ZIP → Runtime · Dockerfile → CodeBuild → ECR → Runtime + {t("create.methods.byoc.spec2")} + {t("create.methods.byoc.spec3")} +
+
+ ), + )} + + + {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")} +
+ )} +
+ )} + {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 +2490,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 +2737,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 +2796,7 @@ const deployLock = !canDeploy )} - {!systemEdit && ( + {!systemEdit && method !== "byoc" && (
)} - {!systemEdit && ( + {!systemEdit && method !== "byoc" && (
@@ -2663,6 +3207,7 @@ const deployLock = !canDeploy )}
)} + {method !== "byoc" && (
+ )} {method === "container" && (
@@ -2856,6 +3402,8 @@ const deployLock = !canDeploy )} {!systemEdit && ( <> + {method !== "byoc" && ( + <>
@@ -2910,6 +3458,8 @@ const deployLock = !canDeploy
)} + + )}
@@ -3066,6 +3616,82 @@ const deployLock = !canDeploy )} + {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 && ( <>
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/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..8a672859 --- /dev/null +++ b/samples/byoc/README.md @@ -0,0 +1,53 @@ +# 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": "..."}` +- 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. + +```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 From 70ec998709ad5be4d511592c54a1e4148f582ee3 Mon Sep 17 00:00:00 2001 From: alexwuu Date: Fri, 18 Sep 2026 14:11:37 +0000 Subject: [PATCH 2/5] feat(deps): manylinux_2_28 resolution target, strict requirements.txt handling, upload-time pre-resolve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The zip deploy paths (byoc code_zip + platform zip runtimes) resolved Python dependencies for manylinux2014, the docs' conservative recommendation. Measured from inside a deployed AgentCore Runtime direct-code agent (2026-09-18): AL2023, aarch64, glibc 2.34 — so packages that only ship manylinux_2_26/2_28 aarch64 wheels (google-re2, pulled in by chromadb) were unsolvable despite running fine there. - app/core/runtime_target.py: one definition of the resolve/install target, default manylinux_2_28, configurable via runtime_python_platform (LAUNCHPAD_RUNTIME_PYTHON_PLATFORM; manylinux2014 stays the documented fallback). pip gets the full tag ladder down to manylinux2014 because it treats --platform as exact strings; uv widens from one tag itself. Consumed by zip_runtime, byoc and schemas/requirements.resolve_pins — still --only-binary, user code is never built or executed on the control plane. - app/services/requirements_txt.py: pip-format parsing for uploaded requirements.txt (continuations, inline comments, markers; --hash dropped — the platform re-locks with its own hashes), supply-chain rejections (-r/-c, -e, local paths, URL/VCS, index options, >500 entries), and a resolver-failure summarizer that names the offending package + reason + fix hints instead of dumping uv's derivation tree. - POST /api/agents/uploads pre-resolves the zip's requirements.txt against the target (optional ?python_version=, 90s bound, threadpool) and returns detected.requirements {status, package_count, error}; the BYOC wizard shows the verdict and re-checks on Python version change. - Docs: architecture (+zh), api (+zh), lab 13, samples/byoc README. --- backend/app/core/config.py | 10 + backend/app/core/runtime_target.py | 81 ++++++ backend/app/deployer/byoc.py | 46 ++-- backend/app/deployer/zip_runtime.py | 37 +-- backend/app/routers/agents.py | 29 ++- backend/app/schemas/requirements.py | 11 +- backend/app/services/byoc_uploads.py | 44 ++++ backend/app/services/requirements_txt.py | 287 +++++++++++++++++++++ backend/tests/test_byoc.py | 77 ++++++ backend/tests/test_requirements_pinning.py | 2 +- backend/tests/test_requirements_txt.py | 248 ++++++++++++++++++ backend/tests/test_zip_runtime_deployer.py | 2 +- docs/api.md | 11 +- docs/api.zh-CN.md | 9 +- docs/architecture.md | 50 +++- docs/architecture.zh-CN.md | 33 ++- docs/lab/13-byoc.md | 24 +- docs/studio-integration.md | 2 +- frontend/src/lib/api.ts | 17 +- frontend/src/locales/en/common.json | 2 + frontend/src/locales/zh-CN/common.json | 2 + frontend/src/pages/CreateAgent.tsx | 50 +++- samples/byoc/README.md | 17 ++ 23 files changed, 1030 insertions(+), 61 deletions(-) create mode 100644 backend/app/core/runtime_target.py create mode 100644 backend/app/services/requirements_txt.py create mode 100644 backend/tests/test_requirements_txt.py 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/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 index d3f540a0..b51b3209 100644 --- a/backend/app/deployer/byoc.py +++ b/backend/app/deployer/byoc.py @@ -26,6 +26,7 @@ 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 @@ -33,6 +34,13 @@ 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 ( @@ -41,7 +49,7 @@ build_and_push_image, platform_buildspec_path, ) -from .zip_runtime import TARGET_PIP_PLATFORM, _compile_lock, sanitize_runtime_name +from .zip_runtime import _compile_lock, sanitize_runtime_name PACKAGE_KEY_TMPL = "agents/{name}/byoc_package.zip" @@ -52,18 +60,20 @@ def _config(spec: AgentSpec) -> ByocConfig: return spec.byoc -def _pip_python_version(python_version: str) -> str: - """PYTHON_3_13 → 3.13 (the shape pip/uv take).""" - return python_version.removeprefix("PYTHON_").replace("_", ".") +# 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]: - lines = [] - for raw in path.read_text(encoding="utf-8").splitlines(): - line = raw.strip() - if line and not line.startswith("#"): - lines.append(line) - return lines + """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: @@ -174,19 +184,21 @@ def resolve_requirements_into( requirements = _requirements_lines(req_file) if not requirements: return 0 - lock = _compile_lock(requirements, build_dir, compile_runner or pip_runner) + 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") - pip_version = _pip_python_version(python_version) proc = pip_runner( [ sys.executable, "-m", "pip", "install", "--require-hashes", "-r", str(lock), "-t", str(src_root), - "--platform", TARGET_PIP_PLATFORM, + *pip_platform_args(), "--only-binary=:all:", "--python-version", pip_version, "--quiet", @@ -195,8 +207,12 @@ def resolve_requirements_into( text=True, ) if proc.returncode != 0: - stderr = (proc.stderr or "").strip()[-2000:] - raise RuntimeError(f"pip install failed for the zip's requirements.txt: {stderr}") + 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) 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/routers/agents.py b/backend/app/routers/agents.py index 5cb805be..3b880f5d 100644 --- a/backend/app/routers/agents.py +++ b/backend/app/routers/agents.py @@ -7,9 +7,10 @@ import time from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, get_args from fastapi import APIRouter, Depends, Request +from fastapi.concurrency import run_in_threadpool from sqlalchemy.orm import Session from starlette.datastructures import UploadFile @@ -24,7 +25,13 @@ 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.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 @@ -48,6 +55,7 @@ router = APIRouter(prefix="/api", tags=["agents"]) 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]: @@ -237,6 +245,7 @@ def import_discovered_runtimes( @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). @@ -245,9 +254,19 @@ async def upload_byoc_artifact( 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. + ``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): @@ -275,7 +294,8 @@ async def upload_byoc_artifact( if size == 0: raise AppError("byoc.invalid_upload", "the uploaded file is empty", status_code=400) - manifest = byoc_uploads.stage_upload( + manifest = await run_in_threadpool( + byoc_uploads.stage_upload, ws.context, filename=filename, tmp_zip=tmp_zip, @@ -283,6 +303,7 @@ async def upload_byoc_artifact( 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)", 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/byoc_uploads.py b/backend/app/services/byoc_uploads.py index 72ee93af..ff804782 100644 --- a/backend/app/services/byoc_uploads.py +++ b/backend/app/services/byoc_uploads.py @@ -27,6 +27,8 @@ 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 @@ -43,6 +45,8 @@ 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 @@ -174,6 +178,36 @@ def validate_and_detect(path: Path) -> dict[str, Any]: } +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.""" @@ -202,6 +236,7 @@ def stage_upload( 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 @@ -213,6 +248,15 @@ def stage_upload( "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, 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/tests/test_byoc.py b/backend/tests/test_byoc.py index d4e51b06..ecbba50e 100644 --- a/backend/tests/test_byoc.py +++ b/backend/tests/test_byoc.py @@ -339,6 +339,20 @@ def stub_s3(monkeypatch): 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( @@ -359,6 +373,47 @@ def test_upload_endpoint_stages_and_reads_back(client, stub_s3): 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")} @@ -618,13 +673,35 @@ def runner(args, **_kw): 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() 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_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 f9701914..052a3d44 100644 --- a/docs/api.md +++ b/docs/api.md @@ -110,15 +110,24 @@ returned `upload_id` goes into the create body's `spec.byoc`. | Method | Path | Result | |---|---|---| -| `POST` | `/api/agents/uploads` | `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}}` | +| `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? diff --git a/docs/api.zh-CN.md b/docs/api.zh-CN.md index 5177b011..279f9835 100644 --- a/docs/api.zh-CN.md +++ b/docs/api.zh-CN.md @@ -428,15 +428,22 @@ period_not_allowed | description_too_long | dimension_keys_immutable`:1–10 | Method | Path | Result | |---|---|---| -| `POST` | `/api/agents/uploads` | `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}}` | +| `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? diff --git a/docs/architecture.md b/docs/architecture.md index 5a7c13ba..a720c4b6 100644 --- a/docs/architecture.md +++ b/docs/architecture.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 @@ -282,6 +295,27 @@ renders it on the agent detail view. (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 diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 4b142cd8..093d207d 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.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`),因此控制台会在构建启动前就拒掉范围写法。平台自带的 @@ -220,7 +231,21 @@ spec 也能被无歧义地读回,将来新增第二个 SDK 无需迁移已存 sp **校验什么/不校验什么。** 上传闸门强制归档安全(zip-slip、绝对路径、符号链接、 zip ≤250 MiB/解压后 ≤750 MiB/条目 ≤2 万——即 AgentCore 直连代码上限),并*报告* -检测结果(候选入口、requirements.txt、Dockerfile、AgentCore SDK 标记)。平台 +检测结果(候选入口、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` 注入 diff --git a/docs/lab/13-byoc.md b/docs/lab/13-byoc.md index 2863000c..a79c693e 100644 --- a/docs/lab/13-byoc.md +++ b/docs/lab/13-byoc.md @@ -41,7 +41,10 @@ zip -r hello-container.zip hello-container/ # container_source:Dockerfile 构 2. 构件类型保持 **代码 zip**;把 `hello-http.zip` 拖进上传框。 3. 上传完成后会显示检测摘要:入口候选(`main.py`)、requirements.txt、 AgentCore SDK 标记。若没有检测到 SDK 标记,会出现黄色提示——确认你的代码 - 自行实现了 `POST /invocations`。 + 自行实现了 `POST /invocations`。若 zip 带有 requirements.txt,上传时还会 + 针对运行时目标做一次**干跑解析**:绿色表示可解析(并显示包数量),红色则 + 给出具体原因(哪个包、为什么)——这样无需等到部署失败才发现依赖问题。 + 切换 Python 版本会自动重新检查。 4. 入口文件选 `main.py`,Python 版本保持 3.13;可按需添加环境变量。 5. 在 **允许的模型** 列表里添加你的代码要调用的模型(1–20 个,可从目录选择 或输入自定义 ID)。执行角色只允许调用列表中的这些模型 ID——你的代码调用 @@ -55,6 +58,25 @@ zip -r hello-container.zip hello-container/ # container_source:Dockerfile 构 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`。 diff --git a/docs/studio-integration.md b/docs/studio-integration.md index c9a93352..6a94c108 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 diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a8afd671..b9dcd70e 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -635,6 +635,18 @@ export interface ByocUploadInfo { has_requirements: boolean; has_dockerfile: boolean; agentcore_sdk_detected: boolean; + /** + * Upload-time dry resolve of the zip's requirements.txt against the deploy + * target (linux/aarch64 + the selected Python). `failed` means the deploy's + * package stage would fail the same way; `skipped` = the check could not + * run (no requirements.txt, resolver timeout) and says nothing either way. + * Absent on manifests staged before the check existed. + */ + requirements?: { + status: "ok" | "failed" | "skipped"; + package_count: number | null; + error: string | null; + }; }; } @@ -3548,10 +3560,11 @@ export const api = { deleteUser: (id: string) => request<{ ok: boolean }>(`/api/users/${id}`, { method: "DELETE" }), /** Stage a BYOC source zip; the returned upload_id goes into spec.byoc. */ - uploadByocArtifact: (file: File) => { + uploadByocArtifact: (file: File, pythonVersion?: ByocPythonVersion) => { const form = new FormData(); form.append("file", file); - return requestForm("/api/agents/uploads", form); + const query = pythonVersion ? `?python_version=${pythonVersion}` : ""; + return requestForm(`/api/agents/uploads${query}`, form); }, getByocUpload: (uploadId: string) => request(`/api/agents/uploads/${encodeURIComponent(uploadId)}`), diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 43fffb9f..3d03bc58 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -438,6 +438,8 @@ "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", diff --git a/frontend/src/locales/zh-CN/common.json b/frontend/src/locales/zh-CN/common.json index 7c0d330e..73f6bd26 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -438,6 +438,8 @@ "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": "入口文件", diff --git a/frontend/src/pages/CreateAgent.tsx b/frontend/src/pages/CreateAgent.tsx index ca9eb562..f907613e 100644 --- a/frontend/src/pages/CreateAgent.tsx +++ b/frontend/src/pages/CreateAgent.tsx @@ -995,6 +995,9 @@ function CreateAgentWizard() { const [byocContractOpen, setByocContractOpen] = useState(false); const [byocDescription, setByocDescription] = useState(""); const byocFileRef = useRef(null); + // the staged zip, kept so a Python-version change can re-run the + // requirements pre-resolve (re-staging the same bytes under the new target) + const byocLastFile = useRef(null); // BYOC provenance shown on the step-3 details view of an existing agent const [detailByoc, setDetailByoc] = useState(null); // the models that agent's execution role permits; [0] is the primary (MODEL_ID) @@ -1832,11 +1835,12 @@ const deployLock = !canDeploy [toast], ); - const uploadByocZip = async (file: File) => { + const uploadByocZip = async (file: File, pythonVersion?: ByocPythonVersion) => { setByocUploading(true); try { - const info = await api.uploadByocArtifact(file); + const info = await api.uploadByocArtifact(file, pythonVersion ?? byocPython); if (!alive.current) return; + byocLastFile.current = file; setByocUpload(info); const candidates = info.detected.entrypoint_candidates; if (candidates.length && !candidates.includes(byocEntrypoint)) { @@ -1849,6 +1853,14 @@ const deployLock = !canDeploy } }; + const changeByocPython = (version: ByocPythonVersion) => { + setByocPython(version); + // the pre-resolve result is per-Python-version — re-check the staged zip + if (byocLastFile.current && byocUpload?.detected.has_requirements) { + void uploadByocZip(byocLastFile.current, version); + } + }; + const inspectSource = async (input: File | { url: string }) => { setSrcBusy(true); try { @@ -2256,6 +2268,38 @@ const deployLock = !canDeploy {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" && ( @@ -2319,7 +2363,7 @@ const deployLock = !canDeploy className="input mono" data-testid="byoc-python" value={byocPython} - onChange={(e) => setByocPython(e.target.value as ByocPythonVersion)} + onChange={(e) => changeByocPython(e.target.value as ByocPythonVersion)} > {(["PYTHON_3_13", "PYTHON_3_12", "PYTHON_3_11", "PYTHON_3_10"] as const) .map((v) => ( diff --git a/samples/byoc/README.md b/samples/byoc/README.md index 8a672859..bad2ebfd 100644 --- a/samples/byoc/README.md +++ b/samples/byoc/README.md @@ -24,6 +24,23 @@ that switches models at runtime should pick from `ALLOWED_MODEL_IDS`. 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 From 06fc9f18dcfc6a304508585d9c7a3f4997a83703 Mon Sep 17 00:00:00 2001 From: alexwuu Date: Fri, 18 Sep 2026 14:11:37 +0000 Subject: [PATCH 3/5] fix(runtime): render free-form JSON replies instead of a blank turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Runtime HTTP contract requires JSON or SSE and names no key, but _runtime_payload_events only read {"result"} (BedrockAgentCoreApp's convention), the delta/tool/complete envelope and Converse events. A BYOC agent answering its own JSON — measured 2026-09-18: a CrewAI agent returning {"answer", "session_id", "turns", "latency_ms"} — produced an empty chat turn with no error while CloudWatch showed the invocation succeeding. - Bodies with none of the known keys now take the first conventional text key (response, answer, output, output_text, text, message, content, completion, reply; a nested {"text"} block under one counts), and a body with none of those is shown as compact JSON (4000-char cap) with a warning log, never as a blank turn. {"result"} still wins; {"error"} still raises; Converse bookkeeping events stay silent. - Tests for each shape; docs (architecture en/zh, lab 13, samples/byoc README) now state the response contract. --- backend/app/services/agentcore/runtime.py | 68 +++++++++++++++++++++++ backend/tests/test_runtime_endpoints.py | 48 ++++++++++++++++ docs/architecture.md | 13 ++++- docs/architecture.zh-CN.md | 10 +++- docs/lab/13-byoc.md | 1 + samples/byoc/README.md | 4 ++ 6 files changed, 142 insertions(+), 2 deletions(-) diff --git a/backend/app/services/agentcore/runtime.py b/backend/app/services/agentcore/runtime.py index 6b614f4b..e8801d22 100644 --- a/backend/app/services/agentcore/runtime.py +++ b/backend/app/services/agentcore/runtime.py @@ -449,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/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/docs/architecture.md b/docs/architecture.md index a720c4b6..91752fa9 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -267,7 +267,18 @@ unconditional until the category has a second member. 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"}`). Three +`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 | diff --git a/docs/architecture.zh-CN.md b/docs/architecture.zh-CN.md index 093d207d..c677eade 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -208,7 +208,15 @@ spec 也能被无歧义地读回,将来新增第二个 SDK 无需迁移已存 sp 第四张卡片部署成员开发者自己编写的代码——已用 AgentCore SDK (`BedrockAgentCoreApp` + `@app.entrypoint`)包装,或任何满足运行时契约的 HTTP 服务(ARM64、8080 端口、`POST /invocations` + `GET /ping`、负载 -`{"prompt", "actor_id"}`)。三种构件类型,同一个 `spec.byoc` 配置块 +`{"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 的路径 | diff --git a/docs/lab/13-byoc.md b/docs/lab/13-byoc.md index a79c693e..03951792 100644 --- a/docs/lab/13-byoc.md +++ b/docs/lab/13-byoc.md @@ -23,6 +23,7 @@ Agent——用 `bedrock-agentcore` SDK(`BedrockAgentCoreApp` + `@app.entrypoin | 端口 | 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 准备示例代码 diff --git a/samples/byoc/README.md b/samples/byoc/README.md index bad2ebfd..f9709e95 100644 --- a/samples/byoc/README.md +++ b/samples/byoc/README.md @@ -4,6 +4,10 @@ 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. From 3178ade3e64a8cf4faca72f80b944aa0e9ab9dfc Mon Sep 17 00:00:00 2001 From: alexwuu Date: Fri, 18 Sep 2026 14:11:37 +0000 Subject: [PATCH 4/5] =?UTF-8?q?feat(agents):=20list-first=20agent=20manage?= =?UTF-8?q?ment=20=E2=80=94=20/agents,=20/agents/new,=20/agents/:id?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The management page at /create hosted the 3-step wizard and the agent list on one route, with discovery hidden behind ?view=discover. Split it into routes without touching the wizard's state machine: /agents list landing: + New Agent / Import existing Runtime, stats strip (total / running / deploying / failed), the agent table — name links to the detail, CHAT + DETAILS visible, EDIT / CONVERT / DELETE in a per-row "···" menu (portalled so the last row is never clipped), FAILED rows carry the error as tooltip + VIEW REASON, relative UPDATED time, empty state with a create CTA /agents/new the wizard: four method cards (equal height), a button to the import page, the system-preset cards beneath /agents/import discovery of existing Runtime / Harness resources /agents/:id the agent's detail (launch sequence, versions, BYOC provenance; live polling while deploying; OPEN CHAT / OBSERVABILITY / EDIT). A deploy from /agents/new hands over here when it goes active /agents/:id/edit the wizard preloaded for a re-publish /create[?...] redirects (?view=discover -> /agents/import, otherwise /agents/new with the query kept for Registry prefill) Sidebar target, ROUTE_PATHS, every in-app navigate()/Link, the preset settings mock script and the docs (architecture en/zh, lab 02/03/13, studio-integration) follow. en + zh-CN keys under agents.*. --- .../scripts/ui_system_preset_settings_mock.py | 63 ++- docs/architecture.md | 25 +- docs/architecture.zh-CN.md | 25 +- docs/lab/02-deploy-runtime.md | 4 +- docs/lab/03-deploy-harness.md | 2 +- docs/lab/13-byoc.md | 2 +- docs/studio-integration.md | 2 +- frontend/src/App.tsx | 28 +- frontend/src/layout/nav.ts | 9 +- frontend/src/locales/en/common.json | 37 +- frontend/src/locales/zh-CN/common.json | 37 +- frontend/src/pages/AssistantNextSteps.tsx | 4 +- frontend/src/pages/CreateAgent.tsx | 480 ++++++++++++++---- frontend/src/pages/CreateAgentAssistant.tsx | 6 +- frontend/src/pages/CreateAgentStudio.tsx | 10 +- frontend/src/pages/Overview.tsx | 2 +- frontend/src/pages/Registry.tsx | 4 +- .../src/pages/assistant/CreationProgress.tsx | 2 +- .../src/pages/assistant/PreparationPanel.tsx | 2 +- frontend/src/theme/app.css | 24 + 20 files changed, 597 insertions(+), 171 deletions(-) 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/docs/architecture.md b/docs/architecture.md index 91752fa9..8a01a0ac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -241,9 +241,23 @@ 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 five cards, in this order: +The `/agents/new` picker shows four cards, in this order: | # | Card | `AgentSpec.method` | What it is | |---|---|---|---| @@ -251,7 +265,10 @@ The `/create` picker shows five cards, in this order: | 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 | **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) | -| 5 | **Discover existing runtimes and harnesses** | — | not a deploy method (see below) | + +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 @@ -1828,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, @@ -1886,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 c677eade..40ba2dcc 100644 --- a/docs/architecture.zh-CN.md +++ b/docs/architecture.zh-CN.md @@ -184,9 +184,22 @@ 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` | 说明 | |---|---|---|---| @@ -194,7 +207,9 @@ agent 全部卡死。而读不到的扫描——未启用扫描、API 报错、 | 2 | **Strands Studio** | `zip_runtime` | 方式C —— Strands 模板走 zip 快速通道;卡片内嵌链接进入 `/create/studio` 画布,画布以 `studio` 方式部署 | | 3 | **其他 Agent SDK** | `container` | 方式A —— 自带 Agent SDK,经 CodeBuild 打包为 ARM64 容器 | | 4 | **自带代码** | `byoc` | 开发者自己编写的 Agent 代码——上传 zip(直连代码运行时或 Dockerfile → CodeBuild),或引用本账户私有 ECR 中的现有镜像——见下文 BYOC 小节 | -| 5 | **发现现有 Runtime 与 Harness** | — | 不是部署方式(见下文) | + +发现现有 Runtime 与 Harness 不是部署方式:它有独立页面 `/agents/import`(见下文), +可从列表页头部和第一步 NEXT 旁的按钮进入。 第三张卡片是一个**类别**,而不是某一个 SDK。`AgentSpec.agent_sdk` 记录容器 Agent 打包的是哪个 SDK,向导把它作为配置步骤上的二级选项。它是只有一个成员的 @@ -1018,7 +1033,7 @@ A2A zip Agent 使用另一个没有 Mantle 分支的模板,因此向导会将其 ### 发现既有 Runtime 与 Harness -`/create?view=discover` 是与三种创建方式并列的一条接入路径,而不是一种部署方式。 +`/agents/import` 是与三种创建方式并列的一条接入路径,而不是一种部署方式。 `GET /api/agents/discovery` 会跟完所配置 Region 中 Runtime 列表的每一页,并对每个资源做一次 详情读取。后端只返回白名单投影:Runtime 标识、名称、描述、协议、制品类型、authorizer 类型、 AWS 状态/版本以及最近更新时间。环境变量值、制品位置、执行角色与 authorizer 配置从不离开 @@ -1061,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` 以及 @@ -1422,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 index 03951792..a099dab7 100644 --- a/docs/lab/13-byoc.md +++ b/docs/lab/13-byoc.md @@ -38,7 +38,7 @@ zip -r hello-container.zip hello-container/ # container_source:Dockerfile 构 ## 13.2 控制台部署(code_zip) -1. 打开 **Create**,选第 4 张卡片 **自带代码**,点 **NEXT**。 +1. 打开 **Agent 管理**(`/agents`)→ **新建 Agent**(`/agents/new`),选第 4 张卡片 **自带代码**,点 **NEXT**。 2. 构件类型保持 **代码 zip**;把 `hello-http.zip` 拖进上传框。 3. 上传完成后会显示检测摘要:入口候选(`main.py`)、requirements.txt、 AgentCore SDK 标记。若没有检测到 SDK 标记,会出现黄色提示——确认你的代码 diff --git a/docs/studio-integration.md b/docs/studio-integration.md index 6a94c108..114ad645 100644 --- a/docs/studio-integration.md +++ b/docs/studio-integration.md @@ -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/layout/nav.ts b/frontend/src/layout/nav.ts index 69169bd7..b4831fa8 100644 --- a/frontend/src/layout/nav.ts +++ b/frontend/src/layout/nav.ts @@ -14,7 +14,7 @@ export const NAV_ENTRIES: NavEntry[] = [ { idx: "02", to: "/create/assistant", labelKey: "nav.assistant" }, // members reach it too since 2026-08-07: reads are open, and the mutating // actions are gated per user by agent-management permissions (auth `can()`) - { idx: "03", to: "/create", labelKey: "nav.createAgent" }, + { idx: "03", to: "/agents", labelKey: "nav.createAgent" }, { idx: "04", to: "/registry", labelKey: "nav.registry" }, { idx: "05", to: "/knowledge-bases", labelKey: "nav.knowledgeBases" }, { idx: "06", to: "/memory", labelKey: "nav.memory" }, @@ -32,7 +32,7 @@ export const PLATFORM_COUNT = 7; * * Distinct from `adminOnly` on a NAV_ENTRY — these are whole modules that only * exist for administrators, whereas an `adminOnly` platform entry keeps its place - * in the numbered flow (dropping `/create` from the list would renumber the + * in the numbered flow (dropping `/agents` from the list would renumber the * console for members). */ export const ADMIN_NAV_ENTRIES: NavEntry[] = [ @@ -75,6 +75,11 @@ export function navEntryFor(pathname: string): NavEntry | null { */ export const ROUTE_PATHS: string[] = [ "/", + "/agents", + "/agents/new", + "/agents/import", + "/agents/:agentId", + "/agents/:agentId/edit", "/create", "/create/studio", "/create/assistant", diff --git a/frontend/src/locales/en/common.json b/frontend/src/locales/en/common.json index 3d03bc58..34fc4c9b 100644 --- a/frontend/src/locales/en/common.json +++ b/frontend/src/locales/en/common.json @@ -513,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.", @@ -4871,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 73f6bd26..1e9beb75 100644 --- a/frontend/src/locales/zh-CN/common.json +++ b/frontend/src/locales/zh-CN/common.json @@ -513,7 +513,7 @@ "details": "详情", "delete": "删除", "remove": "移除", - "empty": "还没有 Agent——请在上方创建。", + "empty": "还没有 Agent——点击「新建 Agent」创建。", "deleted": "已删除 Agent。", "confirmDeleteTitle": "删除该 Agent?", "confirmDelete": "这会移除“{{name}}”及其 AWS 资源,且不可撤销。", @@ -4871,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 f907613e..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, @@ -296,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() { @@ -483,7 +500,7 @@ function RuntimeDiscovery() { meta={region ? t("create.discovery.region", { region }) : undefined} />
- navigate("/create")}> + navigate("/agents")}> @@ -730,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", @@ -2118,6 +2209,7 @@ const deployLock = !canDeploy }) } onConvert={(id, name) => setConfirm({ kind: "convert", id, name })} + onCreate={canDeploy ? () => navigate("/agents/new") : undefined} /> )} @@ -3581,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(); }} @@ -3632,6 +3728,10 @@ const deployLock = !canDeploy agentStatus={agentStatus} detailsMode={detailsMode} onRestart={() => { + if (!isList) { + navigate("/agents"); + return; + } setStep(1); setLaunch(null); setDeployment(null); @@ -3656,6 +3756,30 @@ 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")} + + )} + + ); + })()} +
+ )}
@@ -3840,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(); @@ -3938,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" && ( + + )} + +
@@ -4039,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/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} From e15180adf111f13bb1a05ef59a55e785ca4e7021 Mon Sep 17 00:00:00 2001 From: River Xie Date: Sun, 20 Sep 2026 07:35:26 +0000 Subject: [PATCH 5/5] fix(byoc): isolate builds, roles and model permissions Give each source packaging attempt a private temporary directory so overlapping same-named agents cannot exchange code across workspaces. Reconcile the per-agent execution role when a resumed deploy has lost its scratch state, propagating IAM failures instead of selecting the shared role implicitly. Validate BYOC model selections as literal resources and preserve supported model and system inference-profile ARNs. Keep legacy non-BYOC fallback behavior. Recognize actual runtime event shapes so metadata and null errors do not hide free-form JSON answers. Add regression coverage for interleaved packages, all artifact types on resumed create/update, explicit shared-role mode, IAM failure, model authorization, and JSON/SSE response compatibility. Document the enforced BYOC contracts. --- backend/app/deployer/byoc.py | 34 ++++-- backend/app/schemas/agent.py | 49 +++++++- backend/app/services/agent_iam.py | 28 +++-- backend/app/services/agentcore/runtime.py | 65 ++++++----- backend/tests/test_agent_iam_policy.py | 27 +++++ backend/tests/test_byoc.py | 136 ++++++++++++++++++++++ backend/tests/test_runtime_endpoints.py | 127 ++++++++++++++++++-- docs/architecture.md | 17 ++- samples/byoc/README.md | 3 + 9 files changed, 427 insertions(+), 59 deletions(-) diff --git a/backend/app/deployer/byoc.py b/backend/app/deployer/byoc.py index b51b3209..a84618d7 100644 --- a/backend/app/deployer/byoc.py +++ b/backend/app/deployer/byoc.py @@ -20,6 +20,7 @@ import shutil import subprocess import sys +import tempfile import time import zipfile from pathlib import Path @@ -233,10 +234,15 @@ def _zip_tree(src_root: Path, zip_path: Path) -> None: 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) + # Agent names are only unique within a workspace. Each attempt owns its + # directory, including while another job is resolving dependencies. + with tempfile.TemporaryDirectory(prefix="launchpad_byoc_") as tmp: + return _build_code_zip(ctx, agent, cfg, bucket, Path(tmp)) + + +def _build_code_zip( + ctx: StageContext, agent: Agent, cfg: ByocConfig, bucket: str, build_dir: Path +) -> StageResult: zip_path = build_dir / "upload.zip" byoc_uploads.download_upload(ctx.workspace, agent.workspace_id, cfg.upload_id or "", zip_path) @@ -278,10 +284,13 @@ def _package_code_zip( 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) + with tempfile.TemporaryDirectory(prefix="launchpad_byoc_") as tmp: + return _build_container_source(ctx, agent, cfg, Path(tmp)) + + +def _build_container_source( + ctx: StageContext, agent: Agent, cfg: ByocConfig, build_dir: Path +) -> StageResult: zip_path = build_dir / "upload.zip" byoc_uploads.download_upload(ctx.workspace, agent.workspace_id, cfg.upload_id or "", zip_path) @@ -345,6 +354,11 @@ def _container_uri(ctx: StageContext, agent: Agent, cfg: ByocConfig) -> str: def _stage_deploy(ctx: StageContext, agent: Agent) -> StageResult: + # A resumed job skips its successful provision stage, but scratch is + # process-local. Reconcile the role idempotently instead of silently + # switching the workload to the workspace's broader shared role. + if not ctx.scratch.get("execution_role_arn"): + _stage_provision(ctx, agent) client = control_client(ctx.workspace) mode = ctx.scratch.get("mode", "create") db = ctx.session() @@ -352,9 +366,7 @@ def _stage_deploy(ctx: StageContext, agent: Agent) -> StageResult: 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", "" - ) + role_arn = ctx.scratch["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 diff --git a/backend/app/schemas/agent.py b/backend/app/schemas/agent.py index 7903c855..ebbde76b 100644 --- a/backend/app/schemas/agent.py +++ b/backend/app/schemas/agent.py @@ -197,6 +197,44 @@ def _check(self) -> "FilesystemConfig": # 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 +INFERENCE_PROFILE_PREFIXES = ("global.", "us.", "eu.", "apac.") +_BYOC_MODEL_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9.:-]*$") +_BYOC_MODEL_ARN_RE = re.compile( + r"^arn:aws(?:-cn|-us-gov)?:bedrock:[a-z0-9-]+:(?P\d{12})?:" + r"(?Pfoundation-model|inference-profile)/(?P[A-Za-z0-9][A-Za-z0-9.:-]*)$" +) + + +def byoc_model_target(model_id: str) -> tuple[str, str]: + """Validate a literal BYOC model selection and return (resource type, id). + + Custom identifiers remain usable without catalog lookup, but can never + insert IAM wildcards. Profile targets must be derivable from a system + inference-profile id; application profiles require a separate live lookup. + """ + if model_id.startswith("arn:"): + match = _BYOC_MODEL_ARN_RE.fullmatch(model_id) + if match is None or ( + match["kind"] == "foundation-model" and match["account"] is not None + ): + raise ValueError( + "BYOC models must be literal model IDs, foundation-model ARNs, " + "or system inference-profile ARNs" + ) + kind, target = match["kind"], match["id"] + else: + if _BYOC_MODEL_ID_RE.fullmatch(model_id) is None: + raise ValueError("BYOC model IDs cannot contain wildcards, spaces, or IAM variables") + target = model_id + kind = ( + "inference-profile" if target.startswith(INFERENCE_PROFILE_PREFIXES) + else "foundation-model" + ) + if kind == "inference-profile": + prefix = next((p for p in INFERENCE_PROFILE_PREFIXES if target.startswith(p)), "") + if not prefix or len(target) == len(prefix): + raise ValueError("BYOC inference profiles must use a global, us, eu, or apac model ID") + return kind, target # Private ECR in *some* account/region — the workspace match (this account, this # region) is a resource check, done against the WorkspaceContext at request time, @@ -243,9 +281,9 @@ class ByocConfig(BaseModel): # 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 + # inference-profile ids, or their supported ARNs). Custom ids are checked + # for literal IAM-safe shape, without requiring a catalog match. 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. @@ -262,6 +300,8 @@ def _allowed_models_shape(self) -> "ByocConfig": raise ValueError("allowed_models entries cannot be empty") if len(cleaned) != len(set(cleaned)): raise ValueError("allowed_models entries must be unique") + for model_id in cleaned: + byoc_model_target(model_id) self.allowed_models = cleaned return self @@ -432,6 +472,9 @@ def _byoc_constraints(self) -> "AgentSpec": elif models[0] != self.model_id: models.remove(self.model_id) models.insert(0, self.model_id) + # Omitting the list still authorizes model_id, so it needs the same + # literal-resource validation as explicit allowlist entries. + byoc_model_target(self.model_id) return self @property diff --git a/backend/app/services/agent_iam.py b/backend/app/services/agent_iam.py index 80801520..dcf381b0 100644 --- a/backend/app/services/agent_iam.py +++ b/backend/app/services/agent_iam.py @@ -27,13 +27,13 @@ from typing import Any from app.models.ledger import Agent -from app.schemas.agent import AgentSpec +from app.schemas.agent import INFERENCE_PROFILE_PREFIXES, AgentSpec, byoc_model_target from app.services.workspace import WorkspaceContext # Inference-profile prefixes: an id like `global.anthropic.claude-sonnet-5` is a # profile, and invoking it authorizes against the profile ARN *and* the underlying # foundation-model ARNs. Scoping to only one of the two fails at first invoke. -_PROFILE_PREFIXES = ("global.", "us.", "eu.", "apac.") +_PROFILE_PREFIXES = INFERENCE_PROFILE_PREFIXES _ROLE_PREFIX = "launchpad-agent-" _ROLE_NAME_MAX = 64 # IAM hard limit @@ -198,11 +198,25 @@ 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) - )) + if spec.method != "byoc": + return model_resources(spec.model_id, ctx) + resources: list[str] = [] + for selection in spec.allowed_model_ids: + kind, model_id = byoc_model_target(selection) + if selection.startswith("arn:"): + partition = selection.split(":", 2)[1] + resource = selection + else: + partition = "aws" + scope = f"{ctx.region}:{ctx.account_id}" if kind == "inference-profile" else "*:" + resource = f"arn:{partition}:bedrock:{scope}:{kind}/{model_id}" + if kind == "inference-profile": + prefix = next(p for p in _PROFILE_PREFIXES if model_id.startswith(p)) + resources.append( + f"arn:{partition}:bedrock:*::foundation-model/{model_id[len(prefix):]}" + ) + resources.append(resource) + return list(dict.fromkeys(resources)) def _uses_gateway(spec: AgentSpec) -> bool: diff --git a/backend/app/services/agentcore/runtime.py b/backend/app/services/agentcore/runtime.py index e8801d22..eae5d1a2 100644 --- a/backend/app/services/agentcore/runtime.py +++ b/backend/app/services/agentcore/runtime.py @@ -414,7 +414,7 @@ def _runtime_payload_events(payload: Any) -> Iterator[dict[str, Any]]: raise RuntimeError(f"runtime returned error: {payload['error']}") kind = payload.get("event") - if isinstance(kind, str): + if isinstance(kind, str) and kind in {"delta", "heartbeat", "tool", "complete", "error"}: if kind == "delta": text = payload.get("text") if text: @@ -436,45 +436,52 @@ def _runtime_payload_events(payload: Any) -> Iterator[dict[str, Any]]: if "runtimeClientError" in inner or "internalServerException" in inner: detail = inner.get("runtimeClientError") or inner.get("internalServerException") raise RuntimeError(f"runtime returned error: {detail}") - tool_use = inner.get("contentBlockStart", {}).get("start", {}).get("toolUse") - if isinstance(tool_use, dict): - yield { - "event": "tool", - "data": {"name": tool_use.get("name", ""), "id": tool_use.get("toolUseId")}, - } - delta = inner.get("contentBlockDelta", {}).get("delta", {}) - if isinstance(delta, dict): + converse_event = _is_converse_stream_event(inner) + if converse_event: + tool_use = inner.get("contentBlockStart", {}).get("start", {}).get("toolUse") + if isinstance(tool_use, dict): + yield { + "event": "tool", + "data": {"name": tool_use.get("name", ""), "id": tool_use.get("toolUseId")}, + } + delta = inner.get("contentBlockDelta", {}).get("delta", {}) text = delta.get("text") if text: 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): + elif not converse_event: 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", +def _is_converse_stream_event(payload: dict[str, Any]) -> bool: + """Converse's event union has one member with a structured value. + + A free-form reply may use the same keys for auxiliary fields, especially + ``metadata``. Only suppress bookkeeping when the whole body is an event. + """ + if len(payload) != 1: + return False + name, detail = next(iter(payload.items())) + if not isinstance(detail, dict): + return False + if name == "metadata": + return any(isinstance(detail.get(key), dict) for key in ("usage", "metrics", "trace")) + fields = { + "contentBlockStart": ("start", dict), + "contentBlockDelta": ("delta", dict), + "contentBlockStop": ("contentBlockIndex", int), + "messageStart": ("role", str), + "messageStop": ("stopReason", str), } -) + if name not in fields: + return False + field, field_type = fields[name] + return isinstance(detail.get(field), field_type) + + # 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 diff --git a/backend/tests/test_agent_iam_policy.py b/backend/tests/test_agent_iam_policy.py index 18d9355f..38fa947c 100644 --- a/backend/tests/test_agent_iam_policy.py +++ b/backend/tests/test_agent_iam_policy.py @@ -147,6 +147,33 @@ 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_foundation_model_arn_is_scoped_exactly(self): + model = "arn:aws:bedrock:us-west-2::foundation-model/amazon.nova-pro-v1:0" + assert _statement(self._byoc([model]), "BedrockModels")["Resource"] == [model] + + def test_profile_arn_includes_only_its_foundation_model(self): + profile = ( + "arn:aws:bedrock:us-east-1:123456789012:" + "inference-profile/us.amazon.nova-pro-v1:0" + ) + assert _statement(self._byoc([profile]), "BedrockModels")["Resource"] == [ + "arn:aws:bedrock:*::foundation-model/amazon.nova-pro-v1:0", profile, + ] + + def test_custom_id_stays_literal_instead_of_using_the_legacy_fallback(self): + spec = self._byoc(["my-private-endpoint"]) + assert _statement(spec, "BedrockModels")["Resource"] == [ + "arn:aws:bedrock:*::foundation-model/my-private-endpoint", + ] + + def test_omitted_allowlist_keeps_the_primary_arn_scoped(self): + model = "arn:aws:bedrock:us-west-2::foundation-model/amazon.nova-pro-v1:0" + spec = _spec( + method="byoc", model_id=model, + byoc={"artifact_kind": "code_zip", "upload_id": "u1"}, + ) + assert _statement(spec, "BedrockModels")["Resource"] == [model] + 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.""" diff --git a/backend/tests/test_byoc.py b/backend/tests/test_byoc.py index ecbba50e..819ceff0 100644 --- a/backend/tests/test_byoc.py +++ b/backend/tests/test_byoc.py @@ -20,6 +20,7 @@ from app.services import byoc_uploads from app.services.agentcore import runtime as rt from tests.conftest import ws_ctx +from tests.test_agent_iam_lifecycle import StubIam ECR_IMAGE = "111122223333.dkr.ecr.us-west-2.amazonaws.com/my-agents:v1" @@ -272,6 +273,22 @@ def test_byoc_allowed_models_shape(): "allowed_models": [f"us.model.m{i}" for i in range(21)]})) +@pytest.mark.parametrize("model_id", [ + "*", "us.*", "amazon.nova?", "${aws:username}", "model name", "global.", + "arn:aws:bedrock:*::foundation-model/amazon.nova-pro-v1:0", + "arn:aws:bedrock:us-west-2::foundation-model/*", + "arn:aws:bedrock:us-west-2:111122223333:application-inference-profile/custom", + "arn:aws:bedrock:us-west-2:111122223333:inference-profile/custom", +]) +@pytest.mark.parametrize("explicit_list", [False, True]) +def test_byoc_rejects_models_that_cannot_be_scoped(model_id, explicit_list): + body = _byoc_spec(model_id=model_id) + if explicit_list: + body["byoc"]["allowed_models"] = [model_id] + with pytest.raises(ValidationError, match="BYOC"): + AgentSpec(**body) + + 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"): @@ -616,6 +633,60 @@ def test_package_stage_code_zip_no_requirements(monkeypatch, tmp_path): assert set(zf.namelist()) == {"main.py", "helper.py"} +@pytest.mark.parametrize("kind", ["code_zip", "container_source"]) +def test_same_named_builds_in_different_workspaces_keep_their_sources(monkeypatch, kind): + """Interleave B while A is resolving/building; inspect both shipped archives.""" + s3 = StubS3() + _client_router(monkeypatch, {"s3": s3}) + contexts = {} + agents = {} + for workspace_id in ("workspace-a", "workspace-b"): + workspace = ws_ctx({"artifacts_bucket": workspace_id}, id=workspace_id) + contexts[workspace_id] = _stage_ctx(workspace_id, f"dep-{workspace_id}", workspace) + spec = AgentSpec(**_byoc_spec( + byoc={"artifact_kind": kind, "upload_id": "upload"}, + )) + agents[workspace_id] = Agent( + id=workspace_id, workspace_id=workspace_id, name=spec.name, + method="byoc", spec=spec.model_dump(), version="1", + ) + key = byoc_uploads.source_key(workspace_id, "upload") + s3.objects[(workspace_id, key)] = zip_bytes({ + "main.py": f'print("{workspace_id}")\n'.encode(), + "Dockerfile": b"FROM python:3.13-slim\n", + }) + + workdirs = [] + + def run_b(): + byoc_dep._stage_package(contexts["workspace-b"], agents["workspace-b"]) + + def resolve(src_root, build_dir, *_args): + workdirs.append(build_dir) + if b"workspace-a" in (src_root / "main.py").read_bytes(): + run_b() + return 0 + + def build(ctx, agent, archive): + workdirs.append(Path(archive).parent) + if agent.workspace_id == "workspace-a": + run_b() + s3.upload_file(archive, agent.workspace_id, "context.zip") + ctx.scratch["image_digest"] = "sha256:" + "0" * 64 + return "v1", 0.1 + + monkeypatch.setattr(byoc_dep, "resolve_requirements_into", resolve) + monkeypatch.setattr(byoc_dep, "build_and_push_image", build) + byoc_dep._stage_package(contexts["workspace-a"], agents["workspace-a"]) + + key = "agents/byoc-agent/byoc_package.zip" if kind == "code_zip" else "context.zip" + for workspace_id in ("workspace-a", "workspace-b"): + with zipfile.ZipFile(io.BytesIO(s3.objects[(workspace_id, key)])) as archive: + assert archive.read("main.py") == f'print("{workspace_id}")\n'.encode() + assert len(set(workdirs)) == 2 + assert all(not path.exists() for path in workdirs) + + def test_package_stage_missing_entrypoint_fails(monkeypatch): s3 = StubS3() _client_router(monkeypatch, {"s3": s3}) @@ -993,6 +1064,71 @@ def test_deploy_stage_update_mode_publishes_new_version(monkeypatch): assert cfg["entryPoint"] == ["main.py"] +@pytest.mark.parametrize("mode", ["create", "update"]) +@pytest.mark.parametrize("kind", ["code_zip", "container_source", "container_image"]) +def test_resumed_deploy_reconciles_the_per_agent_role(monkeypatch, mode, kind): + iam = StubIam() + stub = StubRuntimeControl() + _client_router(monkeypatch, {"iam": iam}) + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws: stub) + monkeypatch.setattr( + byoc_dep, "get_settings", lambda: SimpleNamespace(per_agent_execution_roles=True), + ) + cfg = {"artifact_kind": kind} + cfg.update({"image_uri": ECR_IMAGE} if kind == "container_image" else {"upload_id": "u1"}) + spec = AgentSpec(**_byoc_spec(byoc=cfg)) + agent_id, dep_id = _mk_agent(spec) + workspace = ws_ctx(RESOURCES, account_id="123456789012") + first = _stage_ctx(agent_id, dep_id, workspace) + byoc_dep._stage_provision(first, _get_agent(agent_id)) + role_arn = first.scratch["execution_role_arn"] + if mode == "update": + with SessionLocal() as db: + db.get(Agent, agent_id).resource_id = "rt-1" + db.commit() + + # Restart recreates StageContext while the successful provision is skipped. + resumed = _stage_ctx(agent_id, dep_id, workspace) + resumed.scratch["mode"] = mode + byoc_dep._stage_deploy(resumed, _get_agent(agent_id)) + + payload = stub.updated_with if mode == "update" else stub.created_with + assert payload["roleArn"] == role_arn + assert role_arn != RESOURCES["execution_role_arn"] + assert len(iam.roles) == 1 + assert any(call.startswith("get_role:") for call in iam.calls) + + +def test_resumed_deploy_respects_explicit_shared_role_setting(monkeypatch): + stub = StubRuntimeControl() + monkeypatch.setattr(byoc_dep, "control_client", lambda _ws: stub) + monkeypatch.setattr( + byoc_dep, "get_settings", lambda: SimpleNamespace(per_agent_execution_roles=False), + ) + agent_id, dep_id = _mk_agent(AgentSpec(**_byoc_spec())) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + assert stub.created_with["roleArn"] == RESOURCES["execution_role_arn"] + + +def test_resumed_deploy_does_not_fall_back_after_iam_failure(monkeypatch): + class DeniedIam(StubIam): + def create_role(self, **_kwargs): + raise RuntimeError("IAM access denied") + + _client_router(monkeypatch, {"iam": DeniedIam()}) + monkeypatch.setattr( + byoc_dep, "get_settings", lambda: SimpleNamespace(per_agent_execution_roles=True), + ) + monkeypatch.setattr( + byoc_dep, "control_client", lambda _ws: pytest.fail("must not deploy after IAM failure"), + ) + agent_id, dep_id = _mk_agent(AgentSpec(**_byoc_spec())) + ctx = _stage_ctx(agent_id, dep_id, ws_ctx(RESOURCES)) + with pytest.raises(RuntimeError, match="IAM access denied"): + byoc_dep._stage_deploy(ctx, _get_agent(agent_id)) + + # ── delete path ────────────────────────────────────────────────────────────── def test_delete_removes_runtime_upload_and_images(monkeypatch): diff --git a/backend/tests/test_runtime_endpoints.py b/backend/tests/test_runtime_endpoints.py index 149515ba..d65764d6 100644 --- a/backend/tests/test_runtime_endpoints.py +++ b/backend/tests/test_runtime_endpoints.py @@ -88,13 +88,20 @@ def test_wait_endpoint_ready_times_out(): # ─── invoke qualifier ──────────────────────────────────────────────────────── class StubDataPlane: - def __init__(self, body: bytes): + def __init__(self, body: bytes, *, content_type: str = "application/json"): self.body = body + self.content_type = content_type self.invoked_with: dict | None = None def invoke_agent_runtime(self, **kwargs): self.invoked_with = kwargs - return {"response": SimpleNamespace(read=lambda: self.body)} + return { + "response": SimpleNamespace( + read=lambda: self.body, + iter_lines=lambda **_kwargs: iter(self.body.splitlines()), + ), + "contentType": self.content_type, + } def test_invoke_runtime_text_omits_qualifier_by_default(): @@ -246,16 +253,57 @@ def test_invoke_runtime_text_reads_conventional_text_keys(): def test_invoke_runtime_text_prefers_result_over_other_keys(): - stub = StubDataPlane(b'{"result": "primary", "answer": "ignored"}') + stub = StubDataPlane( + b'{"result": "primary", "answer": "ignored", "metadata": {"tokens": 2}, "error": null}' + ) assert rt.invoke_runtime_text(stub, "arn:rt-1", "hi")["text"] == "primary" +@pytest.mark.parametrize("content_type", ["application/json", "text/event-stream"]) +@pytest.mark.parametrize( + "auxiliary", + [ + {"metadata": {"tokens": 2}}, + {"metadata": {"usage": {"inputTokens": 2}}}, + {"metadata": None}, + {"error": None}, + {"metadata": {"tokens": 2}, "error": None}, + {"event": "report"}, + {"event": {"type": "report"}}, + {"event": None}, + {"contentBlockStart": "auxiliary"}, + {"contentBlockDelta": None}, + {"messageStart": {"role": "assistant"}}, + ], +) +def test_invoke_runtime_text_preserves_answer_with_auxiliary_fields(auxiliary, content_type): + body = json.dumps({"answer": "hello", **auxiliary}) + if content_type == "text/event-stream": + body = f"data: {body}\n\n" + stub = StubDataPlane(body.encode(), content_type=content_type) + + assert rt.invoke_runtime_text(stub, "arn:rt-1", "hi")["text"] == "hello" + + 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]}' +@pytest.mark.parametrize( + "payload", + [ + {"metadata": {"tokens": 2}}, + {"summary": "abc", "metadata": {"usage": {"inputTokens": 2}}, "error": None}, + ], +) +def test_invoke_runtime_text_shows_unknown_json_with_metadata(payload): + body = json.dumps(payload) + out = rt.invoke_runtime_text(StubDataPlane(body.encode()), "arn:rt-1", "hi") + assert out["text"] == body + + def test_invoke_runtime_text_free_form_error_key_still_raises(): with pytest.raises(RuntimeError, match="缺少 prompt"): rt.invoke_runtime_text( @@ -265,12 +313,77 @@ def test_invoke_runtime_text_free_form_error_key_still_raises(): ) -def test_runtime_payload_events_ignores_converse_bookkeeping_events(): +@pytest.mark.parametrize("wrapped", [False, True]) +@pytest.mark.parametrize( + "payload", + [ + {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"start": {}, "contentBlockIndex": 0}}, + {"contentBlockDelta": {"delta": {"toolUse": {"input": "{}"}}}}, + {"contentBlockStop": {"contentBlockIndex": 0}}, + {"messageStop": {"stopReason": "end_turn"}}, + {"metadata": {"usage": {"inputTokens": 1}}}, + {"metadata": {"metrics": {"latencyMs": 10}}}, + {"metadata": {"trace": {}}}, + ], +) +def test_stream_runtime_events_ignores_converse_bookkeeping_events(payload, wrapped): # Converse stream events without text must not be dumped as JSON. - for payload in [ + body = json.dumps({"event": payload} if wrapped else payload) + stub = StubDataPlane(f"data: {body}\n\n".encode(), content_type="text/event-stream") + + assert list(rt.stream_runtime_events(stub, "arn:rt-1", "hi")) == [] + + +@pytest.mark.parametrize("wrapped", [False, True]) +def test_stream_runtime_events_preserves_converse_text_and_tools(wrapped): + payloads = [ {"messageStart": {"role": "assistant"}}, + {"contentBlockStart": {"start": {"toolUse": {"name": "search", "toolUseId": "t1"}}}}, + {"contentBlockDelta": {"delta": {"text": "hello"}}}, {"contentBlockStop": {"contentBlockIndex": 0}}, {"messageStop": {"stopReason": "end_turn"}}, {"metadata": {"usage": {"inputTokens": 1}}}, - ]: - assert list(rt._runtime_payload_events(payload)) == [], payload + ] + body = "".join( + f"data: {json.dumps({'event': payload} if wrapped else payload)}\n\n" + for payload in payloads + ) + stub = StubDataPlane(body.encode(), content_type="text/event-stream") + + assert list(rt.stream_runtime_events(stub, "arn:rt-1", "hi")) == [ + {"event": "tool", "data": {"name": "search", "id": "t1"}}, + {"event": "delta", "data": {"text": "hello"}}, + ] + assert rt.invoke_runtime_text(stub, "arn:rt-1", "hi")["text"] == "hello" + + +def test_invoke_runtime_text_uses_native_complete_without_deltas(): + stub = StubDataPlane( + b'data: {"event":"heartbeat"}\n\n' + b'data: {"event":"complete","result":"hello","answer":"ignored"}\n\n', + content_type="text/event-stream", + ) + assert rt.invoke_runtime_text(stub, "arn:rt-1", "hi")["text"] == "hello" + + +@pytest.mark.parametrize( + "error", + [ + {"error": "boom", "answer": "ignored"}, + {"event": "error", "message": "boom"}, + {"runtimeClientError": {"message": "boom"}}, + {"internalServerException": {"message": "boom"}}, + {"event": {"runtimeClientError": {"message": "boom"}}}, + {"event": {"internalServerException": {"message": "boom"}}}, + ], +) +def test_stream_runtime_events_raises_real_error_after_partial_text(error): + body = 'data: {"event":"delta","text":"partial"}\n\n' + body += f"data: {json.dumps(error)}\n\n" + stub = StubDataPlane(body.encode(), content_type="text/event-stream") + stream = rt.stream_runtime_events(stub, "arn:rt-1", "hi") + + assert next(stream) == {"event": "delta", "data": {"text": "partial"}} + with pytest.raises(RuntimeError, match="boom"): + next(stream) diff --git a/docs/architecture.md b/docs/architecture.md index 8a01a0ac..8f686a38 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -295,7 +295,9 @@ by its first conventional text key (`response`, `answer`, `output`, `text`, 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 +Auxiliary `metadata` or `error: null` fields do not suppress a reply; actual +Converse bookkeeping events remain silent and non-empty `error` values surface +as failed turns. Three artifact kinds, one `spec.byoc` block (`backend/app/schemas/agent.py::ByocConfig`): | `artifact_kind` | Input | Path to Runtime | @@ -311,7 +313,11 @@ BYOC container kinds additionally get `ecr:BatchGetImage`/`GetDownloadUrlForLaye 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 +never a model wildcard. Literal IDs, foundation-model ARNs and system +inference-profile ARNs are supported. Wildcards, IAM variables and unsupported +ARN kinds (including application inference profiles) are rejected before +deployment; an unknown custom ID stays an exact resource rather than granting +all foundation models. 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 @@ -319,6 +325,13 @@ Re-publish rewrites the role policy, so an edited list lands with the deploy. Up provenance (sha256, size, filename, uploader, time) onto the spec — the console renders it on the agent detail view. +Each source packaging attempt owns a private temporary directory, removed after +the upload/build finishes, so same-named agents in different workspaces cannot +overwrite one another's sources. If deployment resumes after provision, it +reconciles the per-agent role again before calling Runtime; losing process-local +scratch state never selects the shared role. The shared role is used only when +the operator explicitly disables `per_agent_execution_roles`. + **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 diff --git a/samples/byoc/README.md b/samples/byoc/README.md index f9709e95..e8dc7e11 100644 --- a/samples/byoc/README.md +++ b/samples/byoc/README.md @@ -22,6 +22,9 @@ 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`. +Selections accept literal IDs, foundation-model ARNs and system inference-profile +ARNs. Wildcards, IAM variables and application inference-profile ARNs are refused; +an unknown ID never grants access to all foundation models. ## hello-http — artifact kind `code_zip`