diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd8136f..a56eff5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -44,7 +44,13 @@ jobs: run: uv sync --dev - name: Coverage-backed unit + integration suite - run: make test-cov + run: >- + uv run pytest tests/unit tests/integration + -m 'not docker' + --cov=src/agentseek_api + --cov-report=term-missing + --cov-fail-under=90 + -q cli-compatibility: name: CLI Compatibility (${{ matrix.os }}) @@ -78,9 +84,7 @@ jobs: run: uv run python scripts/test_cli_config_autodiscovery.py - name: Dockerfile command renders a runnable config - run: >- - uv run python -c - "from pathlib import Path; import subprocess; out = Path('.tmp/agentseek.Dockerfile'); out.parent.mkdir(exist_ok=True); subprocess.run(['uv', 'run', 'agentseek-api', 'dockerfile', '--config', 'examples/external_graph/manifest.json', str(out)], check=True); text = out.read_text(encoding='utf-8'); assert 'ENV PYTHONPATH=/deps/agent' in text; assert 'ENV AGENTSEEK_GRAPHS=/deps/agent/examples/external_graph/manifest.json' in text" + run: uv run python scripts/test_cli_config_autodiscovery.py --config examples/external_graph/manifest.json - name: CLI config, host environment, and process tests run: >- @@ -92,6 +96,8 @@ jobs: tests/unit/test_runtime_entrypoint.py tests/unit/test_process_supervisor.py tests/unit/test_sqlite_checkpointer.py + tests/unit/test_container_policy.py + tests/unit/test_secure_temp.py tests/integration/test_cli_runtime_processes.py tests/integration/test_metadata_db_config.py -q @@ -130,9 +136,15 @@ jobs: run: uv run python scripts/test_cli_embed_serve_smoke.py cli-docker-runtime: - name: CLI Docker Runtime + name: CLI Docker Runtime (Compose ${{ matrix.compose-version }}) runs-on: ubuntu-latest - timeout-minutes: 30 + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + compose-version: + - runner-current + - v2.24.0 steps: - name: Checkout uses: actions/checkout@v4 @@ -147,6 +159,21 @@ jobs: - name: Sync dependencies run: uv sync --dev + - name: Install Compose floor + if: matrix.compose-version == 'v2.24.0' + uses: docker/setup-compose-action@2fe291b7677a45ee1269ec56a42604c143505e7e # v1 + with: + version: v2.24.0 + + - name: Record Compose version + run: docker compose version --short + + - name: Container environment boundary + run: uv run python scripts/test_container_env_boundary.py + + - name: Docker and Compose regression suite + run: uv run pytest tests/unit/test_docker_runtime.py -m docker -q + - name: CLI Docker smoke run: make test-cli-docker diff --git a/CHANGELOG.md b/CHANGELOG.md index 31808b7..008239c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,45 @@ Notable changes to AgentSeek API are documented in this file. ## Unreleased +## 0.3.0 - 2026-08-20 + +### Highlights + +- Added the explicit `preloaded-v1` container environment contract so the host + remains the single owner of application environment resolution. +- Generated containers install and attest the exact + `agentseek-api[embedded]==0.3.0` runtime independently of project + dependencies. +- Hardened Docker, Compose, and build-context boundaries so application values + are carried only to the declared runtime target and credential-bearing source + files are excluded from image inputs. +- Defined compatible custom-image labels and runtime-manifest checks for + `preloaded-v1` launches. + +### Upgrade notes + +- Container projects that pin `agentseek-api==0.2.2`, or otherwise exclude + `0.3.0`, must update their dependency constraint before using generated + images. +- Custom images must publish the `preloaded-v1` environment-contract, runtime + manifest, distribution, and version labels; older images must keep using the + older launcher until migrated. +- `dockerfile` now writes a complete build-bundle directory. Declare only + reviewed project paths with `build_include`; credentialed `pip_config_file` + input is delivered as a BuildKit secret rather than copied into the bundle. +- Container application values cross only an explicit runtime boundary: + `--pass-env` selects the direct Docker carrier, while `compose_env` and + `--compose-pass-env` select the private Compose dotenv carrier. All are + trusted-input declarations and none authorize values to enter the build. +- Custom images must provide the exact `org.agentseek.environment-contract`, + `org.agentseek.runtime-manifest`, `org.agentseek.runtime-distribution`, and + `org.agentseek.runtime-version` labels and matching manifest/runtime state. + There is no legacy-image fallback. +- Release Train A shipped API 0.2.3, templates 0.1.3, and AgentSeek 0.1.3. The + planned Train B template/catalog release is 0.1.4. The separate planned + AgentSeek release is also 0.1.4; both follow their shipped 0.1.3 releases and + remain separate release gates. + ## 0.2.3 - 2026-08-17 ### Fixed diff --git a/README.md b/README.md index 3e2606d..5ca4c2e 100644 --- a/README.md +++ b/README.md @@ -296,7 +296,7 @@ keys with similar names. uv run agentseek-api dev uv run agentseek-api serve --config ./langgraph.json --port 8080 uv run agentseek-api worker --config ./langgraph.json -uv run agentseek-api dockerfile --config ./langgraph.json ./Dockerfile.agentseek +uv run agentseek-api dockerfile --config ./langgraph.json ./agentseek-build-bundle uv run agentseek-api build --config ./langgraph.json -t agentseek-api:dev uv run agentseek-api up --config ./langgraph.json --port 8123 --wait uv run agentseek-api version @@ -323,9 +323,18 @@ uv run agentseek-api version - `build` - Use `-t, --tag` to set the image tag - Supports `--platform`, `--pull`, and `--no-pull` +- `dockerfile` + - Writes a complete private build-bundle directory, including the generated + `Dockerfile`, sanitized runtime manifest, and selected project files; the + output argument is a new directory, not a standalone Dockerfile path - `up` - Supports `--wait`, `--image`, `--base-image`, `--postgres-uri`, `--recreate`, and `--no-recreate` + - `--pass-env NAME` explicitly selects a resolved application value for the + direct Docker carrier; the value is inherited by name and never put in argv + - `--compose-pass-env NAME` explicitly selects a resolved application value + for Compose interpolation; config can make the same selection with + `compose_env` Some LangGraph CLI-shaped flags are parsed for command compatibility but rejected when their runtime behavior is not implemented yet. For mocked, @@ -404,6 +413,40 @@ Useful config fields: - `http.disable_a2a`: disable the A2A endpoint and agent-card discovery route - `base_image`, `python_version`, `image_distro`, `pip_config_file`, `dockerfile_lines`: Docker build customization fields +- `build_include`: additional trusted regular files or directory trees to copy + into the sanitized build bundle +- `compose_env`: names from the already-resolved application environment that + may cross into an explicitly selected Compose dotenv carrier + +### Container migration for 0.3.0 + +The `preloaded-v1` contract is a breaking, fail-closed container boundary. The +host resolves application configuration once. Containers started from generated images receive only +the selected runtime payload, while ambient host values, dotenv files, package +credentials, and unselected Compose values stay outside the build context and +image layers. Use `--pass-env` or `--compose-pass-env` only for trusted input; +these flags authorize a value to cross the named runtime boundary, not to enter +the build. + +`build_include` is also a trusted-input declaration: review every selected path. +Credentialed Python indexes belong in `pip_config_file`, which is mounted as a +BuildKit pip secret and is not copied into the context. The `dockerfile` command +now writes the complete bundle directory consumed by Docker rather than a lone +Dockerfile. + +Custom images must expose all four exact labels: + +- `org.agentseek.environment-contract=preloaded-v1` +- `org.agentseek.runtime-manifest=/opt/agentseek/manifest.v1.json` +- `org.agentseek.runtime-distribution=agentseek-api` +- `org.agentseek.runtime-version=0.3.0` + +The manifest, installed distribution, entrypoint, and labels must agree. There +is no legacy-image fallback: migrate and attest the image before passing it to +`up --image`, or keep using the older launcher with the older image. Release +Train A coordinates are API 0.2.3, templates 0.1.3, and AgentSeek 0.1.3; the +The planned Train B template/catalog release is 0.1.4. The separate planned +AgentSeek release is also 0.1.4; both follow the shipped 0.1.3 releases. Endpoint-level LangGraph config keys such as `http` and `api_version` are tolerated by the CLI layer where possible. Store config is used by the HTTP diff --git a/README.zh-CN.md b/README.zh-CN.md index 9705a2d..f9a8624 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -262,7 +262,7 @@ agentseek-api [arguments] uv run agentseek-api dev uv run agentseek-api serve --config ./langgraph.json --port 8080 uv run agentseek-api worker --config ./langgraph.json -uv run agentseek-api dockerfile --config ./langgraph.json ./Dockerfile.agentseek +uv run agentseek-api dockerfile --config ./langgraph.json ./agentseek-build-bundle uv run agentseek-api build --config ./langgraph.json -t agentseek-api:dev uv run agentseek-api up --config ./langgraph.json --port 8123 --wait uv run agentseek-api version @@ -289,9 +289,17 @@ uv run agentseek-api version - `build` - 使用 `-t, --tag` 设置镜像 tag - 支持 `--platform`、`--pull`、`--no-pull` +- `dockerfile` + - 输出完整的私有构建 bundle 目录,其中包含生成的 `Dockerfile`、净化后的 + runtime manifest 与选中的项目文件;输出参数必须是新目录,而不是单个 + Dockerfile 文件路径 - `up` - 支持 `--wait`、`--image`、`--base-image`、`--postgres-uri`、 `--recreate`、`--no-recreate` + - `--pass-env NAME` 显式选择一个已解析的应用变量,通过名称继承方式交给 + 直接 Docker carrier,变量值不会进入 argv + - `--compose-pass-env NAME` 显式选择一个已解析的应用变量交给 Compose; + 配置文件中的 `compose_env` 提供同样的选择能力 部分仿照 LangGraph CLI 的参数会为了命令兼容性被解析,但当对应运行时 行为还未实现时会被直接拒绝。对于 mock、内存或 tunnel 化的本地工作流, @@ -371,6 +379,35 @@ Redis 实例同时运行。 - `http.disable_a2a`:关闭 A2A 端点及 agent-card 发现路由 - `base_image`、`python_version`、`image_distro`、`pip_config_file`、 `dockerfile_lines`:Docker 构建自定义字段 +- `build_include`:额外复制到净化构建 bundle 中的受信任普通文件或目录树 +- `compose_env`:允许通过显式 Compose dotenv carrier 的、已完成解析的应用 + 环境变量名称 + +### 0.3.0 容器迁移 + +`preloaded-v1` 是不兼容旧行为、失败即关闭的容器边界。宿主机只解析一次 +应用配置。从生成镜像启动的容器只接收显式选择的运行时 payload;宿主机环境、dotenv +文件、包仓库凭证和未选择的 Compose 值都不会进入构建上下文或镜像层。 +`--pass-env` 与 `--compose-pass-env` 只应接收可信输入:它们授权变量跨越指定 +运行时边界,并不允许变量进入镜像构建。 + +`build_include` 同样属于可信输入声明,必须审查每条路径。带凭证的 Python +仓库配置应使用 `pip_config_file`,CLI 会将其作为 BuildKit pip secret 挂载, +不会复制到上下文。`dockerfile` 命令现在输出 Docker 实际消费的完整 bundle +目录,而不是单独的 Dockerfile。 + +自定义镜像必须提供以下四个精确标签: + +- `org.agentseek.environment-contract=preloaded-v1` +- `org.agentseek.runtime-manifest=/opt/agentseek/manifest.v1.json` +- `org.agentseek.runtime-distribution=agentseek-api` +- `org.agentseek.runtime-version=0.3.0` + +manifest、已安装 distribution、entrypoint 与标签必须一致。系统不提供旧镜像 +回退:传给 `up --image` 前必须完成迁移与校验;否则应继续用旧 launcher 配合 +旧镜像。Train A 已达成版本为 API 0.2.3、templates 0.1.3、AgentSeek 0.1.3; +后续 Train B 的 template/catalog 计划单独发布 0.1.4。AgentSeek 也计划另行发布 +0.1.4;两者都基于已经发布的 0.1.3。 CLI 层会尽量容忍 LangGraph 在端点级别使用的配置键,例如 `http` 与 `api_version`。Store 配置会被 HTTP Store API 以及注入的 LangGraph diff --git a/pyproject.toml b/pyproject.toml index 236f1a9..9579044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "agentseek-api" -version = "0.2.3" +version = "0.3.0" description = "AgentSeek API core runtime with OceanBase checkpoints." readme = "README.md" requires-python = ">=3.12" @@ -27,6 +27,7 @@ dependencies = [ "mcp>=1.27.1,<2", "python-dotenv>=1.0,<1.3", "scalar-fastapi>=1.0.3", + "packaging>=24.0", ] [project.optional-dependencies] @@ -60,5 +61,6 @@ packages = ["src/agentseek_api"] asyncio_mode = "auto" testpaths = ["tests"] markers = [ + "docker: tests requiring Docker or Docker Compose", "e2e: end-to-end tests against a live server and real SeekDB/OceanBase backend", ] diff --git a/scripts/container_image_archive.py b/scripts/container_image_archive.py new file mode 100644 index 0000000..eeb1b74 --- /dev/null +++ b/scripts/container_image_archive.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Fail-closed structural scanning for Docker-save and OCI image archives.""" + +from __future__ import annotations + +import argparse +import gzip +import hashlib +import io +import json +import os +import posixpath +import tarfile +from collections.abc import Mapping +from pathlib import Path +from typing import Any + + +class ImageArchiveError(RuntimeError): + """A value-free image archive validation failure.""" + + +_OCI_CONFIG_MEDIA_TYPES = { + "application/vnd.oci.image.config.v1+json", + "application/vnd.docker.container.image.v1+json", +} +_OCI_MANIFEST_MEDIA_TYPES = { + "application/vnd.oci.image.manifest.v1+json", + "application/vnd.docker.distribution.manifest.v2+json", +} +_OCI_LAYER_ENCODINGS = { + "application/vnd.oci.image.layer.v1.tar": "tar", + "application/vnd.oci.image.layer.v1.tar+gzip": "gzip", + "application/vnd.oci.image.layer.v1.tar+zstd": "zstd", + "application/vnd.docker.image.rootfs.diff.tar": "tar", + "application/vnd.docker.image.rootfs.diff.tar.gzip": "gzip", +} + + +def _safe_name(name: str) -> bool: + return ( + bool(name) + and not name.startswith("/") + and posixpath.normpath(name) == name + and not any(part in {"", ".", ".."} for part in name.split("/")) + ) + + +def _canonical_tar_name(name: str) -> str | None: + if not name or "\x00" in name or name.startswith("/"): + return None + trimmed = name.rstrip("/") + if any(part == ".." for part in trimmed.split("/")): + return None + normalized = posixpath.normpath(trimmed) + if normalized == ".." or normalized.startswith("../"): + return None + return normalized + + +def _json_object(payload: bytes, boundary: str) -> Mapping[str, Any]: + try: + value = json.loads(payload) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ImageArchiveError(f"{boundary} JSON was malformed") from exc + if not isinstance(value, dict): + raise ImageArchiveError(f"{boundary} JSON shape was invalid") + return value + + +def _tar_files(payload: bytes, boundary: str) -> dict[str, bytes]: + files: dict[str, bytes] = {} + names: set[str] = set() + try: + with tarfile.open(fileobj=io.BytesIO(payload), mode="r:") as archive: + for member in archive: + name = _canonical_tar_name(member.name) + if name is None or (name == "." and not member.isdir()): + raise ImageArchiveError(f"{boundary} member path was unsafe") + if name == ".": + continue + if name in names: + raise ImageArchiveError(f"{boundary} contained duplicate members") + names.add(name) + if member.isfile(): + stream = archive.extractfile(member) + if stream is None: + raise ImageArchiveError(f"{boundary} member was unreadable") + files[name] = stream.read() + except ImageArchiveError: + raise + except (tarfile.TarError, OSError, EOFError) as exc: + raise ImageArchiveError(f"{boundary} was malformed") from exc + return files + + +def _require_file( + files: Mapping[str, bytes], reference: object, boundary: str +) -> bytes: + if not isinstance(reference, str) or not _safe_name(reference): + raise ImageArchiveError(f"{boundary} reference path was unsafe") + if reference not in files: + raise ImageArchiveError(f"{boundary} reference was missing") + return files[reference] + + +def _scan_forbidden(payload: bytes, forbidden: bytes) -> None: + if not forbidden: + raise ImageArchiveError("forbidden byte sequence was empty") + if forbidden in payload: + raise ImageArchiveError("image surface contained forbidden bytes") + + +def _decode_layer(payload: bytes, encoding: str | None = None) -> bytes: + if encoding is None: + if payload.startswith(b"\x1f\x8b"): + encoding = "gzip" + elif payload.startswith(b"\x28\xb5\x2f\xfd"): + encoding = "zstd" + else: + encoding = "tar" + if encoding == "gzip": + try: + return gzip.decompress(payload) + except (OSError, EOFError) as exc: + raise ImageArchiveError("image layer compression was malformed") from exc + if encoding == "zstd": + try: + import zstandard + except ImportError as exc: + raise ImageArchiveError("zstd layer support was unavailable") from exc + try: + return zstandard.ZstdDecompressor().decompress(payload) + except zstandard.ZstdError as exc: + raise ImageArchiveError("image layer compression was malformed") from exc + if encoding == "tar": + return payload + raise ImageArchiveError("image layer compression was unsupported") + + +def _scan_layer( + payload: bytes, forbidden: bytes, *, encoding: str | None = None +) -> None: + decoded = _decode_layer(payload, encoding) + _scan_forbidden(decoded, forbidden) + for member_payload in _tar_files(decoded, "image layer").values(): + _scan_forbidden(member_payload, forbidden) + + +def _scan_docker_save(files: Mapping[str, bytes], forbidden: bytes) -> None: + try: + manifest = json.loads( + _require_file(files, "manifest.json", "Docker save manifest") + ) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise ImageArchiveError("Docker save manifest JSON was malformed") from exc + if ( + not isinstance(manifest, list) + or len(manifest) != 1 + or not isinstance(manifest[0], dict) + ): + raise ImageArchiveError("Docker save manifest shape was unsupported") + entry = manifest[0] + config_reference = entry.get("Config") + layer_references = entry.get("Layers") + if not isinstance(layer_references, list) or not layer_references: + raise ImageArchiveError("Docker save layer references were invalid") + if ( + not isinstance(config_reference, str) + or any(not isinstance(reference, str) for reference in layer_references) + or config_reference in layer_references + ): + raise ImageArchiveError("Docker save references were invalid") + config = _require_file(files, config_reference, "Docker save config") + _json_object(config, "Docker save config") + _scan_forbidden(config, forbidden) + for reference in dict.fromkeys(layer_references): + _scan_layer(_require_file(files, reference, "Docker save layer"), forbidden) + + +def _oci_blob( + files: Mapping[str, bytes], + descriptor: object, + media_types: set[str], + boundary: str, +) -> tuple[bytes, str]: + if not isinstance(descriptor, dict): + raise ImageArchiveError(f"{boundary} descriptor shape was invalid") + digest = descriptor.get("digest") + size = descriptor.get("size") + media_type = descriptor.get("mediaType") + if ( + not isinstance(digest, str) + or not digest.startswith("sha256:") + or len(digest) != 71 + ): + raise ImageArchiveError(f"{boundary} digest was invalid") + hexadecimal = digest.removeprefix("sha256:") + if any(character not in "0123456789abcdef" for character in hexadecimal): + raise ImageArchiveError(f"{boundary} digest was invalid") + if not isinstance(size, int) or size < 0 or media_type not in media_types: + raise ImageArchiveError(f"{boundary} descriptor was unsupported") + payload = _require_file(files, f"blobs/sha256/{hexadecimal}", boundary) + if len(payload) != size or hashlib.sha256(payload).hexdigest() != hexadecimal: + raise ImageArchiveError(f"{boundary} blob integrity failed") + return payload, media_type + + +def _scan_oci(files: Mapping[str, bytes], forbidden: bytes) -> None: + layout = _json_object( + _require_file(files, "oci-layout", "OCI layout"), "OCI layout" + ) + if layout.get("imageLayoutVersion") != "1.0.0": + raise ImageArchiveError("OCI layout version was unsupported") + index = _json_object(_require_file(files, "index.json", "OCI index"), "OCI index") + manifests = index.get("manifests") + if not isinstance(manifests, list) or len(manifests) != 1: + raise ImageArchiveError("OCI index manifest selection was unsupported") + manifest_bytes, _ = _oci_blob( + files, manifests[0], _OCI_MANIFEST_MEDIA_TYPES, "OCI manifest" + ) + manifest = _json_object(manifest_bytes, "OCI manifest") + config_descriptor = manifest.get("config") + config_bytes, _ = _oci_blob( + files, config_descriptor, _OCI_CONFIG_MEDIA_TYPES, "OCI config" + ) + _json_object(config_bytes, "OCI config") + _scan_forbidden(config_bytes, forbidden) + layers = manifest.get("layers") + if not isinstance(layers, list) or not layers: + raise ImageArchiveError("OCI layer descriptors were invalid") + config_digest = ( + config_descriptor.get("digest") if isinstance(config_descriptor, dict) else None + ) + descriptors: dict[str, Mapping[str, Any]] = {} + for descriptor in layers: + if not isinstance(descriptor, dict): + raise ImageArchiveError("OCI layer references were invalid") + digest = descriptor.get("digest") + if not isinstance(digest, str) or digest == config_digest: + raise ImageArchiveError("OCI layer references were invalid") + previous = descriptors.get(digest) + if previous is not None: + if descriptor != previous: + raise ImageArchiveError("OCI layer references conflicted") + continue + descriptors[digest] = descriptor + layer_bytes, media_type = _oci_blob( + files, descriptor, set(_OCI_LAYER_ENCODINGS), "OCI layer" + ) + _scan_layer(layer_bytes, forbidden, encoding=_OCI_LAYER_ENCODINGS[media_type]) + + +def scan_image_archive( + archive: bytes | Path, *, forbidden: bytes, history: bytes +) -> None: + """Validate and scan every referenced image surface without exposing values.""" + + try: + payload = archive.read_bytes() if isinstance(archive, Path) else bytes(archive) + except OSError as exc: + raise ImageArchiveError("image archive could not be read") from exc + files = _tar_files(payload, "image archive") + _scan_forbidden(history, forbidden) + recognized = False + if "manifest.json" in files: + _scan_docker_save(files, forbidden) + recognized = True + if "oci-layout" in files or "index.json" in files: + _scan_oci(files, forbidden) + recognized = True + if not recognized: + raise ImageArchiveError("image archive format was unsupported") + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--archive", type=Path, required=True) + parser.add_argument("--history", type=Path, required=True) + args = parser.parse_args(argv) + sentinel = os.environ.get("AGENTSEEK_IMAGE_SCAN_SENTINEL") + if not sentinel: + raise SystemExit("image archive sentinel was unavailable") + try: + scan_image_archive( + args.archive, + forbidden=sentinel.encode(), + history=args.history.read_bytes(), + ) + except (ImageArchiveError, OSError): + raise SystemExit("image archive boundary verification failed") from None + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh old mode 100644 new mode 100755 index 3998042..70e262d --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -3,97 +3,326 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" cd "$ROOT_DIR" +umask 077 -IMAGE_TAG="${IMAGE_TAG:-agentseek-api-cli-smoke:latest}" -DB_CONTAINER="${DB_CONTAINER:-agentseek-cli-mysql}" +IMAGE_TAG="${IMAGE_TAG:-agentseek-api-cli-smoke:0.3.0}" APP_CONTAINER="${APP_CONTAINER:-agentseek-up-8123}" -APP_CONTAINER_AUTOBUILD="${APP_CONTAINER_AUTOBUILD:-agentseek-up-8124}" -PG_CONTAINER="${PG_CONTAINER:-agentseek-cli-postgres}" -TMP_DIR="${TMP_DIR:-$ROOT_DIR/.tmp/cli-docker}" +TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agentseek-cli-docker.XXXXXX")" +PROJECT_DIR="$TMP_DIR/source-only-project" +BUNDLE_DIR="$TMP_DIR/build-bundle" +BUNDLE_CONTEXT="$BUNDLE_DIR/context" +CANDIDATE_DIR="$PROJECT_DIR/candidate-dist" +IMAGE_ARCHIVE="$TMP_DIR/image.tar" +HISTORY_FILE="$TMP_DIR/image.history" +BUILD_LOG="$TMP_DIR/build.log" +WHEEL_BUILD_LOG="$TMP_DIR/wheel-build.log" +BUILD_SENTINEL="$(uv run python -c 'import secrets; print(secrets.token_urlsafe(24))')" +IMAGE_OWNED=0 +CONTAINER_OWNED=0 + +container_id() { + docker container ls --all --filter "name=^/${APP_CONTAINER}$" --format '{{.ID}}' +} + +image_id() { + docker image ls --all --no-trunc --filter "reference=${IMAGE_TAG}" --format '{{.ID}}' +} cleanup() { - docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || true - docker rm -f "$APP_CONTAINER_AUTOBUILD" >/dev/null 2>&1 || true - docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true - docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 || true + local status=$? + local cleanup_failed=0 + local remaining="" + trap - EXIT + set +e + if [[ "$CONTAINER_OWNED" -eq 1 ]]; then + remaining="$(container_id 2>/dev/null)" || cleanup_failed=1 + if [[ -n "$remaining" ]]; then + docker rm -f "$APP_CONTAINER" >/dev/null 2>&1 || cleanup_failed=1 + fi + remaining="$(container_id 2>/dev/null)" || cleanup_failed=1 + if [[ -n "$remaining" ]]; then + cleanup_failed=1 + fi + fi + if [[ "$IMAGE_OWNED" -eq 1 ]]; then + remaining="$(image_id 2>/dev/null)" || cleanup_failed=1 + if [[ -n "$remaining" ]]; then + docker image rm -f "$IMAGE_TAG" >/dev/null 2>&1 || cleanup_failed=1 + fi + remaining="$(image_id 2>/dev/null)" || cleanup_failed=1 + if [[ -n "$remaining" ]]; then + cleanup_failed=1 + fi + fi + rm -rf -- "$TMP_DIR" || cleanup_failed=1 + if [[ -e "$TMP_DIR" ]]; then + cleanup_failed=1 + fi + if [[ "$status" -eq 0 && "$cleanup_failed" -ne 0 ]]; then + echo "Owned container resource cleanup boundary failed." >&2 + exit 1 + fi + exit "$status" } print_logs() { - docker logs "$APP_CONTAINER" || true - docker logs "$APP_CONTAINER_AUTOBUILD" || true - docker logs "$DB_CONTAINER" || true - docker logs "$PG_CONTAINER" || true + local existing="" + existing="$(container_id 2>/dev/null)" || return 0 + if [[ -n "$existing" ]]; then + docker logs "$APP_CONTAINER" || true + fi +} + +print_up_log() { + uv run python scripts/value_free_log_tail.py \ + "$TMP_DIR/up.log" \ + "$PROJECT_DIR/application.env" >&2 || true } trap cleanup EXIT -mkdir -p "$TMP_DIR" +mkdir -m 700 -p "$PROJECT_DIR" "$CANDIDATE_DIR" + +cat >"$PROJECT_DIR/graph.py" <<'PY' +from __future__ import annotations + +from langchain_core.messages import AIMessage +from langgraph.graph import END, START, MessagesState, StateGraph + + +async def respond(state: MessagesState) -> dict: + text = state["messages"][-1].content if state["messages"] else "" + return {"messages": [AIMessage(content=f"external graph heard: {text}")]} + + +def build_graph(checkpointer=None): + builder = StateGraph(MessagesState) + builder.add_node("respond", respond) + builder.add_edge(START, "respond") + builder.add_edge("respond", END) + return builder.compile(name="Source Only Graph", checkpointer=checkpointer) +PY + +cat >"$PROJECT_DIR/auth_backend.py" <<'PY' +from langgraph_sdk import Auth + +HeaderAuthBackend = Auth() + -cat >"$TMP_DIR/up.env" <<'EOF' +@HeaderAuthBackend.authenticate +async def authenticate(headers: dict[bytes, bytes]) -> dict: + raw = headers.get(b"x-user-id", b"default_user") + identity = raw.decode() if isinstance(raw, bytes) else str(raw) + return {"identity": identity} + + +@HeaderAuthBackend.on.threads.create +async def on_threads_create(ctx: Auth.types.AuthContext, value: dict) -> None: + value.setdefault("metadata", {})["owner"] = ctx.user.identity + + +@HeaderAuthBackend.on.threads.read +async def on_threads_read(ctx: Auth.types.AuthContext, value: dict) -> dict: + return {"owner": ctx.user.identity} + + +@HeaderAuthBackend.on.threads.update +async def on_threads_update(ctx: Auth.types.AuthContext, value: dict) -> dict: + return {"owner": ctx.user.identity} + + +@HeaderAuthBackend.on.threads.delete +async def on_threads_delete(ctx: Auth.types.AuthContext, value: dict) -> dict: + return {"owner": ctx.user.identity} + + +@HeaderAuthBackend.on.threads.search +async def on_threads_search(ctx: Auth.types.AuthContext, value: dict) -> dict: + return {"owner": ctx.user.identity} +PY + +cat >"$PROJECT_DIR/application.env" <"$PROJECT_DIR/agentseek.json" <<'JSON' +{ + "dependencies": ["packaging==25.0"], + "graphs": {"external_hello": "./graph.py:build_graph"}, + "auth": {"path": "./auth_backend.py:HeaderAuthBackend"}, + "env": "application.env", + "dockerfile_lines": [ + "RUN [\"python\", \"-c\", \"import pathlib; pathlib.Path('/tmp/custom-boundary').write_text('ok')\"]" + ] +} +JSON -uv run agentseek-api dockerfile --config "$CONFIG_PATH" "$TMP_DIR/agentseek.Dockerfile" -test -s "$TMP_DIR/agentseek.Dockerfile" +cat >"$PROJECT_DIR/launch.json" <<'JSON' +{ + "dependencies": [], + "graphs": {"external_hello": "./graph.py:build_graph"}, + "auth": {"path": "auth_backend:HeaderAuthBackend"}, + "env": "application.env" +} +JSON -uv run agentseek-api build --config "$CONFIG_PATH" -t "$IMAGE_TAG" +if ! uv build --wheel --out-dir "$CANDIDATE_DIR" >"$WHEEL_BUILD_LOG" 2>&1; then + echo "Candidate wheel build failed." >&2 + exit 1 +fi -docker rm -f "$DB_CONTAINER" >/dev/null 2>&1 || true -docker run -d --rm \ - --name "$DB_CONTAINER" \ - -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \ - -e MYSQL_DATABASE=seekdb \ - -p 3306:3306 \ - mysql:8.4 >/dev/null +CANDIDATE_WHEELS=("$CANDIDATE_DIR"/agentseek_api-0.3.0-*.whl) +if [[ "${#CANDIDATE_WHEELS[@]}" -ne 1 || ! -f "${CANDIDATE_WHEELS[0]}" ]]; then + echo "Candidate wheel selection failed." >&2 + exit 1 +fi +CANDIDATE_WHEEL="${CANDIDATE_WHEELS[0]}" +CANDIDATE_SHA256="$(shasum -a 256 "$CANDIDATE_WHEEL" | awk '{print $1}')" -docker rm -f "$PG_CONTAINER" >/dev/null 2>&1 || true -docker run -d --rm \ - --name "$PG_CONTAINER" \ - -e POSTGRES_USER=postgres \ - -e POSTGRES_PASSWORD=postgres \ - -e POSTGRES_DB=agentseek \ - -p 5432:5432 \ - postgres:16 >/dev/null +uv run python - "$PROJECT_DIR/agentseek.json" "$BUNDLE_DIR" "$CANDIDATE_WHEEL" "$CANDIDATE_SHA256" >"$TMP_DIR/bundle.log" <<'PY' +from pathlib import Path +import sys -for _ in $(seq 1 60); do - if docker exec "$DB_CONTAINER" mysqladmin ping -h 127.0.0.1 --silent >/dev/null 2>&1; then - break - fi - sleep 2 -done +from agentseek_api.cli import main +from agentseek_api.container_build import candidate_runtime_artifact -if ! docker exec "$DB_CONTAINER" mysqladmin ping -h 127.0.0.1 --silent >/dev/null 2>&1; then - print_logs - echo "MySQL container did not become ready." >&2 +config, bundle, wheel = (Path(value) for value in sys.argv[1:4]) +artifact = candidate_runtime_artifact(wheel, sys.argv[4]) +raise SystemExit( + main( + ["dockerfile", "--config", str(config), str(bundle)], + cwd=config.parent, + runtime_artifact=artifact, + ) +) +PY + +if [[ ! -d "$BUNDLE_CONTEXT" || ! -s "$BUNDLE_CONTEXT/Dockerfile" ]]; then + echo "Private build bundle was not produced." >&2 exit 1 fi -for _ in $(seq 1 60); do - if docker exec "$PG_CONTAINER" pg_isready -U postgres -d agentseek >/dev/null 2>&1; then - break - fi - sleep 2 -done +uv run python - "$BUNDLE_DIR" "$BUILD_SENTINEL" <<'PY' +import hashlib +import json +from pathlib import Path +import sys -if ! docker exec "$PG_CONTAINER" pg_isready -U postgres -d agentseek >/dev/null 2>&1; then - print_logs - echo "PostgreSQL container did not become ready." >&2 +bundle = Path(sys.argv[1]) +sentinel = sys.argv[2].encode() +context = bundle / "context" +dockerfile_path = context / "Dockerfile" +manifest_path = context / "manifest.v1.json" +inventory_path = bundle / "inventory.json" +if not all(path.is_file() for path in (dockerfile_path, manifest_path, inventory_path)): + raise SystemExit("Private build bundle metadata was not produced") +dockerfile_bytes = dockerfile_path.read_bytes() +dockerfile = dockerfile_bytes.decode("utf-8") +manifest = json.loads(manifest_path.read_text(encoding="utf-8")) +inventory = json.loads(inventory_path.read_text(encoding="utf-8")) +if not isinstance(manifest, dict) or not isinstance(inventory, list): + raise SystemExit("Private build bundle metadata shape failed") +matches = [item for item in inventory if item.get("relative_path") == "Dockerfile"] +if ( + len(matches) != 1 + or matches[0].get("size") != len(dockerfile_bytes) + or matches[0].get("sha256") != hashlib.sha256(dockerfile_bytes).hexdigest() +): + raise SystemExit("Dockerfile inventory binding failed") +positions = [ + dockerfile.index("packaging==25.0"), + dockerfile.index("/tmp/custom-boundary"), + dockerfile.index("agentseek_api-0.3.0-py3-none-any.whl[embedded]"), + dockerfile.index("COPY manifest.v1.json /opt/agentseek/manifest.v1.json"), + dockerfile.index('"python", "-m", "pip", "check"'), + dockerfile.index("importlib.metadata"), +] +if positions != sorted(positions) or len(set(positions)) != len(positions): + raise SystemExit("Dockerfile install and verification order failed") +for path in context.rglob("*"): + if path.is_file() and sentinel in path.read_bytes(): + raise SystemExit("Build context sentinel isolation failed") +PY + +if ! EXISTING_IMAGE="$(image_id)"; then + echo "Candidate image ownership query failed." >&2 + exit 1 +fi +if [[ -n "$EXISTING_IMAGE" ]]; then + echo "Candidate image ownership boundary failed." >&2 + exit 1 +fi +IMAGE_OWNED=1 +if ! docker buildx build --load --file "$BUNDLE_CONTEXT/Dockerfile" --tag "$IMAGE_TAG" "$BUNDLE_CONTEXT" >"$BUILD_LOG" 2>&1; then + echo "Candidate bundle image build failed." >&2 exit 1 fi -if ! uv run agentseek-api up \ - --config "$CONFIG_PATH" \ - --image "$IMAGE_TAG" \ - --port 8123 \ - --env-file "$TMP_DIR/up.env" \ - --recreate; then +uv run python - "$IMAGE_TAG" <<'PY' +import json +import subprocess +import sys + +expected = { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", +} +raw = subprocess.run( + ["docker", "image", "inspect", sys.argv[1]], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +).stdout +image = json.loads(raw)[0] +if any(image["Config"]["Labels"].get(name) != value for name, value in expected.items()): + raise SystemExit("Image label contract failed") +command = image["Config"]["Cmd"] +if "--environment-mode" not in command or "preloaded-v1" not in command: + raise SystemExit("Image command mode contract failed") +PY + +docker run --rm -i "$IMAGE_TAG" python - <<'PY' +import importlib.metadata +import json +import pathlib + +path = pathlib.Path("/opt/agentseek/manifest.v1.json") +raw = path.read_bytes() +document = json.loads(raw) +canonical = (json.dumps(document, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode() +if raw != canonical: + raise SystemExit("Runtime manifest canonicalization failed") +if document["runtime"] != { + "contract": "preloaded-v1", + "distribution": "agentseek-api", + "version": "0.3.0", +}: + raise SystemExit("Runtime manifest identity failed") +if importlib.metadata.version("agentseek-api") != "0.3.0": + raise SystemExit("Runtime distribution version failed") +PY + +if ! EXISTING_CONTAINER="$(container_id)"; then + echo "Application container ownership query failed." >&2 + exit 1 +fi +if [[ -n "$EXISTING_CONTAINER" ]]; then + echo "Application container ownership boundary failed." >&2 + exit 1 +fi +CONTAINER_OWNED=1 +if ! ( + cd "$PROJECT_DIR" + uv run --project "$ROOT_DIR" agentseek-api up \ + --config launch.json \ + --image "$IMAGE_TAG" \ + --port 8123 \ + --recreate +) >"$TMP_DIR/up.log" 2>&1; then + print_up_log print_logs exit 1 fi @@ -107,66 +336,41 @@ done if ! curl -fsS "http://127.0.0.1:8123/health" | grep -q '"healthy"'; then print_logs - echo "App container did not become healthy." >&2 + echo "Source-only app container did not become healthy." >&2 exit 1 fi -if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8123 --mode full; then +if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8123 --mode smoke; then print_logs exit 1 fi -DUPLICATE_STDERR="$TMP_DIR/up-duplicate.stderr" -set +e -uv run agentseek-api up \ - --config "$CONFIG_PATH" \ - --image "$IMAGE_TAG" \ - --port 8123 \ - --env-file "$TMP_DIR/up.env" \ - 2>"$DUPLICATE_STDERR" -DUPLICATE_EXIT=$? -set -e - -if [[ "$DUPLICATE_EXIT" -eq 0 ]]; then - print_logs - echo "Duplicate agentseek-api up unexpectedly succeeded without --recreate." >&2 +RUNTIME_RECORD="$(docker exec "$APP_CONTAINER" python -c 'import importlib.metadata,pathlib,agentseek_api; print(importlib.metadata.version("agentseek-api")); print(pathlib.Path(agentseek_api.__file__).resolve())')" +RUNTIME_VERSION="$(printf '%s\n' "$RUNTIME_RECORD" | sed -n '1p')" +RUNTIME_MODULE="$(printf '%s\n' "$RUNTIME_RECORD" | sed -n '2p')" +if [[ "$RUNTIME_VERSION" != "0.3.0" ]]; then + echo "Running distribution version boundary failed." >&2 exit 1 fi - -if ! grep -q "already exists" "$DUPLICATE_STDERR" || ! grep -q -- "--recreate" "$DUPLICATE_STDERR"; then - print_logs - cat "$DUPLICATE_STDERR" >&2 || true - echo "Duplicate agentseek-api up did not emit the expected recreate guidance." >&2 +if [[ "$RUNTIME_MODULE" != *"site-packages"* || "$RUNTIME_MODULE" == /deps/agent/* ]]; then + echo "Running distribution path boundary failed." >&2 exit 1 fi -if grep -Eqi "Conflict|already in use by container|Error response from daemon" "$DUPLICATE_STDERR"; then - print_logs - cat "$DUPLICATE_STDERR" >&2 || true - echo "Duplicate agentseek-api up leaked raw Docker conflict output." >&2 +PROCESS_COMMAND="$(docker container inspect --format '{{json .Path}} {{json .Args}}' "$APP_CONTAINER")" +if [[ "$PROCESS_COMMAND" != *"--environment-mode"* || "$PROCESS_COMMAND" != *"preloaded-v1"* ]]; then + echo "Running process environment mode boundary failed." >&2 exit 1 fi -if ! uv run agentseek-api up \ - --config "$CONFIG_PATH" \ - --port 8124 \ - --base-image python:3.13-slim-bookworm \ - --env-file "$TMP_DIR/up.env" \ - --postgres-uri postgresql://postgres:postgres@host.docker.internal:5432/agentseek \ - --no-pull \ - --wait \ - --recreate; then - print_logs - exit 1 -fi +docker image save --output "$IMAGE_ARCHIVE" "$IMAGE_TAG" +docker history --no-trunc --format '{{json .}}' "$IMAGE_TAG" >"$HISTORY_FILE" +chmod 600 "$IMAGE_ARCHIVE" "$HISTORY_FILE" +AGENTSEEK_IMAGE_SCAN_SENTINEL="$BUILD_SENTINEL" \ + uv run python scripts/container_image_archive.py \ + --archive "$IMAGE_ARCHIVE" \ + --history "$HISTORY_FILE" -if ! curl -fsS "http://127.0.0.1:8124/health" | grep -q '"healthy"'; then - print_logs - echo "Auto-built app container did not become healthy." >&2 - exit 1 -fi - -if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8124 --mode smoke; then - print_logs - exit 1 -fi +printf 'candidate wheel sha256: %s\n' "$CANDIDATE_SHA256" +printf 'runtime version: %s\n' "$RUNTIME_VERSION" +printf 'runtime module: %s\n' "$RUNTIME_MODULE" diff --git a/scripts/test-redis-runtime.sh b/scripts/test-redis-runtime.sh index cfb6557..b711632 100644 --- a/scripts/test-redis-runtime.sh +++ b/scripts/test-redis-runtime.sh @@ -6,6 +6,7 @@ cd "$ROOT_DIR" IMAGE_TAG="${IMAGE_TAG:-agentseek-api-redis-smoke:latest}" CONFIG_PATH="${CONFIG_PATH:-examples/docker_ci_auth/manifest.json}" +AUTH_MODULE_PATH="${AUTH_MODULE_PATH:-/deps/agent/examples/docker_ci_auth/auth_backend.py:HeaderAuthBackend}" NETWORK_NAME="${NETWORK_NAME:-agentseek-redis-runtime}" BACKEND_CONTAINER="${BACKEND_CONTAINER:-agentseek-redis-backend}" REDIS_CONTAINER="${REDIS_CONTAINER:-agentseek-redis}" @@ -19,6 +20,7 @@ OCEANBASE_DOCKER_MODE="${OCEANBASE_DOCKER_MODE:-mini}" STATE_DIR="${STATE_DIR:-$ROOT_DIR/.tmp/redis-runtime}" RESUME_STATE_FILE="$STATE_DIR/resume-state.json" SHUTDOWN_STATE_FILE="" +CANDIDATE_DIR="" REDIS_WORKER_LOCK_KEY="${REDIS_WORKER_LOCK_KEY:-agentseek:worker:active}" WORKER_CONCURRENT_JOBS="${WORKER_CONCURRENT_JOBS:-10}" @@ -34,6 +36,9 @@ cleanup() { if [[ -n "$SHUTDOWN_STATE_FILE" ]]; then rm -f "$SHUTDOWN_STATE_FILE" || true fi + if [[ -n "$CANDIDATE_DIR" && -d "$CANDIDATE_DIR" ]]; then + rm -rf -- "$CANDIDATE_DIR" || true + fi exit "$status" } @@ -271,6 +276,8 @@ start_worker() { -e REDIS_URL="redis://${REDIS_CONTAINER}:6379/0" \ -e REDIS_WORKER_LOCK_KEY="${REDIS_WORKER_LOCK_KEY}" \ -e WORKER_CONCURRENT_JOBS="${WORKER_CONCURRENT_JOBS}" \ + -e AGENTSEEK_GRAPHS="/opt/agentseek/manifest.v1.json" \ + -e AUTH_MODULE_PATH="${AUTH_MODULE_PATH}" \ -e SEEKDB_URL="${SEEKDB_URL}" \ -e OCEANBASE_HOST="${BACKEND_CONTAINER}" \ -e OCEANBASE_PORT="${OCEANBASE_PORT}" \ @@ -278,7 +285,7 @@ start_worker() { -e OCEANBASE_PASSWORD="${OCEANBASE_PASSWORD}" \ -e OCEANBASE_DB_NAME="${OCEANBASE_DB_NAME}" \ "$IMAGE_TAG" \ - python -m agentseek_api.cli worker >/dev/null; then + python -I -m agentseek_api.cli worker --environment-mode preloaded-v1 >/dev/null; then return 0 else status=$? @@ -340,10 +347,46 @@ set_backend_defaults "$SEEKDB_DOCKER_BACKEND" docker network rm "$NETWORK_NAME" >/dev/null 2>&1 || true docker network create "$NETWORK_NAME" >/dev/null -mkdir -p "$STATE_DIR" +mkdir -p "$STATE_DIR" "$ROOT_DIR/.tmp" SHUTDOWN_STATE_FILE="$(mktemp "$STATE_DIR/shutdown-state.XXXXXX")" -uv run agentseek-api build --config "$CONFIG_PATH" -t "$IMAGE_TAG" +CANDIDATE_DIR="$(mktemp -d "$ROOT_DIR/.tmp/agentseek-redis-candidate.XXXXXX")" +if ! uv build --wheel --out-dir "$CANDIDATE_DIR"; then + echo "Candidate runtime wheel build failed." >&2 + exit 1 +fi +CANDIDATE_WHEELS=("$CANDIDATE_DIR"/agentseek_api-0.3.0-*.whl) +if [[ "${#CANDIDATE_WHEELS[@]}" -ne 1 || ! -f "${CANDIDATE_WHEELS[0]}" ]]; then + echo "Candidate runtime wheel selection failed." >&2 + exit 1 +fi +CANDIDATE_WHEEL="${CANDIDATE_WHEELS[0]}" + +uv run python - "$CONFIG_PATH" "$IMAGE_TAG" "$CANDIDATE_WHEEL" <<'PY' +from __future__ import annotations + +import hashlib +import sys +from pathlib import Path + +from agentseek_api.cli import main +from agentseek_api.container_build import candidate_runtime_artifact + +config = Path(sys.argv[1]) +image = sys.argv[2] +wheel = Path(sys.argv[3]) +artifact = candidate_runtime_artifact( + wheel, + hashlib.sha256(wheel.read_bytes()).hexdigest(), +) +raise SystemExit( + main( + ("build", "--config", str(config), "-t", image), + cwd=Path.cwd(), + runtime_artifact=artifact, + ) +) +PY start_backend @@ -375,6 +418,8 @@ docker run -d \ -p "${API_PORT}:2024" \ -e EXECUTOR_BACKEND=redis \ -e REDIS_URL="redis://${REDIS_CONTAINER}:6379/0" \ + -e AGENTSEEK_GRAPHS="/opt/agentseek/manifest.v1.json" \ + -e AUTH_MODULE_PATH="${AUTH_MODULE_PATH}" \ -e SEEKDB_URL="${SEEKDB_URL}" \ -e OCEANBASE_HOST="${BACKEND_CONTAINER}" \ -e OCEANBASE_PORT="${OCEANBASE_PORT}" \ diff --git a/scripts/test_cli_config_autodiscovery.py b/scripts/test_cli_config_autodiscovery.py index ce0522e..b911ebc 100644 --- a/scripts/test_cli_config_autodiscovery.py +++ b/scripts/test_cli_config_autodiscovery.py @@ -1,13 +1,68 @@ from __future__ import annotations +import argparse +import hashlib +import json +import os +import re import subprocess import sys import tempfile from pathlib import Path -def main() -> int: - with tempfile.TemporaryDirectory(prefix="agentseek-cli-autodiscovery-") as tmp_dir_text: +_URL_USERINFO = re.compile(r"(?Phttps?://)[^/@\s]+@", re.IGNORECASE) + + +def _report_github_failure(message: str) -> None: + if os.environ.get("GITHUB_ACTIONS") != "true": + return + normalized = " | ".join( + line.strip() for line in message.splitlines() if line.strip() + ) + normalized = _URL_USERINFO.sub(r"\g@", normalized)[:800] + if not normalized: + normalized = "dockerfile subprocess failed without a diagnostic" + escaped = normalized.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error title=AgentSeek dockerfile smoke::{escaped}", file=sys.stderr) + + +def _verify_bundle(output: Path) -> tuple[Path, dict[str, object]]: + context = output / "context" + dockerfile = context / "Dockerfile" + manifest_path = context / "manifest.v1.json" + inventory_path = output / "inventory.json" + if not all(path.is_file() for path in (dockerfile, manifest_path, inventory_path)): + raise SystemExit("dockerfile bundle contract was incomplete") + try: + manifest = json.loads(manifest_path.read_text(encoding="utf-8")) + inventory = json.loads(inventory_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError): + raise SystemExit("dockerfile bundle metadata was invalid") from None + if not isinstance(manifest, dict) or not isinstance(inventory, list): + raise SystemExit("dockerfile bundle metadata shape was invalid") + matches = [ + item + for item in inventory + if isinstance(item, dict) and item.get("relative_path") == "Dockerfile" + ] + payload = dockerfile.read_bytes() + if ( + len(matches) != 1 + or matches[0].get("size") != len(payload) + or matches[0].get("sha256") != hashlib.sha256(payload).hexdigest() + ): + raise SystemExit("dockerfile inventory binding was invalid") + return dockerfile, manifest + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--config", type=Path) + args = parser.parse_args(argv) + with tempfile.TemporaryDirectory( + prefix="agentseek-cli-autodiscovery-" + ) as tmp_dir_text: tmp_dir = Path(tmp_dir_text) (tmp_dir / "agentseek.json").write_text( """ @@ -30,24 +85,37 @@ def main() -> int: encoding="utf-8", ) - output_path = tmp_dir / "Dockerfile.agentseek" + output_path = tmp_dir / "agentseek-build-bundle" + command = [sys.executable, "-m", "agentseek_api.cli", "dockerfile"] + command_cwd = tmp_dir + if args.config is not None: + resolved_config = args.config.resolve() + command.extend(("--config", str(resolved_config))) + command_cwd = resolved_config.parent + command.append(str(output_path)) completed = subprocess.run( - [sys.executable, "-m", "agentseek_api.cli", "dockerfile", str(output_path)], - cwd=str(tmp_dir), + command, + cwd=str(command_cwd), check=False, capture_output=True, text=True, ) if completed.returncode != 0: - raise SystemExit( - "agentseek-api dockerfile failed without --config:\n" - f"stdout:\n{completed.stdout}\n" - f"stderr:\n{completed.stderr}" - ) - - content = output_path.read_text(encoding="utf-8") - assert "ENV AGENTSEEK_GRAPHS=/deps/agent/agentseek.json" in content - assert "ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json" not in content + _report_github_failure(completed.stderr) + raise SystemExit("agentseek-api dockerfile bundle generation failed") + + try: + _dockerfile, manifest = _verify_bundle(output_path) + if args.config is None: + if manifest.get("graphs") != {"agentseek": "chat.graph:graph"}: + raise SystemExit("auto-discovered graph manifest did not match") + if "langgraph" in json.dumps(manifest["graphs"]): + raise SystemExit("the lower-priority graph manifest was selected") + elif not manifest.get("graphs"): + raise SystemExit("the explicit graph manifest was empty") + except SystemExit as exc: + _report_github_failure(str(exc)) + raise return 0 diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py new file mode 100755 index 0000000..4ff1fd2 --- /dev/null +++ b/scripts/test_container_env_boundary.py @@ -0,0 +1,920 @@ +#!/usr/bin/env python3 +"""Real Docker/Compose acceptance proof for the container environment boundary.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import secrets +import stat +import subprocess +import sys +import traceback +from collections.abc import Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Protocol + +from agentseek_api.cli import main as cli_main +from agentseek_api.container_build import candidate_runtime_artifact +from agentseek_api.docker_runtime import ( + BuildImageInvocation, + ControlQueryInvocation, + DockerRunInvocation, + ProcessInvocation, + ProcessResult, +) +from agentseek_api.secure_temp import private_artifact, private_directory + +try: + from container_image_archive import scan_image_archive +except ModuleNotFoundError: # Loaded as a module by the unit acceptance tests. + from scripts.container_image_archive import scan_image_archive + + +_ROOT = Path(__file__).resolve().parents[1] +_PORT = "48123" +_SUCCESS = "container boundary verification passed" +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_URL_USERINFO = re.compile(r"(?Phttps?://)[^/@\s]+@", re.IGNORECASE) + + +class BoundaryFailure(RuntimeError): + """A value-free acceptance-boundary failure.""" + + +@dataclass(frozen=True) +class CapturedInvocation: + kind: str + argv: tuple[str, ...] = field(repr=False) + environment: Mapping[str, str] = field(repr=False) + stdin_sha256: str | None = field(repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "argv", tuple(self.argv)) + object.__setattr__( + self, "environment", MappingProxyType(dict(self.environment)) + ) + + +@dataclass(frozen=True) +class BoundaryEvidence: + disallowed_value: str = field(repr=False) + allowed_value: str = field(repr=False) + build_environment: Mapping[str, str] = field(repr=False) + compose_environment: Mapping[str, str] = field(repr=False) + build_context_archive: bytes = field(repr=False) + image_archive_and_history: bytes = field(repr=False) + application_environment: Mapping[str, str] = field(repr=False) + invocations: tuple[CapturedInvocation, ...] = field(repr=False) + + def __post_init__(self) -> None: + object.__setattr__( + self, "build_environment", MappingProxyType(dict(self.build_environment)) + ) + object.__setattr__( + self, + "compose_environment", + MappingProxyType(dict(self.compose_environment)), + ) + object.__setattr__( + self, + "application_environment", + MappingProxyType(dict(self.application_environment)), + ) + object.__setattr__(self, "invocations", tuple(self.invocations)) + + +class EvidenceCollector(Protocol): + def __call__( + self, + *, + disallowed_name: str, + disallowed_value: str, + allowed_name: str, + allowed_value: str, + ) -> BoundaryEvidence: ... + + +def _safe_process( + argv: tuple[str, ...], + *, + cwd: Path, + environment: Mapping[str, str], + stdin: bytes | None = None, + timeout: float | None = None, +) -> ProcessResult: + try: + completed = subprocess.run( + list(argv), + cwd=cwd, + env=dict(environment), + input=stdin, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + raise BoundaryFailure("container control query timed out") from exc + except OSError as exc: + raise BoundaryFailure("container process could not be started") from exc + return ProcessResult( + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + +def _classify(invocation: ProcessInvocation) -> str: + argv = invocation.argv[1:] + if isinstance(invocation, BuildImageInvocation): + return "build" + if isinstance(invocation, DockerRunInvocation): + return "docker-run" + if argv[:2] == ("rm", "-f"): + return "remove" + if argv[:1] == ("compose",): + return "compose" + return "inspect" + + +def _value_free_process_tail( + result: ProcessResult, *, redactions: tuple[bytes, ...] +) -> str: + output = result.stdout + b"\n" + result.stderr + for value in redactions: + if value: + output = output.replace(value, b"") + text = output.decode("utf-8", errors="replace") + text = _URL_USERINFO.sub(r"\g@", text) + normalized = " | ".join(line.strip() for line in text.splitlines() if line.strip()) + return normalized[-800:] + + +def _invocation_stage(invocation: ProcessInvocation) -> str: + if isinstance(invocation, BuildImageInvocation): + return "candidate image build" + if isinstance(invocation, DockerRunInvocation): + return "direct carrier probe" + argv = invocation.argv[1:] + if argv[:2] == ("compose", "version"): + return "Compose capability query" + if argv[:2] == ("buildx", "version"): + return "Buildx version query" + if argv[:2] == ("buildx", "inspect"): + return "Buildx availability query" + if argv[:2] == ("image", "inspect"): + return "image contract query" + if argv[:2] == ("rm", "-f"): + return "container cleanup" + if argv[:1] == ("compose",): + return "Compose render" + return "Docker control query" + + +def _probe_script(names: tuple[str, ...], result_name: str) -> str: + if Path(result_name).name != result_name or not result_name: + raise BoundaryFailure("synthetic probe result name boundary failed") + encoded_names = json.dumps(names, ensure_ascii=True, separators=(",", ":")) + encoded_result_path = json.dumps(f"/result/{result_name}", ensure_ascii=True) + return ( + "import json,os,pathlib;" + f"p=pathlib.Path({encoded_result_path});" + f"names={encoded_names};" + "p.write_text(json.dumps({n:os.environ.get(n) for n in names}," + "ensure_ascii=False,sort_keys=True,separators=(',',':')),encoding='utf-8');" + "p.chmod(0o600)" + ) + + +def _run_probe( + *, + docker_executable: str, + image: str, + application: Mapping[str, str], + docker_environment: Mapping[str, str], + cwd: Path, + result_path: Path, +) -> Mapping[str, str]: + names = tuple(sorted(application)) + container_name = f"agentseek-boundary-probe-{secrets.token_hex(6)}" + argv = [ + docker_executable, + "run", + "--rm", + "--name", + container_name, + ] + if os.name != "nt": + argv.extend(("--user", f"{os.getuid()}:{os.getgid()}")) + argv.extend( + ( + "--mount", + f"type=bind,src={result_path.parent},dst=/result", + ) + ) + for name in names: + argv.extend(("-e", name)) + argv.extend( + ( + "--entrypoint", + "python", + image, + "-I", + "-c", + _probe_script(names, result_path.name), + ) + ) + result = _safe_process( + tuple(argv), + cwd=cwd, + environment={**docker_environment, **application}, + ) + if result.returncode != 0: + raise BoundaryFailure("synthetic Docker carrier probe failed") + remaining = _safe_process( + (docker_executable, "container", "inspect", container_name), + cwd=cwd, + environment=docker_environment, + timeout=30, + ) + if remaining.returncode == 0: + raise BoundaryFailure("synthetic probe cleanup boundary failed") + try: + status = result_path.stat() + if stat.S_IMODE(status.st_mode) != 0o600: + raise BoundaryFailure("synthetic probe result mode boundary failed") + payload = json.loads(result_path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise BoundaryFailure("synthetic probe result boundary failed") from exc + if not isinstance(payload, dict) or not all( + isinstance(name, str) and (value is None or isinstance(value, str)) + for name, value in payload.items() + ): + raise BoundaryFailure("synthetic probe result shape boundary failed") + return MappingProxyType(dict(payload)) + + +@dataclass +class _EvidenceTransport: + result_path: Path + compose_dotenv_canary: str = field(repr=False) + forbidden_values: tuple[bytes, ...] = field(repr=False) + invocations: list[CapturedInvocation] = field(default_factory=list, repr=False) + build_environment: Mapping[str, str] = field( + default_factory=lambda: MappingProxyType({}), repr=False + ) + compose_environment: Mapping[str, str] = field( + default_factory=lambda: MappingProxyType({}), repr=False + ) + build_context_archive: bytes = field(default=b"", repr=False) + image_archive_and_history: bytes = field(default=b"", repr=False) + application_environment: Mapping[str, str] = field( + default_factory=lambda: MappingProxyType({}), repr=False + ) + image: str | None = None + docker_executable: str | None = None + container_name: str | None = None + last_stage: str = "planning" + last_failure_detail: str = field(default="", repr=False) + + def require_docker_executable(self) -> str: + if self.docker_executable is None: + raise BoundaryFailure("Docker executable evidence was missing") + return self.docker_executable + + def _record(self, invocation: ProcessInvocation) -> None: + if self.docker_executable is None: + self.docker_executable = invocation.argv[0] + elif self.docker_executable != invocation.argv[0]: + raise BoundaryFailure("Docker executable identity changed") + digest = ( + None + if invocation.stdin_bytes is None + else hashlib.sha256(invocation.stdin_bytes).hexdigest() + ) + self.invocations.append( + CapturedInvocation( + kind=_classify(invocation), + argv=invocation.argv, + environment=invocation.environment, + stdin_sha256=digest, + ) + ) + + def _render_compose(self, invocation: ProcessInvocation) -> ProcessResult: + try: + command_index = invocation.argv.index("up") + except ValueError as exc: + raise BoundaryFailure("Compose invocation classification failed") from exc + prefix = invocation.argv[:command_index] + rendered_result = _safe_process( + (*prefix, "config", "--format", "json"), + cwd=invocation.cwd, + environment=invocation.environment, + timeout=30, + ) + if rendered_result.returncode != 0: + raise BoundaryFailure("Compose document render failed") + try: + rendered = json.loads(rendered_result.stdout) + service_environment = rendered["services"]["probe"]["environment"] + if not isinstance(service_environment, dict) or not all( + isinstance(name, str) and isinstance(value, str) + for name, value in service_environment.items() + ): + raise TypeError + observed = service_environment["PROJECT_DOTENV_CANARY"] + except ( + KeyError, + TypeError, + UnicodeDecodeError, + json.JSONDecodeError, + ) as exc: + raise BoundaryFailure("Compose document boundary was malformed") from exc + self.compose_environment = MappingProxyType(dict(service_environment)) + if ( + observed != "unset" + or self.compose_dotenv_canary in service_environment.values() + ): + raise BoundaryFailure("explicit Compose env-file isolation failed") + return ProcessResult(returncode=0) + + def _capture_image(self, invocation: BuildImageInvocation) -> None: + try: + tag_index = invocation.argv.index("--tag") + self.image = invocation.argv[tag_index + 1] + except (ValueError, IndexError) as exc: + raise BoundaryFailure("built image tag boundary was missing") from exc + with private_artifact( + prefix="agentseek-boundary-image-", + contents=b"", + tmp_root=invocation.cwd.parent, + ) as image_archive: + save = _safe_process( + ( + invocation.argv[0], + "image", + "save", + "--output", + str(image_archive), + self.image, + ), + cwd=invocation.cwd, + environment=invocation.environment, + ) + if save.returncode != 0: + raise BoundaryFailure("built image export failed") + archive_bytes = image_archive.read_bytes() + history = _safe_process( + ( + invocation.argv[0], + "history", + "--no-trunc", + "--format", + "{{json .}}", + self.image, + ), + cwd=invocation.cwd, + environment=invocation.environment, + timeout=30, + ) + if history.returncode != 0: + raise BoundaryFailure("built image history query failed") + for forbidden in self.forbidden_values: + scan_image_archive( + archive_bytes, + forbidden=forbidden, + history=history.stdout, + ) + self.image_archive_and_history = archive_bytes + b"\n" + history.stdout + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + self._record(invocation) + self.last_stage = _invocation_stage(invocation) + if isinstance(invocation, BuildImageInvocation): + self.build_environment = MappingProxyType(dict(invocation.environment)) + self.build_context_archive = invocation.stdin_bytes + result = _safe_process( + invocation.argv, + cwd=invocation.cwd, + environment=invocation.environment, + stdin=invocation.stdin_bytes, + ) + if result.returncode == 0: + self._capture_image(invocation) + else: + self.last_failure_detail = _value_free_process_tail( + result, + redactions=( + *self.forbidden_values, + *( + value.encode() + for value in invocation.environment.values() + if value + ), + ), + ) + return result + if isinstance(invocation, DockerRunInvocation): + if self.image is None: + raise BoundaryFailure("direct-run image boundary was missing") + try: + name_index = invocation.argv.index("--name") + self.container_name = invocation.argv[name_index + 1] + except (ValueError, IndexError) as exc: + raise BoundaryFailure( + "generated container ownership boundary was missing" + ) from exc + application = { + name: invocation.environment[name] + for name in invocation.application_names + } + docker_environment = { + name: value + for name, value in invocation.environment.items() + if name not in invocation.application_names + } + self.application_environment = _run_probe( + docker_executable=invocation.argv[0], + image=self.image, + application=application, + docker_environment=docker_environment, + cwd=invocation.cwd, + result_path=self.result_path, + ) + return _safe_process( + invocation.argv, + cwd=invocation.cwd, + environment=invocation.environment, + stdin=invocation.stdin_bytes, + ) + if invocation.argv[1:2] == ("compose",) and "up" in invocation.argv: + return self._render_compose(invocation) + timeout = ( + invocation.timeout_seconds + if isinstance(invocation, ControlQueryInvocation) + else None + ) + result = _safe_process( + invocation.argv, + cwd=invocation.cwd, + environment=invocation.environment, + stdin=invocation.stdin_bytes, + timeout=timeout, + ) + if result.returncode != 0: + self.last_failure_detail = _value_free_process_tail( + result, + redactions=( + *self.forbidden_values, + *( + value.encode() + for value in invocation.environment.values() + if value + ), + ), + ) + return result + + +def _build_candidate(project: Path): + output = project / "candidate-dist" + output.mkdir(mode=0o700) + environment = { + name: value + for name, value in os.environ.items() + if name + in { + "PATH", + "HOME", + "UV_CACHE_DIR", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + } + } + result = _safe_process( + ("uv", "build", "--wheel", "--out-dir", str(output), str(_ROOT)), + cwd=_ROOT, + environment=environment, + ) + wheels = tuple(output.glob("agentseek_api-0.3.0-*.whl")) + if result.returncode != 0 or len(wheels) != 1: + raise BoundaryFailure("candidate runtime wheel build failed") + wheel = wheels[0] + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + return candidate_runtime_artifact(wheel, digest) + + +def _write_project( + root: Path, + *, + allowed_name: str, + allowed_value: str, + compose_dotenv_canary: str, +) -> tuple[Path, Path]: + package = root / "chat" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "graph.py").write_text( + "from langgraph.graph import END, START, StateGraph\n" + "\n" + "def respond(state):\n" + " return {'message': state.get('message', '')}\n" + "\n" + "builder = StateGraph(dict)\n" + "builder.add_node('respond', respond)\n" + "builder.add_edge(START, 'respond')\n" + "builder.add_edge('respond', END)\n" + "graph = builder.compile(name='Boundary Graph')\n", + encoding="utf-8", + ) + application_dotenv = root / "application.env" + application_dotenv.write_text( + f"{allowed_name}={allowed_value}\n" + "METADATA_DB_URL=sqlite+aiosqlite:////tmp/agentseek-boundary.db\n", + encoding="utf-8", + ) + application_dotenv.chmod(0o600) + config = root / "agentseek.json" + config.write_text( + json.dumps( + { + "dependencies": [], + "graphs": {"chat": "chat/graph.py:graph"}, + "env": "application.env", + "compose_env": [], + }, + separators=(",", ":"), + ), + encoding="utf-8", + ) + (root / ".env").write_text( + f"PROJECT_DOTENV_CANARY={compose_dotenv_canary}\n", encoding="utf-8" + ) + compose = root / "compose.yaml" + compose.write_text( + "services:\n" + " probe:\n" + " image: busybox:1.36\n" + " environment:\n" + " PROJECT_DOTENV_CANARY: ${PROJECT_DOTENV_CANARY-unset}\n", + encoding="utf-8", + ) + return config, compose + + +def _prove_value_domain_carrier( + *, + docker_executable: str, + image: str, + docker_environment: Mapping[str, str], + cwd: Path, + result_path: Path, +) -> None: + values = MappingProxyType( + { + "VALUE_EMPTY": "", + "VALUE_NEWLINE": "physical\nnewline", + "VALUE_UNICODE": "海洋数据库", + "VALUE_DOLLAR": "$cash", + "VALUE_EXPANSION": "${NOT_EXPANDED}", + "VALUE_HASH": "value#fragment", + "VALUE_SPACES": " leading and trailing ", + "VALUE_EQUALS": "left=right", + "VALUE_SINGLE_QUOTE": "it's literal", + "VALUE_DOUBLE_QUOTE": 'say "hello"', + "VALUE_BACKSLASH": r"C:\boundary\path", + } + ) + result_path.write_bytes(b"") + result_path.chmod(0o600) + observed = _run_probe( + docker_executable=docker_executable, + image=image, + application=values, + docker_environment=docker_environment, + cwd=cwd, + result_path=result_path, + ) + if dict(observed) != dict(values): + raise BoundaryFailure("direct Docker carrier value-domain boundary failed") + + +def _remove_owned_image( + *, + docker_executable: str, + image: str, + cwd: Path, + environment: Mapping[str, str], +) -> None: + removed = _safe_process( + (docker_executable, "image", "rm", "--force", image), + cwd=cwd, + environment=environment, + timeout=30, + ) + remaining = _safe_process( + (docker_executable, "image", "inspect", image), + cwd=cwd, + environment=environment, + timeout=30, + ) + if removed.returncode != 0 or remaining.returncode == 0: + raise BoundaryFailure("owned image cleanup boundary failed") + + +def _remove_owned_container( + *, + docker_executable: str, + container_name: str, + cwd: Path, + environment: Mapping[str, str], +) -> None: + _safe_process( + (docker_executable, "rm", "-f", container_name), + cwd=cwd, + environment=environment, + timeout=30, + ) + remaining = _safe_process( + (docker_executable, "container", "inspect", container_name), + cwd=cwd, + environment=environment, + timeout=30, + ) + if remaining.returncode == 0: + raise BoundaryFailure("owned container cleanup boundary failed") + + +def collect_boundary_evidence( + *, + disallowed_name: str, + disallowed_value: str, + allowed_name: str, + allowed_value: str, +) -> BoundaryEvidence: + """Collect value-redacted evidence from real Docker and Compose processes.""" + + if not all( + _ENVIRONMENT_NAME.fullmatch(name) for name in (disallowed_name, allowed_name) + ): + raise BoundaryFailure("boundary sentinel name was invalid") + if not all( + value.isascii() and value.isprintable() + for value in (disallowed_value, allowed_value) + ): + raise BoundaryFailure("boundary sentinel value domain was invalid") + + compose_dotenv_canary = secrets.token_urlsafe(24) + previous = os.environ.get(disallowed_name) + had_previous = disallowed_name in os.environ + with private_directory(prefix="agentseek-boundary-") as workspace: + project = workspace / "project" + project.mkdir(mode=0o700) + config, compose = _write_project( + project, + allowed_name=allowed_name, + allowed_value=allowed_value, + compose_dotenv_canary=compose_dotenv_canary, + ) + artifact = _build_candidate(project) + with private_directory( + prefix="agentseek-boundary-results-", tmp_root=workspace + ) as result_root: + with private_artifact( + prefix="environment-", contents=b"", tmp_root=result_root + ) as result_path: + transport = _EvidenceTransport( + result_path=result_path, + compose_dotenv_canary=compose_dotenv_canary, + forbidden_values=( + disallowed_value.encode(), + allowed_value.encode(), + ), + ) + try: + os.environ[disallowed_name] = disallowed_value + exit_code = cli_main( + ( + "up", + "--config", + str(config), + "--docker-compose", + str(compose), + "--port", + _PORT, + "--recreate", + "--wait", + ), + process_transport=transport, + cwd=project, + runtime_artifact=artifact, + ) + if exit_code != 0: + detail = ( + f": {transport.last_failure_detail}" + if transport.last_failure_detail + else "" + ) + raise BoundaryFailure( + "agentseek-api up failed during " + f"{transport.last_stage}{detail}" + ) + if transport.image is None: + raise BoundaryFailure( + "built image boundary evidence was missing" + ) + controls = dict(transport.build_environment) + _prove_value_domain_carrier( + docker_executable=transport.require_docker_executable(), + image=transport.image, + docker_environment=controls, + cwd=project, + result_path=result_path, + ) + return BoundaryEvidence( + disallowed_value=disallowed_value, + allowed_value=allowed_value, + build_environment=transport.build_environment, + compose_environment=transport.compose_environment, + build_context_archive=transport.build_context_archive, + image_archive_and_history=transport.image_archive_and_history, + application_environment=transport.application_environment, + invocations=tuple(transport.invocations), + ) + finally: + active_failure = sys.exception() + if had_previous: + assert previous is not None + os.environ[disallowed_name] = previous + else: + os.environ.pop(disallowed_name, None) + first = transport.invocations[0] if transport.invocations else None + environment = {} if first is None else first.environment + cleanup_failure: BoundaryFailure | None = None + if transport.container_name is not None: + try: + _remove_owned_container( + docker_executable=transport.require_docker_executable(), + container_name=transport.container_name, + cwd=project, + environment=environment, + ) + except BoundaryFailure as exc: + cleanup_failure = exc + if transport.image is not None: + try: + _remove_owned_image( + docker_executable=transport.require_docker_executable(), + image=transport.image, + cwd=project, + environment=environment, + ) + except BoundaryFailure as exc: + cleanup_failure = cleanup_failure or exc + if cleanup_failure is not None: + if active_failure is None: + raise cleanup_failure + active_failure.add_note( + "Owned container cleanup could not be verified." + ) + + +def _require(condition: bool, boundary: str) -> None: + if not condition: + raise BoundaryFailure(f"{boundary} boundary failed") + + +def _report_github_failure(message: str) -> None: + if os.environ.get("GITHUB_ACTIONS") != "true": + return + escaped = message.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print( + f"::error title=AgentSeek container boundary::{escaped}", + file=sys.stderr, + ) + + +def _unexpected_failure_fingerprint(exc: BaseException) -> str: + location = "" + for frame in reversed(traceback.extract_tb(exc.__traceback__)): + path = Path(frame.filename).resolve() + try: + relative = path.relative_to(_ROOT) + except ValueError: + continue + location = f" at {relative.as_posix()}:{frame.lineno}" + break + return f"real-runtime boundary failed ({type(exc).__name__}{location})" + + +def _verify_evidence(evidence: BoundaryEvidence) -> None: + _require( + evidence.disallowed_value not in evidence.build_environment.values(), + "build environment isolation", + ) + _require( + evidence.disallowed_value not in evidence.compose_environment.values(), + "Compose environment isolation", + ) + _require( + evidence.disallowed_value.encode() not in evidence.build_context_archive, + "build context isolation", + ) + _require( + evidence.disallowed_value.encode() not in evidence.image_archive_and_history, + "image layer and history isolation", + ) + _require( + evidence.disallowed_value not in evidence.application_environment.values(), + "application environment isolation", + ) + _require( + evidence.allowed_value not in evidence.build_environment.values(), + "selected application build isolation", + ) + _require( + evidence.allowed_value not in evidence.compose_environment.values(), + "selected application Compose isolation", + ) + _require( + evidence.allowed_value.encode() not in evidence.build_context_archive, + "selected application context isolation", + ) + _require( + evidence.allowed_value.encode() not in evidence.image_archive_and_history, + "selected application image isolation", + ) + _require( + evidence.application_environment.get("ALLOWED_SENTINEL") + == evidence.allowed_value, + "selected application carrier", + ) + for invocation in evidence.invocations: + _require( + all(evidence.disallowed_value not in arg for arg in invocation.argv), + f"{invocation.kind} argv disallowed-value isolation", + ) + _require( + evidence.disallowed_value not in invocation.environment.values(), + f"{invocation.kind} environment disallowed-value isolation", + ) + if invocation.kind == "docker-run": + _require( + invocation.environment.get("ALLOWED_SENTINEL") + == evidence.allowed_value, + "direct-run selected application carrier", + ) + _require( + all(evidence.allowed_value not in arg for arg in invocation.argv), + "direct-run argv selected-value isolation", + ) + else: + _require( + all(evidence.allowed_value not in arg for arg in invocation.argv), + f"{invocation.kind} argv selected-value isolation", + ) + _require( + evidence.allowed_value not in invocation.environment.values(), + f"{invocation.kind} environment selected-value isolation", + ) + + +def main(*, evidence_collector: EvidenceCollector = collect_boundary_evidence) -> int: + try: + evidence = evidence_collector( + disallowed_name="DISALLOWED_CANARY", + disallowed_value=secrets.token_urlsafe(24), + allowed_name="ALLOWED_SENTINEL", + allowed_value=secrets.token_hex(32), + ) + _verify_evidence(evidence) + except BoundaryFailure as exc: + _report_github_failure(str(exc)) + print(f"container boundary verification failed: {exc}", file=sys.stderr) + return 1 + except Exception as exc: + failure = _unexpected_failure_fingerprint(exc) + _report_github_failure(failure) + print(f"container boundary verification failed: {failure}", file=sys.stderr) + return 1 + print(_SUCCESS) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index 206e618..5c2d59a 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -74,6 +74,21 @@ def main() -> None: ) assert version.stdout.strip() == "1.0.0" + packaging_version = subprocess.run( + [ + str(python), + "-c", + ( + "import importlib.metadata; " + "print(importlib.metadata.version('packaging'))" + ), + ], + check=True, + capture_output=True, + text=True, + ) + assert packaging_version.stdout.strip() == "24.0" + cli_env = dict(os.environ) cli_env.pop("PYTHONPATH", None) cli_env["PORT"] = "not-an-integer" diff --git a/scripts/value_free_log_tail.py b/scripts/value_free_log_tail.py new file mode 100644 index 0000000..2219208 --- /dev/null +++ b/scripts/value_free_log_tail.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Print a bounded, value-free tail from a hosted container smoke log.""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + + +MAXIMUM_TAIL_BYTES = 12_000 +_REDACTION = b"" +_URL_USERINFO = re.compile(rb"(?i)(https?://)[^/\s:@]+:[^@\s/]+@") + + +def value_free_log_tail(log_path: Path, environment_path: Path) -> str: + output = log_path.read_bytes()[-MAXIMUM_TAIL_BYTES:] + for line in environment_path.read_bytes().splitlines(): + _name, separator, value = line.partition(b"=") + if separator and value: + output = output.replace(value, _REDACTION) + output = _URL_USERINFO.sub(rb"\1@", output) + output = output[-MAXIMUM_TAIL_BYTES:] + return output.decode("utf-8", errors="replace") + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if len(arguments) != 2: + print("application startup diagnostic unavailable", file=sys.stderr) + return 2 + try: + diagnostic = value_free_log_tail( + Path(arguments[0]), + Path(arguments[1]), + ) + except OSError: + print("application startup diagnostic unavailable", file=sys.stderr) + return 1 + if diagnostic: + print(diagnostic, end="" if diagnostic.endswith("\n") else "\n") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentseek_api/__init__.py b/src/agentseek_api/__init__.py index d31c31e..493f741 100644 --- a/src/agentseek_api/__init__.py +++ b/src/agentseek_api/__init__.py @@ -1 +1 @@ -__version__ = "0.2.3" +__version__ = "0.3.0" diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 60971cf..3c24a71 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -1,12 +1,15 @@ from __future__ import annotations import argparse +import importlib.metadata import json import os +import re import signal import subprocess import sys import time +from contextlib import ExitStack from dataclasses import dataclass, field from urllib import error as urllib_error from urllib import request as urllib_request @@ -15,14 +18,66 @@ from typing import TextIO from agentseek_api import __version__ +from agentseek_api.container_policy import ( + APP_CONTAINER_POLICY, + HOST_RUNTIME_POLICY, + ContainerSelection, + docker_control_environment, + select_application_payload, + select_compose_payload, +) +from agentseek_api.container_build import ( + PUBLISHED_RUNTIME_ARTIFACT, + ContainerBuildError, + FinalAuthSelection, + RuntimeArtifactV1, + load_container_runtime_manifest_v1, + materialize_build_bundle, + plan_container_image, + plan_generated_up_auth, + render_build_dockerfile, +) from agentseek_api.constants import DEFAULT_API_PORT +from agentseek_api.docker_runtime import ( + DockerRuntimeError, + LegacyRunnerAdapter, + PRELOADED_MANIFEST_PATH, + ProcessTransport, + SubprocessTransport, + build_compose_invocation, + build_docker_control_invocation, + build_image_invocation, + build_docker_query_invocation, + build_docker_run_invocation, + encode_compose_environment, + inspect_image_contract, + require_supported_compose, + require_supported_buildx, + resolve_docker_executable, +) from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.environment import ( + CommandDerivedAssignment, + ContainerPolicyError, + EnvironmentPlan, + EnvironmentMode, + EnvironmentTarget, + PRELOADED_V1_POLICY, + ResolvedEnvironment, + resolve_environment, +) from agentseek_api.process_supervisor import ( ForegroundChildSupervisor, ForwardingSignalGuard, ProcessSupervisionError, _ForwardedSignal, ) +from agentseek_api.secure_temp import ( + SecureArtifactError, + private_artifact, + private_directory, + sweep_expired_artifacts, +) DEFAULT_CLI_NAME = "agentseek-api" @@ -53,9 +108,9 @@ "build_uvicorn_command", "create_parser", "main", + "resolve_runtime_for_mode", "register_subcommands", "run_namespace", - "write_dockerfile", ] _CONTAINER_ENV_PREFIXES = ( @@ -88,6 +143,8 @@ class CliConfig: image_distro: str | None = None pip_config_file: Path | None = None dockerfile_lines: list[str] = field(default_factory=list) + compose_env: tuple[str, ...] = () + build_include: tuple[str, ...] = () @dataclass(frozen=True) @@ -228,13 +285,22 @@ def _normalize_symbol_reference(reference: str, *, config_path: Path) -> str: if parts is None: return reference module_name, symbol_name = parts - if module_name.endswith(".py") or module_name.startswith(".") or "/" in module_name or "\\" in module_name: - resolved_module = _resolve_path_from_config(module_name, config_path=config_path) + if ( + module_name.endswith(".py") + or module_name.startswith(".") + or "/" in module_name + or "\\" in module_name + ): + resolved_module = _resolve_path_from_config( + module_name, config_path=config_path + ) return f"{resolved_module}:{symbol_name}" return reference -def _normalize_env_mapping(raw_env: object, *, config_path: Path) -> tuple[dict[str, str], Path | None]: +def _normalize_env_mapping( + raw_env: object, *, config_path: Path +) -> tuple[dict[str, str], Path | None]: if raw_env is None: return {}, None if isinstance(raw_env, str): @@ -246,61 +312,122 @@ def _normalize_env_mapping(raw_env: object, *, config_path: Path) -> tuple[dict[ env_mapping: dict[str, str] = {} for key, value in raw_env.items(): if not isinstance(key, str): - raise CliError(f"Config file '{config_path}' env mapping keys must be strings.") + raise CliError( + f"Config file '{config_path}' env mapping keys must be strings." + ) if isinstance(value, (str, int, float, bool)) or value is None: env_mapping[key] = "" if value is None else str(value) else: - raise CliError(f"Config file '{config_path}' env mapping values must be scalar.") + raise CliError( + f"Config file '{config_path}' env mapping values must be scalar." + ) return env_mapping, None - raise CliError(f"Config file '{config_path}' must set 'env' to a path string or key/value object.") + raise CliError( + f"Config file '{config_path}' must set 'env' to a path string or key/value object." + ) + + +def _normalize_name_list( + raw_value: object, *, config_path: Path, field_name: str +) -> tuple[str, ...]: + if raw_value is None: + return () + if not isinstance(raw_value, list) or not all( + isinstance(name, str) and name.strip() for name in raw_value + ): + raise CliError( + f"Config file '{config_path}' field '{field_name}' must be an array of non-empty strings." + ) + return tuple(name.strip() for name in raw_value) def _load_cli_config(config_path: Path) -> CliConfig: payload = _load_config_payload(config_path) - env_mapping, env_file = _normalize_env_mapping(payload.get("env"), config_path=config_path) + env_mapping, env_file = _normalize_env_mapping( + payload.get("env"), config_path=config_path + ) raw_dependencies = payload.get("dependencies", []) if raw_dependencies is None: raw_dependencies = [] - if not isinstance(raw_dependencies, list) or not all(isinstance(item, str) and item.strip() for item in raw_dependencies): - raise CliError(f"Config file '{config_path}' field 'dependencies' must be an array of non-empty strings.") + if not isinstance(raw_dependencies, list) or not all( + isinstance(item, str) and item.strip() for item in raw_dependencies + ): + raise CliError( + f"Config file '{config_path}' field 'dependencies' must be an array of non-empty strings." + ) auth_path: str | None = None raw_auth = payload.get("auth") if raw_auth is not None: if not isinstance(raw_auth, dict): - raise CliError(f"Config file '{config_path}' field 'auth' must be an object.") + raise CliError( + f"Config file '{config_path}' field 'auth' must be an object." + ) raw_auth_path = raw_auth.get("path") if raw_auth_path is not None: if not isinstance(raw_auth_path, str) or not raw_auth_path.strip(): - raise CliError(f"Config file '{config_path}' field 'auth.path' must be a non-empty string.") - auth_path = _normalize_symbol_reference(raw_auth_path.strip(), config_path=config_path) + raise CliError( + f"Config file '{config_path}' field 'auth.path' must be a non-empty string." + ) + auth_path = _normalize_symbol_reference( + raw_auth_path.strip(), config_path=config_path + ) raw_pip_config = payload.get("pip_config_file") pip_config_file: Path | None = None if raw_pip_config is not None: if not isinstance(raw_pip_config, str) or not raw_pip_config.strip(): - raise CliError(f"Config file '{config_path}' field 'pip_config_file' must be a non-empty string.") - pip_config_file = _resolve_path_from_config(raw_pip_config, config_path=config_path) + raise CliError( + f"Config file '{config_path}' field 'pip_config_file' must be a non-empty string." + ) + pip_config_file = _resolve_path_from_config( + raw_pip_config, config_path=config_path + ) if not pip_config_file.exists(): raise CliError(f"Pip config file '{pip_config_file}' does not exist.") raw_base_image = payload.get("base_image") - if raw_base_image is not None and (not isinstance(raw_base_image, str) or not raw_base_image.strip()): - raise CliError(f"Config file '{config_path}' field 'base_image' must be a non-empty string.") + if raw_base_image is not None and ( + not isinstance(raw_base_image, str) or not raw_base_image.strip() + ): + raise CliError( + f"Config file '{config_path}' field 'base_image' must be a non-empty string." + ) raw_python_version = payload.get("python_version") - if raw_python_version is not None and (not isinstance(raw_python_version, str) or not raw_python_version.strip()): - raise CliError(f"Config file '{config_path}' field 'python_version' must be a non-empty string.") + if raw_python_version is not None and ( + not isinstance(raw_python_version, str) or not raw_python_version.strip() + ): + raise CliError( + f"Config file '{config_path}' field 'python_version' must be a non-empty string." + ) raw_image_distro = payload.get("image_distro") - if raw_image_distro is not None and (not isinstance(raw_image_distro, str) or not raw_image_distro.strip()): - raise CliError(f"Config file '{config_path}' field 'image_distro' must be a non-empty string.") + if raw_image_distro is not None and ( + not isinstance(raw_image_distro, str) or not raw_image_distro.strip() + ): + raise CliError( + f"Config file '{config_path}' field 'image_distro' must be a non-empty string." + ) raw_dockerfile_lines = payload.get("dockerfile_lines", []) if raw_dockerfile_lines is None: raw_dockerfile_lines = [] - if not isinstance(raw_dockerfile_lines, list) or not all(isinstance(item, str) for item in raw_dockerfile_lines): - raise CliError(f"Config file '{config_path}' field 'dockerfile_lines' must be an array of strings.") + if not isinstance(raw_dockerfile_lines, list) or not all( + isinstance(item, str) for item in raw_dockerfile_lines + ): + raise CliError( + f"Config file '{config_path}' field 'dockerfile_lines' must be an array of strings." + ) + + compose_env = _normalize_name_list( + payload.get("compose_env"), config_path=config_path, field_name="compose_env" + ) + build_include = _normalize_name_list( + payload.get("build_include"), + config_path=config_path, + field_name="build_include", + ) return CliConfig( dependencies=[item.strip() for item in raw_dependencies], @@ -309,10 +436,16 @@ def _load_cli_config(config_path: Path) -> CliConfig: env_file=env_file, auth_path=auth_path, base_image=raw_base_image.strip() if isinstance(raw_base_image, str) else None, - python_version=raw_python_version.strip() if isinstance(raw_python_version, str) else None, - image_distro=raw_image_distro.strip() if isinstance(raw_image_distro, str) else None, + python_version=raw_python_version.strip() + if isinstance(raw_python_version, str) + else None, + image_distro=raw_image_distro.strip() + if isinstance(raw_image_distro, str) + else None, pip_config_file=pip_config_file, dockerfile_lines=list(raw_dockerfile_lines), + compose_env=compose_env, + build_include=build_include, ) @@ -323,39 +456,292 @@ def build_runtime_env( cwd: Path, base_env: dict[str, str] | None = None, ) -> dict[str, str]: + plan = build_host_environment_plan( + config_path=config_path, + env_file=env_file, + cwd=cwd, + base_env=base_env, + role=None, + ) + return _resolve_host_environment_plan(plan) + + +def resolve_runtime_for_mode( + *, + mode: EnvironmentMode, + config_path: str | None, + env_file: str | None, + inherited: dict[str, str], + cwd: Path, + role: str | None = None, +) -> ResolvedEnvironment: + """Resolve ordinary sources or one exact preloaded manifest, never both.""" + + if mode is EnvironmentMode.RESOLVE: + discovered = discover_config_path(explicit_path=config_path, cwd=cwd) + plan = build_host_environment_plan( + config_path=discovered, + env_file=env_file, + cwd=cwd, + base_env=inherited, + role=role, + ) + try: + return resolve_environment(plan, HOST_RUNTIME_POLICY) + except DotenvFileError as exc: + raise CliError(str(exc)) from exc + + manifest_value = inherited.get("AGENTSEEK_GRAPHS") + if not manifest_value: + raise CliError("preloaded-v1 requires inherited AGENTSEEK_GRAPHS.") + manifest_path = Path(manifest_value) + if not manifest_path.is_absolute(): + raise CliError("preloaded-v1 AGENTSEEK_GRAPHS must be an absolute path.") + if env_file is not None: + raise CliError("--env-file is not supported in preloaded-v1 mode.") + if ( + config_path is not None + and str(_resolve_path(config_path, cwd=cwd)) != manifest_value + ): + raise CliError( + "--config must resolve to the inherited preloaded manifest path." + ) + try: + manifest = load_container_runtime_manifest_v1(manifest_path) + except ContainerBuildError as exc: + raise CliError(str(exc)) from exc + try: + installed_version = importlib.metadata.version(manifest.runtime.distribution) + except importlib.metadata.PackageNotFoundError as exc: + raise CliError( + "The installed runtime distribution is incompatible with the manifest." + ) from exc + if installed_version != manifest.runtime.version: + raise CliError( + "The installed runtime distribution is incompatible with the manifest." + ) + assignments: tuple[CommandDerivedAssignment, ...] = () + if role == "dev": + assignments = ( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"STUDIO_AUTH_LOCAL_DEV": "true"}, + reason="development role safety", + ), + ) + plan = EnvironmentPlan( + config_path=manifest_path, + config_dotenv=None, + config_mapping={}, + auth_path=None, + cli_dotenv=None, + launch_environment=inherited, + command_assignments=assignments, + ) + return resolve_environment(plan, PRELOADED_V1_POLICY) + + +def build_host_environment_plan( + *, + config_path: Path | None, + env_file: str | None, + cwd: Path, + base_env: dict[str, str] | None = None, + role: str | None, +) -> EnvironmentPlan: inherited = dict(os.environ if base_env is None else base_env) - env: dict[str, str] = {} config = _load_cli_config(config_path) if config_path is not None else None + cli_dotenv = _resolve_path(env_file, cwd=cwd) if env_file else None + assignments: list[CommandDerivedAssignment] = [] + inherited.pop("AGENTSEEK_GRAPHS", None) + if config_path is not None: + assignments.append( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"AGENTSEEK_GRAPHS": str(config_path)}, + reason="selected host config path", + ) + ) + if role == "dev": + assignments.append( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"STUDIO_AUTH_LOCAL_DEV": "true"}, + reason="development role safety", + ) + ) + return EnvironmentPlan( + config_path=config_path, + config_dotenv=config.env_file if config is not None else None, + config_mapping=config.env_mapping if config is not None else {}, + auth_path=config.auth_path if config is not None else None, + cli_dotenv=cli_dotenv, + launch_environment=inherited, + command_assignments=tuple(assignments), + explicit_names=frozenset(), + ) + + +def _resolve_host_environment_plan(plan: EnvironmentPlan) -> dict[str, str]: + try: + resolved = resolve_environment(plan, HOST_RUNTIME_POLICY) + except DotenvFileError as exc: + raise CliError(str(exc)) from exc + return dict(resolved.values) + - if config is not None: - if config.env_file is not None: - _apply_env_layer( - env, - _read_env_layer(config.env_file, inherited=inherited), +def build_container_command_assignments( + *, + config_path: Path, + cwd: Path, + postgres_uri: str | None, + role: str | None = None, + environment_mode: str | None = None, +) -> tuple[CommandDerivedAssignment, ...]: + assignments = [ + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={ + "AGENTSEEK_GRAPHS": _container_config_path( + config_path=config_path, cwd=cwd + ) + }, + reason="container manifest path", + ) + ] + if postgres_uri is not None: + assignments.append( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={ + "METADATA_DB_URL": postgres_uri, + "METADATA_DB_BACKEND": "postgresql", + }, + reason="postgres uri override", + ) + ) + if role == "dev" and environment_mode == "preloaded-v1": + assignments.append( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={"STUDIO_AUTH_LOCAL_DEV": "true"}, + reason="development role safety in preloaded runtime", ) - env.update(config.env_mapping) - if config.auth_path: - env["AUTH_MODULE_PATH"] = config.auth_path - - if env_file: - resolved_env_file = _resolve_path(env_file, cwd=cwd) - _apply_env_layer( - env, - _read_env_layer(resolved_env_file, inherited=inherited), ) + return tuple(assignments) - env.update(inherited) - env.pop("AGENTSEEK_GRAPHS", None) - if config_path is not None: - env["AGENTSEEK_GRAPHS"] = str(config_path) - return env +def build_container_selection( + *, + config_path: Path, + pass_env: Sequence[str], + compose_pass_env: Sequence[str], +) -> ContainerSelection: + """Parse container selectors without coupling them to Docker execution.""" -def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: - command = [ + config = _load_cli_config(config_path) + + def normalized(names: Sequence[str]) -> frozenset[str]: + normalized_names = frozenset(name.strip() for name in names) + if "" in normalized_names: + raise CliError("Container environment selector names must be non-empty.") + return normalized_names + + return ContainerSelection( + pass_env=normalized(pass_env), + compose_env=normalized((*config.compose_env, *compose_pass_env)), + ) + + +def _build_container_environment_plan( + *, + config_path: Path, + env_file: str | None, + cwd: Path, + selection: ContainerSelection, + postgres_uri: str | None, +) -> EnvironmentPlan: + host_plan = build_host_environment_plan( + config_path=config_path, + env_file=env_file, + cwd=cwd, + role=None, + ) + return EnvironmentPlan( + config_path=host_plan.config_path, + config_dotenv=host_plan.config_dotenv, + config_mapping=host_plan.config_mapping, + auth_path=host_plan.auth_path, + cli_dotenv=host_plan.cli_dotenv, + launch_environment=host_plan.launch_environment, + command_assignments=build_container_command_assignments( + config_path=config_path, + cwd=cwd, + postgres_uri=postgres_uri, + ), + explicit_names=selection.pass_env, + ) + + +def _resolve_application_container_payload( + plan: EnvironmentPlan, + *, + selection: ContainerSelection, +) -> tuple[dict[str, str], FinalAuthSelection | None]: + try: + resolved = resolve_environment(plan, APP_CONTAINER_POLICY) + payload = dict(select_application_payload(resolved, selection)) + except (ContainerPolicyError, DotenvFileError) as exc: + raise CliError(str(exc)) from exc + auth_selection: FinalAuthSelection | None = None + if "AUTH_MODULE_PATH" in payload: + auth_selection = FinalAuthSelection( + payload["AUTH_MODULE_PATH"], resolved.origins["AUTH_MODULE_PATH"] + ) + return payload, auth_selection + + +def _is_valid_custom_image_auth(reference: str) -> bool: + if reference == "": + return True + if ( + reference.startswith(".") + or reference.partition(":")[0].endswith(".py") + or "/" in reference + or "\\" in reference + or re.match(r"^[A-Za-z]:", reference) + ): + return False + parts = _split_symbol_reference(reference) + if parts is None or reference.count(":") != 1: + return False + module_name, symbol_name = parts + identifier = r"[A-Za-z_][A-Za-z0-9_]*" + return bool( + re.fullmatch(rf"{identifier}(?:\.{identifier})*", module_name) + and re.fullmatch(identifier, symbol_name) + ) + + +def _planner_dotenv_paths(env_file: str | None, *, cwd: Path) -> tuple[Path, ...]: + return () if env_file is None else (_resolve_path(env_file, cwd=cwd),) + + +def _runtime_entrypoint_prefix(*, isolated: bool) -> list[str]: + return [ sys.executable, + *(["-I"] if isolated else []), "-m", "agentseek_api.runtime_entrypoint", + *(["--preloaded-v1"] if isolated else []), + ] + + +def build_uvicorn_command( + *, host: str, port: int, reload_enabled: bool, isolated: bool = False +) -> list[str]: + command = [ + *_runtime_entrypoint_prefix(isolated=isolated), "uvicorn", "--", "agentseek_api.main:app", @@ -369,25 +755,23 @@ def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list return command -def build_worker_command() -> list[str]: +def build_worker_command(*, isolated: bool = False) -> list[str]: return [ - sys.executable, - "-m", - "agentseek_api.runtime_entrypoint", + *_runtime_entrypoint_prefix(isolated=isolated), "worker", ] -def build_scheduler_command() -> list[str]: +def build_scheduler_command(*, isolated: bool = False) -> list[str]: return [ - sys.executable, - "-m", - "agentseek_api.runtime_entrypoint", + *_runtime_entrypoint_prefix(isolated=isolated), "scheduler", ] -def _default_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: +def _default_runner( + command: list[str], *, env: dict[str, str], cwd: str | None = None +) -> int: try: with ForwardingSignalGuard() as signals: child = ForegroundChildSupervisor.start(command, env=env, cwd=cwd) @@ -471,7 +855,9 @@ def _wait_for_dev_server_ready( ready_urls = [f"{api_url}/ok", f"{api_url}/health"] while time.monotonic() < deadline: if process.poll() is not None: - raise CliError(f"Development server exited before becoming ready (exit code {process.returncode}).") + raise CliError( + f"Development server exited before becoming ready (exit code {process.returncode})." + ) for ready_url in ready_urls: try: with urllib_request.urlopen(ready_url, timeout=2.0) as response: @@ -547,13 +933,23 @@ def _terminate_child(_signum, _frame) -> None: continue -def _execute_runtime_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: - config_path = discover_config_path(explicit_path=args.config, cwd=cwd) - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) +def _execute_runtime_command( + args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path +) -> int: + env = dict( + resolve_runtime_for_mode( + mode=args.environment_mode, + config_path=args.config, + env_file=args.env_file, + inherited=dict(os.environ), + cwd=cwd, + ).values + ) command = build_uvicorn_command( host=args.host, port=args.port, reload_enabled=getattr(args, "reload", False), + isolated=args.environment_mode is EnvironmentMode.PRELOADED_V1, ) return runner(command, env=env, cwd=str(cwd)) @@ -567,13 +963,21 @@ def _execute_dev_command( ) -> int: _write_onboard_banner(stdout) args.reload = not args.no_reload - config_path = discover_config_path(explicit_path=args.config, cwd=cwd) - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - env["STUDIO_AUTH_LOCAL_DEV"] = "true" + env = dict( + resolve_runtime_for_mode( + mode=args.environment_mode, + config_path=args.config, + env_file=args.env_file, + inherited=dict(os.environ), + cwd=cwd, + role="dev", + ).values + ) command = build_uvicorn_command( host=args.host, port=args.port, reload_enabled=args.reload, + isolated=args.environment_mode is EnvironmentMode.PRELOADED_V1, ) if runner is not _default_runner: return runner(command, env=env, cwd=str(cwd)) @@ -588,28 +992,63 @@ def _execute_dev_command( ) -def _execute_worker_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: - config_path = discover_config_path(explicit_path=args.config, cwd=cwd) - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - return runner(build_worker_command(), env=env, cwd=str(cwd)) +def _execute_worker_command( + args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path +) -> int: + env = dict( + resolve_runtime_for_mode( + mode=args.environment_mode, + config_path=args.config, + env_file=args.env_file, + inherited=dict(os.environ), + cwd=cwd, + ).values + ) + command = ( + build_worker_command(isolated=True) + if args.environment_mode is EnvironmentMode.PRELOADED_V1 + else build_worker_command() + ) + return runner(command, env=env, cwd=str(cwd)) -def _execute_scheduler_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: - config_path = discover_config_path(explicit_path=args.config, cwd=cwd) - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - return runner(build_scheduler_command(), env=env, cwd=str(cwd)) +def _execute_scheduler_command( + args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path +) -> int: + env = dict( + resolve_runtime_for_mode( + mode=args.environment_mode, + config_path=args.config, + env_file=args.env_file, + inherited=dict(os.environ), + cwd=cwd, + ).values + ) + return runner( + build_scheduler_command( + isolated=args.environment_mode is EnvironmentMode.PRELOADED_V1 + ), + env=env, + cwd=str(cwd), + ) def _load_config_payload(config_path: Path) -> dict[str, object]: try: payload = json.loads(config_path.read_text(encoding="utf-8")) except Exception as exc: # noqa: BLE001 - raise CliError(f"Config file '{config_path}' could not be parsed as JSON: {exc}") from exc + raise CliError( + f"Config file '{config_path}' could not be parsed as JSON: {exc}" + ) from exc if not isinstance(payload, dict): - raise CliError(f"Config file '{config_path}' must contain a top-level JSON object.") + raise CliError( + f"Config file '{config_path}' must contain a top-level JSON object." + ) graphs = payload.get("graphs") if not isinstance(graphs, dict) or not graphs: - raise CliError(f"Config file '{config_path}' must contain a non-empty 'graphs' object.") + raise CliError( + f"Config file '{config_path}' must contain a non-empty 'graphs' object." + ) return payload @@ -617,7 +1056,9 @@ def _container_config_path(*, config_path: Path, cwd: Path) -> str: try: relative_path = config_path.relative_to(cwd) except ValueError as exc: - raise CliError(f"Config file '{config_path}' must live under the project root '{cwd}' for Docker builds.") from exc + raise CliError( + f"Config file '{config_path}' must live under the project root '{cwd}' for Docker builds." + ) from exc return f"/deps/agent/{relative_path.as_posix()}" @@ -626,84 +1067,17 @@ def _containerize_symbol_reference(reference: str, *, cwd: Path) -> str: if parts is None: return reference module_name, symbol_name = parts - if module_name.endswith(".py") or module_name.startswith(".") or "/" in module_name or "\\" in module_name: + if ( + module_name.endswith(".py") + or module_name.startswith(".") + or "/" in module_name + or "\\" in module_name + ): resolved_module = _resolve_path(module_name, cwd=cwd) return f"{_container_config_path(config_path=resolved_module, cwd=cwd)}:{symbol_name}" return reference -def _resolve_dependency_path(dependency: str, *, config_path: Path) -> Path: - dependency_path = Path(dependency).expanduser() - if dependency_path.is_absolute(): - return dependency_path.resolve() - return (config_path.parent / dependency_path).resolve() - - -def _is_local_dependency(dependency: str) -> bool: - return dependency == "." or dependency.startswith(".") or "/" in dependency or "\\" in dependency - - -def _dependency_install_command(*, dependency_path: Path, cwd: Path) -> str | None: - container_path = _container_config_path(config_path=dependency_path, cwd=cwd) - if (dependency_path / "pyproject.toml").exists() or (dependency_path / "setup.py").exists(): - return f"pip install --no-cache-dir {container_path}" - if (dependency_path / "requirements.txt").exists(): - return f"pip install --no-cache-dir -r {container_path}/requirements.txt" - return None - - -def _find_installable_project_root(*, start: Path, cwd: Path) -> Path: - start_resolved = start.resolve() - cwd_resolved = cwd.resolve() - for candidate in [start_resolved, *start_resolved.parents]: - if candidate == cwd_resolved or cwd_resolved in candidate.parents: - if ( - (candidate / "pyproject.toml").exists() - or (candidate / "setup.py").exists() - or (candidate / "requirements.txt").exists() - ): - return candidate - if candidate == cwd_resolved: - break - return start_resolved - - -def _root_install_command(*, project_root: Path, cwd: Path) -> str | None: - container_path = _container_config_path(config_path=project_root, cwd=cwd) - if (project_root / "pyproject.toml").exists() or (project_root / "setup.py").exists(): - return f"pip install --no-cache-dir {container_path}" - if (project_root / "requirements.txt").exists(): - return f"pip install --no-cache-dir -r {container_path}/requirements.txt" - return None - - -def _docker_dependency_plan(*, config: CliConfig, config_path: Path, cwd: Path) -> tuple[list[str], list[str]]: - pythonpath_entries = ["/deps/agent"] - install_commands: list[str] = [] - seen_pythonpath: set[str] = set() - seen_install_commands: set[str] = set() - - for dependency in config.dependencies: - if _is_local_dependency(dependency): - dependency_path = _resolve_dependency_path(dependency, config_path=config_path) - container_path = _container_config_path(config_path=dependency_path, cwd=cwd) - if container_path not in seen_pythonpath: - pythonpath_entries.append(container_path) - seen_pythonpath.add(container_path) - install_command = _dependency_install_command(dependency_path=dependency_path, cwd=cwd) - if install_command is not None and install_command not in seen_install_commands: - install_commands.append(install_command) - seen_install_commands.add(install_command) - continue - - install_command = f"pip install --no-cache-dir {dependency}" - if install_command not in seen_install_commands: - install_commands.append(install_command) - seen_install_commands.add(install_command) - - return pythonpath_entries, install_commands - - def _ambient_container_env() -> dict[str, str]: return { key: value @@ -712,7 +1086,12 @@ def _ambient_container_env() -> dict[str, str]: } -def build_container_env(*, config_path: Path, env_file: str | None, cwd: Path) -> dict[str, str]: +def build_container_env( + *, + config_path: Path, + env_file: str | None, + cwd: Path, +) -> dict[str, str]: env = build_runtime_env( config_path=config_path, env_file=env_file, @@ -722,117 +1101,100 @@ def build_container_env(*, config_path: Path, env_file: str | None, cwd: Path) - env["AGENTSEEK_GRAPHS"] = _container_config_path(config_path=config_path, cwd=cwd) auth_module_path = env.get("AUTH_MODULE_PATH") if auth_module_path: - env["AUTH_MODULE_PATH"] = _containerize_symbol_reference(auth_module_path, cwd=cwd) + env["AUTH_MODULE_PATH"] = _containerize_symbol_reference( + auth_module_path, cwd=cwd + ) return env -def _default_base_image(*, python_version: str | None, image_distro: str | None) -> str: - version = (python_version or "3.12").strip() - distro = (image_distro or "debian").strip().lower() - if distro in {"", "debian"}: - return f"python:{version}-slim" - if distro in {"bookworm", "bullseye"}: - return f"python:{version}-slim-{distro}" - if distro == "wolfi": - raise CliError("image_distro 'wolfi' is not supported without an explicit base_image.") - raise CliError(f"Unsupported image_distro '{image_distro}'.") - - -def _supports_apt_get_base_image(base_image: str) -> bool: - normalized = base_image.strip().lower() - if normalized.startswith(("python:", "debian:", "ubuntu:", "langchain/langgraph")): - return "alpine" not in normalized and "wolfi" not in normalized - return any(marker in normalized for marker in ("debian", "ubuntu", "bookworm", "bullseye")) - - -def _validate_base_image(base_image: str) -> None: - if _supports_apt_get_base_image(base_image): - return - raise CliError( - f"Base image '{base_image}' is not supported because generated Dockerfiles require apt-get. " - "Use a Debian/Ubuntu-compatible image such as 'python:3.12-slim' or 'langchain/langgraph-api'." - ) - - -def render_dockerfile(*, config_path: Path, cwd: Path, base_image_override: str | None = None) -> str: - config = _load_cli_config(config_path) - project_root = _find_installable_project_root(start=config_path.parent, cwd=cwd) - container_config = _container_config_path(config_path=config_path, cwd=cwd) - pythonpath_entries, dependency_install_commands = _docker_dependency_plan( - config=config, - config_path=config_path, - cwd=cwd, - ) - root_install_command = _root_install_command(project_root=project_root, cwd=cwd) - if root_install_command is not None and root_install_command not in dependency_install_commands: - dependency_install_commands.append(root_install_command) - base_image = base_image_override or config.base_image or _default_base_image( - python_version=config.python_version, - image_distro=config.image_distro, - ) - _validate_base_image(base_image) - pip_install_prefix = "" - if config.pip_config_file is not None: - pip_config_path = _container_config_path(config_path=config.pip_config_file, cwd=cwd) - pip_install_prefix = f"PIP_CONFIG_FILE={pip_config_path} " - return "\n".join( - [ - f"FROM {base_image}", - "", - "ENV PYTHONDONTWRITEBYTECODE=1", - "ENV PYTHONUNBUFFERED=1", - f"ENV PYTHONPATH={':'.join(pythonpath_entries)}", - "", - "RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*", - "", - "WORKDIR /deps/agent", - "COPY . /deps/agent", - *[f"RUN {pip_install_prefix}{command}" for command in dependency_install_commands], - *config.dockerfile_lines, - f"ENV AGENTSEEK_GRAPHS={container_config}", - f"EXPOSE {DEFAULT_API_PORT}", - f'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "{DEFAULT_API_PORT}"]', - "", - ] - ) - - -def write_dockerfile(*, config_path: Path, save_path: Path, cwd: Path, base_image_override: str | None = None) -> Path: - save_path.parent.mkdir(parents=True, exist_ok=True) - save_path.write_text( - render_dockerfile(config_path=config_path, cwd=cwd, base_image_override=base_image_override), - encoding="utf-8", - ) - return save_path - - -def _execute_dockerfile_command(args: argparse.Namespace, *, stdout: TextIO, cwd: Path) -> int: +def _execute_dockerfile_command( + args: argparse.Namespace, + *, + stdout: TextIO, + cwd: Path, + runtime_artifact: RuntimeArtifactV1, +) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) if config_path is None: - raise CliError(f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json.") + raise CliError( + f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." + ) + _load_cli_config(config_path) save_path = _resolve_path(args.save_path, cwd=cwd) - write_dockerfile(config_path=config_path, save_path=save_path, cwd=cwd) - stdout.write(f"{save_path}\n") + plan = plan_container_image( + config_path=config_path, + dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), + runtime_artifact=runtime_artifact, + invocation_cwd=cwd, + ) + dockerfile_bytes = render_build_dockerfile(plan) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile_bytes, + output_root=save_path, + ) + stdout.write(f"{bundle.dockerfile}\n") return 0 -def _execute_build_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +def _execute_build_command( + args: argparse.Namespace, + *, + process_transport: ProcessTransport, + cwd: Path, + runtime_artifact: RuntimeArtifactV1, +) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) if config_path is None: - raise CliError(f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json.") - generated_dockerfile = write_dockerfile( + raise CliError( + f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." + ) + _load_cli_config(config_path) + build_plan = plan_container_image( + config_path=config_path, + dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), + runtime_artifact=runtime_artifact, + invocation_cwd=cwd, + ) + dockerfile_bytes = render_build_dockerfile(build_plan) + environment_plan = build_host_environment_plan( config_path=config_path, - save_path=(cwd / ".agentseek" / "Dockerfile").resolve(), + env_file=args.env_file, cwd=cwd, + role=None, ) - command = ["docker", "build"] - if args.platform: - command.extend(["--platform", args.platform]) - if args.pull: - command.append("--pull") - command.extend(["-t", args.tag, "-f", str(generated_dockerfile), "."]) - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - return runner(command, env=env, cwd=str(cwd)) + docker_control = dict(docker_control_environment(environment_plan)) + docker_executable = resolve_docker_executable(docker_control) + with ExitStack() as cleanup: + sweep_expired_artifacts( + prefix="agentseek-build-", + older_than_seconds=24 * 60 * 60, + ) + output_root = cleanup.enter_context( + private_directory(prefix="agentseek-build-") + ) + bundle = materialize_build_bundle( + build_plan, + dockerfile_bytes=dockerfile_bytes, + output_root=output_root, + ) + invocation = build_image_invocation( + bundle, + plan=build_plan, + docker_executable=docker_executable, + docker_control=docker_control, + tag=args.tag, + platform=args.platform, + pull=args.pull, + ) + require_supported_buildx( + docker_executable=docker_executable, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + plan=build_plan, + ) + return process_transport(invocation).returncode def _container_name_for_port(port: int) -> str: @@ -856,47 +1218,50 @@ def _wait_for_http_ready(url: str, *, timeout_seconds: float) -> None: def _container_exists( name: str, *, - runner: Callable[..., int], - env: dict[str, str], + docker_executable: str, + process_transport: ProcessTransport, + docker_control: dict[str, str], cwd: Path, ) -> bool: - inspect_command = ["docker", "container", "inspect", name] - if runner is _default_runner: - completed = subprocess.run( - inspect_command, - env=env, - cwd=str(cwd), - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - return completed.returncode == 0 - return runner(inspect_command, env=env, cwd=str(cwd)) == 0 + invocation = build_docker_query_invocation( + argv=(docker_executable, "container", "inspect", name), + docker_control=docker_control, + cwd=cwd, + ) + return process_transport(invocation).returncode == 0 -def _execute_up_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +def _execute_up_command( + args: argparse.Namespace, + *, + process_transport: ProcessTransport, + cwd: Path, + runtime_artifact: RuntimeArtifactV1, +) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) if config_path is None: - raise CliError(f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json.") + raise CliError( + f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." + ) image = args.image - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - container_env = build_container_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - if not image: - image = f"agentseek-up:{args.port}" - generated_dockerfile = write_dockerfile( - config_path=config_path, - save_path=(cwd / ".agentseek" / "Dockerfile").resolve(), - cwd=cwd, - base_image_override=args.base_image, - ) - build_command = ["docker", "build"] - if args.pull: - build_command.append("--pull") - build_command.extend(["-t", image, "-f", str(generated_dockerfile), "."]) - build_exit_code = runner(build_command, env=env, cwd=str(cwd)) - if build_exit_code != 0: - return build_exit_code + selection = build_container_selection( + config_path=config_path, + pass_env=args.pass_env, + compose_pass_env=args.compose_pass_env, + ) + environment_plan = _build_container_environment_plan( + config_path=config_path, + env_file=args.env_file, + cwd=cwd, + selection=selection, + postgres_uri=args.postgres_uri, + ) + docker_control = dict(docker_control_environment(environment_plan)) + docker_executable = resolve_docker_executable(docker_control) + application_payload, final_auth = _resolve_application_container_payload( + environment_plan, selection=selection + ) compose_path: Path | None = None if args.docker_compose: @@ -904,51 +1269,229 @@ def _execute_up_command(args: argparse.Namespace, *, runner: Callable[..., int], if not compose_path.exists(): raise CliError(f"Docker compose file '{compose_path}' does not exist.") - container_name = _container_name_for_port(args.port) - if args.recreate: - runner(["docker", "rm", "-f", container_name], env=env, cwd=str(cwd)) - elif _container_exists(container_name, runner=runner, env=env, cwd=cwd): - raise CliError( - f"Container '{container_name}' already exists. Re-run with '--recreate' or remove it manually." + generated_plan = None + generated_dockerfile_bytes: bytes | None = None + custom_image_contract = None + if image: + if final_auth is not None and not _is_valid_custom_image_auth(final_auth.value): + raise CliError( + "Custom-image auth cannot reference a host file; bake the module into the image and use an importable package reference." + ) + else: + _load_cli_config(config_path) + generated_plan = plan_container_image( + config_path=config_path, + dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), + base_image_override=args.base_image, + runtime_artifact=runtime_artifact, + invocation_cwd=cwd, ) - + generated_plan, auth_patch = plan_generated_up_auth(generated_plan, final_auth) + application_payload = dict(application_payload) + if auth_patch is None: + application_payload.pop("AUTH_MODULE_PATH", None) + else: + application_payload["AUTH_MODULE_PATH"] = auth_patch.value + application_payload["AGENTSEEK_GRAPHS"] = PRELOADED_MANIFEST_PATH + generated_dockerfile_bytes = render_build_dockerfile(generated_plan) + + application_payload.pop("PYTHONPATH", None) + application_payload.pop("PYTHONHOME", None) + application_payload["PYTHONSAFEPATH"] = "1" + application_payload["PYTHONNOUSERSITE"] = "1" + + compose_payload: dict[str, str] = {} + encoded_compose: bytes | None = None if compose_path is not None: - compose_command = ["docker", "compose", "-f", str(compose_path), "up", "-d"] + compose_payload = dict( + select_compose_payload( + application_payload=application_payload, + selected_names=selection.compose_env, + docker_control=docker_control, + ) + ) + encoded_compose = encode_compose_environment(compose_payload).encode("utf-8") + + with ExitStack() as cleanup: + build_invocation = None + if image: + custom_image_contract = inspect_image_contract( + image, + docker_executable=docker_executable, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) + application_payload = dict(application_payload) + application_payload["AGENTSEEK_GRAPHS"] = ( + custom_image_contract.manifest_path + ) + if compose_path is not None: + compose_payload = dict( + select_compose_payload( + application_payload=application_payload, + selected_names=selection.compose_env, + docker_control=docker_control, + ) + ) + encoded_compose = encode_compose_environment(compose_payload).encode( + "utf-8" + ) + else: + assert generated_plan is not None + assert generated_dockerfile_bytes is not None + image = f"agentseek-up:{args.port}" + sweep_expired_artifacts( + prefix="agentseek-build-", + older_than_seconds=24 * 60 * 60, + ) + output_root = cleanup.enter_context( + private_directory(prefix="agentseek-build-") + ) + bundle = materialize_build_bundle( + generated_plan, + dockerfile_bytes=generated_dockerfile_bytes, + output_root=output_root, + ) + build_invocation = build_image_invocation( + bundle, + plan=generated_plan, + docker_executable=docker_executable, + docker_control=docker_control, + tag=image, + pull=args.pull, + ) + + compose_env_path: Path | None = None + if compose_path is not None: + require_supported_compose( + docker_executable=docker_executable, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) + sweep_expired_artifacts( + prefix="agentseek-compose-", + older_than_seconds=24 * 60 * 60, + ) + assert encoded_compose is not None + compose_env_path = cleanup.enter_context( + private_artifact( + prefix="agentseek-compose-", + contents=encoded_compose, + ) + ) + + if build_invocation is not None: + assert generated_plan is not None + require_supported_buildx( + docker_executable=docker_executable, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + plan=generated_plan, + ) + build_exit_code = process_transport(build_invocation).returncode + if build_exit_code != 0: + return build_exit_code + custom_image_contract = inspect_image_contract( + image, + docker_executable=docker_executable, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) + application_payload = dict(application_payload) + application_payload["AGENTSEEK_GRAPHS"] = ( + custom_image_contract.manifest_path + ) + if compose_path is not None: + compose_payload = dict( + select_compose_payload( + application_payload=application_payload, + selected_names=selection.compose_env, + docker_control=docker_control, + ) + ) + verified_compose = encode_compose_environment(compose_payload).encode( + "utf-8" + ) + if verified_compose != encoded_compose: + raise CliError( + "The generated image contract changed the selected Compose payload." + ) + + container_name = _container_name_for_port(args.port) + remove_invocation = None if args.recreate: - compose_command.append("--force-recreate") - compose_exit_code = runner(compose_command, env=env, cwd=str(cwd)) - if compose_exit_code != 0: - return compose_exit_code + remove_invocation = build_docker_control_invocation( + argv=(docker_executable, "rm", "-f", container_name), + docker_control=docker_control, + cwd=cwd, + ) - command = [ - "docker", - "run", - "--detach", - "--name", - container_name, - "--add-host", - "host.docker.internal:host-gateway", - "-p", - f"{args.port}:{DEFAULT_API_PORT}", - ] - for key, value in sorted(container_env.items()): - command.extend(["-e", f"{key}={value}"]) - if args.postgres_uri: - command.extend( - [ - "-e", - f"METADATA_DB_URL={args.postgres_uri}", - "-e", - "METADATA_DB_BACKEND=postgresql", - ] + compose_invocation = None + if compose_path is not None: + assert compose_env_path is not None + compose_invocation = build_compose_invocation( + docker_executable=docker_executable, + compose_file=compose_path, + env_file=compose_env_path, + docker_control=docker_control, + application_payload=application_payload, + selected_names=selection.compose_env, + cwd=cwd, + recreate=args.recreate, + ) + + base_argv = ( + docker_executable, + "run", + "--detach", + "--name", + container_name, + "--add-host", + "host.docker.internal:host-gateway", + "-p", + f"{args.port}:{DEFAULT_API_PORT}", + ) + run_invocation = build_docker_run_invocation( + base_argv=base_argv, + image=image, + docker_control=docker_control, + application_payload=application_payload, + container_argv=( + () + if custom_image_contract is None + else custom_image_contract.container_argv + ), + cwd=cwd, ) - command.append(image) - run_exit_code = runner(command, env=env, cwd=str(cwd)) - if run_exit_code != 0: - return run_exit_code - if args.wait: - _wait_for_http_ready(f"http://127.0.0.1:{args.port}/health", timeout_seconds=30.0) - return 0 + + if remove_invocation is not None: + process_transport(remove_invocation) + elif _container_exists( + container_name, + docker_executable=docker_executable, + process_transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ): + raise CliError( + f"Container '{container_name}' already exists. Re-run with '--recreate' or remove it manually." + ) + if compose_invocation is not None: + compose_exit_code = process_transport(compose_invocation).returncode + if compose_exit_code != 0: + return compose_exit_code + run_exit_code = process_transport(run_invocation).returncode + if run_exit_code != 0: + return run_exit_code + if args.wait: + _wait_for_http_ready( + f"http://127.0.0.1:{args.port}/health", timeout_seconds=30.0 + ) + return 0 def _print_version(*, stdout: TextIO) -> int: @@ -972,13 +1515,37 @@ def _add_command_parsers( dev_parser.add_argument("--studio-url") dev_parser.add_argument("--allow-blocking", action="store_true") dev_parser.add_argument("--tunnel", action="store_true") + dev_parser.add_argument( + "--environment-mode", + type=EnvironmentMode, + choices=tuple(EnvironmentMode), + default=EnvironmentMode.RESOLVE, + ) serve_parser = subparsers.add_parser("serve", parents=[runtime_parent]) serve_parser.add_argument("--host", default="127.0.0.1") serve_parser.add_argument("--port", default=DEFAULT_API_PORT, type=int) + serve_parser.add_argument( + "--environment-mode", + type=EnvironmentMode, + choices=tuple(EnvironmentMode), + default=EnvironmentMode.RESOLVE, + ) - subparsers.add_parser("worker", parents=[runtime_parent]) - subparsers.add_parser("scheduler", parents=[runtime_parent]) + worker_parser = subparsers.add_parser("worker", parents=[runtime_parent]) + worker_parser.add_argument( + "--environment-mode", + type=EnvironmentMode, + choices=tuple(EnvironmentMode), + default=EnvironmentMode.RESOLVE, + ) + scheduler_parser = subparsers.add_parser("scheduler", parents=[runtime_parent]) + scheduler_parser.add_argument( + "--environment-mode", + type=EnvironmentMode, + choices=tuple(EnvironmentMode), + default=EnvironmentMode.RESOLVE, + ) subparsers.add_parser("version") @@ -993,6 +1560,8 @@ def _add_command_parsers( up_parser.add_argument("--base-image") up_parser.add_argument("--image") up_parser.add_argument("--postgres-uri") + up_parser.add_argument("--pass-env", action="append", default=[]) + up_parser.add_argument("--compose-pass-env", action="append", default=[]) up_parser.add_argument("--watch", action="store_true") up_parser.add_argument("--debugger-base-url") up_parser.add_argument("--debugger-port", type=int) @@ -1040,13 +1609,18 @@ def run_namespace( args: argparse.Namespace, *, runner: Callable[..., int] | None = None, + process_transport: ProcessTransport | None = None, stdout: TextIO | None = None, stderr: TextIO | None = None, cwd: str | Path | None = None, + runtime_artifact: RuntimeArtifactV1 = PUBLISHED_RUNTIME_ARTIFACT, ) -> int: command = args.command workdir = Path(cwd or Path.cwd()).resolve() run = runner or _default_runner + docker_transport = process_transport or ( + LegacyRunnerAdapter(run) if runner is not None else SubprocessTransport() + ) out = stdout or sys.stdout err = stderr or sys.stderr @@ -1076,9 +1650,19 @@ def run_namespace( if command == "scheduler": return _execute_scheduler_command(args, runner=run, cwd=workdir) if command == "dockerfile": - return _execute_dockerfile_command(args, stdout=out, cwd=workdir) + return _execute_dockerfile_command( + args, + stdout=out, + cwd=workdir, + runtime_artifact=runtime_artifact, + ) if command == "build": - return _execute_build_command(args, runner=run, cwd=workdir) + return _execute_build_command( + args, + process_transport=docker_transport, + cwd=workdir, + runtime_artifact=runtime_artifact, + ) if command == "up": _reject_unsupported_options( args, @@ -1090,9 +1674,20 @@ def run_namespace( "verbose", ), ) - return _execute_up_command(args, runner=run, cwd=workdir) + return _execute_up_command( + args, + process_transport=docker_transport, + cwd=workdir, + runtime_artifact=runtime_artifact, + ) raise CliError(f"Unsupported command '{command}'.") - except CliError as exc: + except ( + CliError, + ContainerPolicyError, + DockerRuntimeError, + SecureArtifactError, + ContainerBuildError, + ) as exc: err.write(f"{exc}\n") return 2 @@ -1102,15 +1697,25 @@ def main( *, prog: str | None = None, runner: Callable[..., int] | None = None, + process_transport: ProcessTransport | None = None, stdout: TextIO | None = None, stderr: TextIO | None = None, cwd: str | Path | None = None, + runtime_artifact: RuntimeArtifactV1 = PUBLISHED_RUNTIME_ARTIFACT, ) -> int: if prog is None: prog = _infer_cli_name() if argv is None else DEFAULT_CLI_NAME parser = create_parser(prog=prog) args = parser.parse_args(list(argv) if argv is not None else None) - return run_namespace(args, runner=runner, stdout=stdout, stderr=stderr, cwd=cwd) + return run_namespace( + args, + runner=runner, + process_transport=process_transport, + stdout=stdout, + stderr=stderr, + cwd=cwd, + runtime_artifact=runtime_artifact, + ) if __name__ == "__main__": diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py new file mode 100644 index 0000000..1cd539a --- /dev/null +++ b/src/agentseek_api/container_build.py @@ -0,0 +1,2831 @@ +"""Sanitized, deterministic build planning for the preloaded-v1 container contract.""" + +from __future__ import annotations + +import hashlib +import io +import json +import math +import os +import re +import shutil +import stat +import tarfile +import tomllib +import zipfile +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from dataclasses import dataclass, field, replace +from enum import StrEnum +from pathlib import Path, PurePosixPath +from types import MappingProxyType +from typing import Literal, TypeAlias +from urllib.parse import unquote, urlsplit + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import Version + +from agentseek_api.constants import DEFAULT_API_PORT +from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.environment import EnvironmentOrigin +from agentseek_api.secure_temp import ( + SecureArtifactError, + create_private_directory, + verify_private_directory, +) + +JsonScalar: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = JsonScalar | tuple["JsonValue", ...] | Mapping[str, "JsonValue"] + +_RUNTIME_VERSION = "0.3.0" +_CANDIDATE_RUNTIME_FILENAME = f"agentseek_api-{_RUNTIME_VERSION}-py3-none-any.whl" +_CONTAINER_ROOT = PurePosixPath("/deps/agent") +_VCS_METADATA_NAMES = frozenset({".git", ".hg", ".svn", ".bzr"}) + + +class ContainerBuildError(ValueError): + """A value-free failure to produce a safe container build plan.""" + + +def _freeze_json(value: object, *, location: str) -> JsonValue: + if isinstance(value, float) and not math.isfinite(value): + raise ContainerBuildError(f"{location} must contain finite numbers only.") + if value is None or isinstance(value, (bool, int, float, str)): + return value + if isinstance(value, (list, tuple)): + return tuple(_freeze_json(item, location=location) for item in value) + if isinstance(value, Mapping): + frozen: dict[str, JsonValue] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise ContainerBuildError(f"{location} keys must be strings.") + frozen[key] = _freeze_json(item, location=f"{location}.{key}") + return MappingProxyType(frozen) + raise ContainerBuildError(f"{location} must contain JSON values only.") + + +def _json_value(value: JsonValue) -> object: + if isinstance(value, Mapping): + return {key: _json_value(item) for key, item in value.items()} + if isinstance(value, tuple): + return [_json_value(item) for item in value] + return value + + +@dataclass(frozen=True) +class StructuredGraphV1: + graph: str + prepare_input: str | None = None + extract_output: str | None = None + name: str | None = None + description: str | None = None + input_schema: Mapping[str, JsonValue] | None = field(default=None, repr=False) + output_schema: Mapping[str, JsonValue] | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + for name in ("input_schema", "output_schema"): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, _freeze_json(dict(value), location=name)) + + +@dataclass(frozen=True) +class StoreTtlManifestV1: + refresh_on_read: bool | None = None + default_ttl: float | None = None + sweep_interval_minutes: int | None = None + + +@dataclass(frozen=True) +class StoreIndexManifestV1: + embed: str | None = None + dims: int | None = None + fields: tuple[str, ...] | None = None + + +@dataclass(frozen=True) +class StoreManifestV1: + ttl: StoreTtlManifestV1 | None = None + index: StoreIndexManifestV1 | None = None + + +@dataclass(frozen=True) +class CorsManifestV1: + allow_origins: tuple[str, ...] | None = None + allow_origin_regex: str | None = None + allow_methods: tuple[str, ...] | None = None + allow_headers: tuple[str, ...] | None = None + allow_credentials: bool | None = None + expose_headers: tuple[str, ...] | None = None + max_age: int | None = None + + +@dataclass(frozen=True) +class HttpManifestV1: + app: str | None = None + cors: CorsManifestV1 | None = None + disable_mcp: bool | None = None + disable_a2a: bool | None = None + + +@dataclass(frozen=True) +class AuthOpenApiManifestV1: + security_schemes: Mapping[str, Mapping[str, JsonValue]] | None = field( + default=None, repr=False + ) + security: tuple[Mapping[str, tuple[str, ...]], ...] | None = field( + default=None, repr=False + ) + + def __post_init__(self) -> None: + if self.security_schemes is not None: + schemes: dict[str, Mapping[str, JsonValue]] = {} + for name, scheme in self.security_schemes.items(): + frozen = _freeze_json( + dict(scheme), location=f"auth.openapi.securitySchemes.{name}" + ) + assert isinstance(frozen, Mapping) + schemes[name] = frozen + object.__setattr__(self, "security_schemes", MappingProxyType(schemes)) + if self.security is not None: + object.__setattr__( + self, + "security", + tuple( + MappingProxyType( + {name: tuple(scopes) for name, scopes in requirement.items()} + ) + for requirement in self.security + ), + ) + + +@dataclass(frozen=True) +class AuthPolicyManifestV1: + openapi: AuthOpenApiManifestV1 | None = field(default=None, repr=False) + disable_studio_auth: bool | None = None + + +class InstallActionKind(StrEnum): + PROJECT = "project" + REQUIREMENTS = "requirements" + PEP508 = "pep508" + SOURCE_ONLY = "source-only" + + +@dataclass(frozen=True) +class InstallAction: + kind: InstallActionKind + operand: str = field(repr=False) + + +class SourceReason(StrEnum): + GRAPH = "graph" + DEPENDENCY = "dependency" + GRAPH_HOOK = "graph-hook" + STORE_HOOK = "store-hook" + HTTP_APP = "http-app" + AUTH = "auth" + BUILD_INCLUDE = "build-include" + RUNTIME_ARTIFACT = "runtime-artifact" + + +@dataclass(frozen=True) +class SelectedSource: + source_path: Path = field(repr=False) + reasons: frozenset[SourceReason] + source_identity: tuple[int, int, int, int] | None = field( + default=None, repr=False, compare=False + ) + source_sha256: str | None = field(default=None, repr=False, compare=False) + ancestor_identities: tuple[tuple[Path, tuple[int, int]], ...] = field( + default=(), repr=False, compare=False + ) + + def __post_init__(self) -> None: + if not self.reasons: + raise ContainerBuildError("A selected build source must have a reason.") + + +@dataclass(frozen=True) +class FinalAuthSelection: + value: str = field(repr=False) + origin: EnvironmentOrigin + + +@dataclass(frozen=True) +class AuthPayloadPatch: + value: str = field(repr=False) + + +class RuntimeArtifactSource(StrEnum): + PUBLISHED_INDEX = "published-index" + CANDIDATE_WHEEL = "candidate-wheel" + + +@dataclass(frozen=True) +class RuntimeArtifactV1: + distribution: Literal["agentseek-api"] + extra: Literal["embedded"] + version: Literal["0.3.0"] + source: RuntimeArtifactSource + candidate_wheel: Path | None = field(default=None, repr=False) + candidate_sha256: str | None = None + candidate_identity: tuple[int, int, int, int] | None = field( + default=None, repr=False + ) + + def __post_init__(self) -> None: + if ( + self.distribution != "agentseek-api" + or self.extra != "embedded" + or self.version != _RUNTIME_VERSION + ): + raise ContainerBuildError("The runtime artifact identity is incompatible.") + if self.source is RuntimeArtifactSource.PUBLISHED_INDEX: + if ( + self.candidate_wheel is not None + or self.candidate_sha256 is not None + or self.candidate_identity is not None + ): + raise ContainerBuildError( + "A published runtime artifact cannot carry candidate state." + ) + elif self.source is RuntimeArtifactSource.CANDIDATE_WHEEL: + if ( + self.candidate_wheel is None + or not re.fullmatch(r"[0-9a-f]{64}", self.candidate_sha256 or "") + or self.candidate_identity is None + ): + raise ContainerBuildError( + "A candidate runtime artifact requires a wheel and SHA-256." + ) + else: # pragma: no cover - enum construction normally prevents this + raise ContainerBuildError("The runtime artifact source is unsupported.") + + @property + def requirement(self) -> str: + return "agentseek-api[embedded]==0.3.0" + + +PUBLISHED_RUNTIME_ARTIFACT = RuntimeArtifactV1( + distribution="agentseek-api", + extra="embedded", + version="0.3.0", + source=RuntimeArtifactSource.PUBLISHED_INDEX, +) + + +@dataclass(frozen=True) +class RuntimeManifestV1: + distribution: Literal["agentseek-api"] + version: Literal["0.3.0"] + contract: Literal["preloaded-v1"] + + +@dataclass(frozen=True) +class ContainerRuntimeManifestV1: + schema_version: Literal[1] + runtime: RuntimeManifestV1 + graphs: Mapping[str, str | StructuredGraphV1] = field(repr=False) + dependencies: tuple[str, ...] = field(repr=False) + store: StoreManifestV1 | None = field(repr=False) + http: HttpManifestV1 | None = field(default=None, repr=False) + auth: AuthPolicyManifestV1 | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + if self.schema_version != 1 or self.runtime != RuntimeManifestV1( + distribution="agentseek-api", version="0.3.0", contract="preloaded-v1" + ): + raise ContainerBuildError("The runtime manifest identity is incompatible.") + object.__setattr__(self, "graphs", MappingProxyType(dict(self.graphs))) + object.__setattr__(self, "dependencies", tuple(self.dependencies)) + + def to_json_object(self) -> dict[str, object]: + document: dict[str, object] = { + "schema_version": 1, + "runtime": { + "distribution": self.runtime.distribution, + "version": self.runtime.version, + "contract": self.runtime.contract, + }, + "graphs": {name: _graph_json(graph) for name, graph in self.graphs.items()}, + "dependencies": list(self.dependencies), + } + if self.store is not None: + document["store"] = _store_json(self.store) + if self.http is not None: + document["http"] = _http_json(self.http) + if self.auth is not None: + document["auth"] = _auth_json(self.auth) + return document + + def to_json_bytes(self) -> bytes: + return ( + json.dumps( + self.to_json_object(), + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + + b"\n" + ) + + +@dataclass(frozen=True) +class ContainerBuildPlan: + base_image: str + python_version: str + image_distro: str + dockerfile_lines: tuple[str, ...] = field(repr=False) + runtime_artifact: RuntimeArtifactV1 = field(repr=False) + install_actions: tuple[InstallAction, ...] = field(repr=False) + pip_config_file: Path | None = field(repr=False) + manifest: ContainerRuntimeManifestV1 = field(repr=False) + selected_sources: Mapping[str, SelectedSource] = field(repr=False) + config_path: Path = field(repr=False) + project_root: Path = field(repr=False) + project_root_identity: tuple[int, int] = field(repr=False) + invocation_cwd: Path = field(repr=False) + excluded_paths: frozenset[Path] = field(default_factory=frozenset, repr=False) + pip_config_identity: tuple[int, int, int, int] | None = field( + default=None, repr=False, compare=False + ) + + def __post_init__(self) -> None: + object.__setattr__( + self, "selected_sources", MappingProxyType(dict(self.selected_sources)) + ) + + +@dataclass(frozen=True) +class BuildInventoryEntry: + relative_path: str + sha256: str + size: int + + +@dataclass(frozen=True) +class ContainerBuildBundle: + root: Path + context: Path + dockerfile: Path + manifest: Path + inventory: tuple[BuildInventoryEntry, ...] + plan_fingerprint: str = field(repr=False) + + def archive_bytes(self) -> bytes: + return create_deterministic_context_archive( + context=self.context, expected_inventory=self.inventory + ) + + +def container_build_plan_fingerprint(plan: ContainerBuildPlan) -> str: + """Return a value-free digest binding a bundle to every frozen plan field.""" + + artifact = plan.runtime_artifact + payload = { + "base_image": plan.base_image, + "python_version": plan.python_version, + "image_distro": plan.image_distro, + "dockerfile_lines": plan.dockerfile_lines, + "runtime_artifact": { + "distribution": artifact.distribution, + "extra": artifact.extra, + "version": artifact.version, + "source": artifact.source.value, + "candidate_wheel": ( + str(artifact.candidate_wheel) + if artifact.candidate_wheel is not None + else None + ), + "candidate_sha256": artifact.candidate_sha256, + "candidate_identity": artifact.candidate_identity, + }, + "install_actions": [ + {"kind": action.kind.value, "operand": action.operand} + for action in plan.install_actions + ], + "pip_config_file": ( + str(plan.pip_config_file) if plan.pip_config_file is not None else None + ), + "pip_config_identity": plan.pip_config_identity, + "manifest_sha256": hashlib.sha256(plan.manifest.to_json_bytes()).hexdigest(), + "selected_sources": [ + { + "destination": destination, + "source_path": str(selected.source_path), + "reasons": sorted(reason.value for reason in selected.reasons), + "source_identity": selected.source_identity, + "source_sha256": selected.source_sha256, + "ancestor_identities": [ + [str(path), identity] + for path, identity in selected.ancestor_identities + ], + } + for destination, selected in sorted(plan.selected_sources.items()) + ], + "config_path": str(plan.config_path), + "project_root": str(plan.project_root), + "project_root_identity": plan.project_root_identity, + "invocation_cwd": str(plan.invocation_cwd), + "excluded_paths": sorted(str(path) for path in plan.excluded_paths), + } + canonical = json.dumps( + payload, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + allow_nan=False, + ).encode("utf-8") + return hashlib.sha256(canonical).hexdigest() + + +@dataclass(frozen=True) +class EffectiveRuntimePolicyV1: + mcp_enabled: bool + a2a_enabled: bool + cors_middleware: Mapping[str, JsonValue] = field(repr=False) + custom_app: str | None + auth_openapi: Mapping[str, JsonValue] | None = field(default=None, repr=False) + studio_auth_disabled: bool = False + + def __post_init__(self) -> None: + object.__setattr__( + self, + "cors_middleware", + _freeze_json(dict(self.cors_middleware), location="cors_middleware"), + ) + if self.auth_openapi is not None: + object.__setattr__( + self, + "auth_openapi", + _freeze_json(dict(self.auth_openapi), location="auth_openapi"), + ) + + +def _omit_none(**items: object) -> dict[str, object]: + return {name: value for name, value in items.items() if value is not None} + + +def _graph_json(graph: str | StructuredGraphV1) -> object: + if isinstance(graph, str): + return graph + return _omit_none( + graph=graph.graph, + prepare_input=graph.prepare_input, + extract_output=graph.extract_output, + name=graph.name, + description=graph.description, + input_schema=None + if graph.input_schema is None + else _json_value(graph.input_schema), + output_schema=None + if graph.output_schema is None + else _json_value(graph.output_schema), + ) + + +def _store_json(store: StoreManifestV1) -> dict[str, object]: + document: dict[str, object] = {} + if store.ttl is not None: + document["ttl"] = _omit_none( + refresh_on_read=store.ttl.refresh_on_read, + default_ttl=store.ttl.default_ttl, + sweep_interval_minutes=store.ttl.sweep_interval_minutes, + ) + if store.index is not None: + document["index"] = _omit_none( + embed=store.index.embed, + dims=store.index.dims, + fields=None if store.index.fields is None else list(store.index.fields), + ) + return document + + +def _http_json(http: HttpManifestV1) -> dict[str, object]: + document = _omit_none( + app=http.app, + disable_mcp=http.disable_mcp, + disable_a2a=http.disable_a2a, + ) + if http.cors is not None: + document["cors"] = _omit_none( + allow_origins=None + if http.cors.allow_origins is None + else list(http.cors.allow_origins), + allow_origin_regex=http.cors.allow_origin_regex, + allow_methods=None + if http.cors.allow_methods is None + else list(http.cors.allow_methods), + allow_headers=None + if http.cors.allow_headers is None + else list(http.cors.allow_headers), + allow_credentials=http.cors.allow_credentials, + expose_headers=None + if http.cors.expose_headers is None + else list(http.cors.expose_headers), + max_age=http.cors.max_age, + ) + return document + + +def _auth_json(auth: AuthPolicyManifestV1) -> dict[str, object]: + document = _omit_none(disable_studio_auth=auth.disable_studio_auth) + if auth.openapi is not None: + openapi: dict[str, object] = {} + if auth.openapi.security_schemes is not None: + openapi["securitySchemes"] = { + key: _json_value(value) + for key, value in auth.openapi.security_schemes.items() + } + if auth.openapi.security is not None: + openapi["security"] = [ + {name: list(scopes) for name, scopes in item.items()} + for item in auth.openapi.security + ] + document["openapi"] = openapi + return document + + +def _safe_regular(path: Path, *, project_root: Path, purpose: str) -> Path: + try: + supplied = path.absolute() + raw = Path(os.path.normpath(supplied)) + resolved = supplied.resolve(strict=True) + relative_resolved = resolved.relative_to(project_root) + lexical_root = raw + for _ in relative_resolved.parts: + lexical_root = lexical_root.parent + relative_raw = raw.relative_to(lexical_root) + if ( + lexical_root.resolve(strict=True) != project_root + or relative_raw.parts != relative_resolved.parts + ): + raise ContainerBuildError( + f"The {purpose} source has an unsafe intermediate directory." + ) + current = lexical_root + for part in relative_raw.parts[:-1]: + current = current / part + parent_status = current.lstat() + if stat.S_ISLNK(parent_status.st_mode) or not stat.S_ISDIR( + parent_status.st_mode + ): + raise ContainerBuildError( + f"The {purpose} source has an unsafe intermediate directory." + ) + status = raw.lstat() + except ValueError as exc: + raise ContainerBuildError( + f"The {purpose} source must remain inside the project root." + ) from exc + except OSError as exc: + raise ContainerBuildError(f"The {purpose} source is missing.") from exc + if stat.S_ISLNK(status.st_mode) or not stat.S_ISREG(status.st_mode): + raise ContainerBuildError(f"The {purpose} source must be a regular file.") + if _VCS_METADATA_NAMES.intersection(resolved.relative_to(project_root).parts): + raise ContainerBuildError( + f"The {purpose} source selects excluded VCS metadata." + ) + return resolved + + +def _safe_directory(path: Path, *, project_root: Path, purpose: str) -> Path: + try: + raw = path.absolute() + relative_raw = raw.relative_to(project_root) + current = project_root + for part in relative_raw.parts: + current = current / part + component_status = current.lstat() + if stat.S_ISLNK(component_status.st_mode) or not stat.S_ISDIR( + component_status.st_mode + ): + raise ContainerBuildError( + f"The {purpose} source has an unsafe intermediate directory." + ) + raw_status = raw.lstat() + except ValueError as exc: + raise ContainerBuildError( + f"The {purpose} source must remain inside the project root." + ) from exc + except OSError as exc: + raise ContainerBuildError(f"The {purpose} source is missing.") from exc + if stat.S_ISLNK(raw_status.st_mode) or not stat.S_ISDIR(raw_status.st_mode): + raise ContainerBuildError(f"The {purpose} source must be a directory.") + resolved = path.resolve() + try: + resolved.relative_to(project_root) + except ValueError as exc: + raise ContainerBuildError( + f"The {purpose} source must remain inside the project root." + ) from exc + if _VCS_METADATA_NAMES.intersection(resolved.relative_to(project_root).parts): + raise ContainerBuildError( + f"The {purpose} source selects excluded VCS metadata." + ) + return resolved + + +def _pip_config_source(path: Path) -> tuple[Path, tuple[int, int, int, int]]: + """Freeze an external secret carrier's identity without reading its contents.""" + + try: + raw = path.absolute() + status = raw.lstat() + if stat.S_ISLNK(status.st_mode) or not stat.S_ISREG(status.st_mode): + raise ContainerBuildError("The pip config must be a readable regular file.") + if os.name != "nt" and status.st_mode & 0o444 == 0: + raise ContainerBuildError("The pip config must be a readable regular file.") + flags = _source_file_flags() + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + descriptor = os.open(raw, flags) + except ContainerBuildError: + raise + except OSError as exc: + raise ContainerBuildError( + "The pip config must be a readable regular file." + ) from exc + try: + opened = os.fstat(descriptor) + if not stat.S_ISREG(opened.st_mode) or _file_identity(opened) != _file_identity( + status + ): + raise ContainerBuildError( + "The pip config identity changed during planning." + ) + identity = _file_identity(opened) + finally: + os.close(descriptor) + return raw.resolve(strict=True), identity + + +def _container_path(relative: Path) -> str: + return str(_CONTAINER_ROOT / PurePosixPath(relative.as_posix())) + + +def _module_file(reference: str, *, base: Path) -> tuple[Path, str] | None: + module = reference.rsplit(":", 1)[0] + if ( + module.endswith(".py") + or module.startswith(".") + or "/" in module + or "\\" in module + ): + path = Path(module).expanduser() + if not path.is_absolute(): + path = base / path + return path, reference.rsplit(":", 1)[1] if ":" in reference else "" + candidate = base / (module.replace(".", os.sep) + ".py") + if candidate.exists(): + return candidate, reference.rsplit(":", 1)[1] if ":" in reference else "" + return None + + +def _is_path_reference(reference: str) -> bool: + module = reference.rsplit(":", 1)[0] + return ( + module.endswith(".py") + or module.startswith(".") + or "/" in module + or "\\" in module + or Path(module).is_absolute() + ) + + +def _add_selected( + selected: dict[str, SelectedSource], + *, + destination: str, + source: Path, + reason: SourceReason, + project_root: Path | None = None, +) -> None: + if destination.startswith("/") or ".." in PurePosixPath(destination).parts: + raise ContainerBuildError("A selected destination escaped the build context.") + selection_root = source.parent if project_root is None else project_root + source_status = source.lstat() + source_identity = _file_identity(source_status) + source_sha256 = hashlib.sha256( + _read_regular_source(source, expected_identity=source_identity) + ).hexdigest() + ancestors: list[tuple[Path, tuple[int, int]]] = [] + current = source.parent + while True: + ancestors.append((current, _directory_identity(current.lstat()))) + if current == selection_root: + break + if selection_root not in current.parents: + raise ContainerBuildError("A selected source escaped the project root.") + current = current.parent + ancestor_identities = tuple(reversed(ancestors)) + existing = selected.get(destination) + if existing is None: + selected[destination] = SelectedSource( + source, + frozenset({reason}), + source_identity=source_identity, + source_sha256=source_sha256, + ancestor_identities=ancestor_identities, + ) + return + if existing.source_path != source: + raise ContainerBuildError( + "Two different sources selected the same destination." + ) + if ( + existing.source_identity != source_identity + or existing.source_sha256 != source_sha256 + or existing.ancestor_identities != ancestor_identities + ): + raise ContainerBuildError("A selected source identity changed during planning.") + selected[destination] = SelectedSource( + source, + existing.reasons | frozenset({reason}), + source_identity=source_identity, + source_sha256=source_sha256, + ancestor_identities=ancestor_identities, + ) + + +def _select_file( + selected: dict[str, SelectedSource], + *, + source: Path, + project_root: Path, + reason: SourceReason, + excluded: frozenset[Path], + destination_prefix: str = "app", +) -> Path: + source = _safe_regular(source, project_root=project_root, purpose=reason.value) + relative = source.relative_to(project_root) + if ( + source in excluded + or _VCS_METADATA_NAMES.intersection(relative.parts) + or source.name + in { + ".gitignore", + ".gitattributes", + } + ): + raise ContainerBuildError(f"The selected {reason.value} source is excluded.") + if relative.parts and relative.parts[0] in {"agentseek_api", "agentseek_api.py"}: + raise ContainerBuildError( + "A selected source could shadow the installed runtime." + ) + destination = str( + PurePosixPath(destination_prefix) / PurePosixPath(relative.as_posix()) + ) + _add_selected( + selected, + destination=destination, + source=source, + reason=reason, + project_root=project_root, + ) + return source + + +def _select_tree( + selected: dict[str, SelectedSource], + *, + root: Path, + project_root: Path, + reason: SourceReason, + excluded: frozenset[Path], +) -> None: + root = _safe_directory(root, project_root=project_root, purpose=reason.value) + for candidate in sorted(root.rglob("*")): + relative = candidate.relative_to(project_root) + if _VCS_METADATA_NAMES.intersection(relative.parts): + continue + status = candidate.lstat() + if stat.S_ISLNK(status.st_mode): + raise ContainerBuildError( + f"The selected {reason.value} tree contains a symlink." + ) + if stat.S_ISDIR(status.st_mode): + continue + if not stat.S_ISREG(status.st_mode): + raise ContainerBuildError( + f"The selected {reason.value} tree contains a non-regular file." + ) + if candidate.resolve() in excluded: + continue + _select_file( + selected, + source=candidate, + project_root=project_root, + reason=reason, + excluded=excluded, + ) + + +def _validate_allowed( + raw: Mapping[str, object], allowed: set[str], location: str +) -> None: + unknown = set(raw) - allowed + if unknown: + raise ContainerBuildError(f"{location} contains unsupported fields.") + + +def _string_tuple(value: object, *, location: str) -> tuple[str, ...] | None: + if value is None: + return None + if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + raise ContainerBuildError(f"{location} must be an array of strings.") + return tuple(value) + + +def _optional_bool(value: object, *, location: str) -> bool | None: + if value is None: + return None + if not isinstance(value, bool): + raise ContainerBuildError(f"{location} must be a boolean.") + return value + + +def _optional_number( + value: object, *, location: str, integer: bool = False +) -> float | int | None: + if value is None: + return None + expected = int if integer else (int, float) + if isinstance(value, bool) or not isinstance(value, expected): + raise ContainerBuildError(f"{location} must be numeric.") + if isinstance(value, float) and not math.isfinite(value): + raise ContainerBuildError(f"{location} must be finite.") + return value + + +def _validate_preloaded_reference( + reference: str, *, location: str, allow_empty: bool = False +) -> None: + if reference == "" and allow_empty: + return + module, separator, symbol = reference.rpartition(":") + error = f"{location} must use an importable package or copied container module reference." + if not separator or not module or not symbol or "\\" in module: + raise ContainerBuildError(error) + if not _is_path_reference(reference): + identifier = r"[A-Za-z_][A-Za-z0-9_]*" + if not re.fullmatch( + rf"{identifier}(?:\.{identifier})*", module + ) or not re.fullmatch(identifier, symbol): + raise ContainerBuildError(error) + return + path = PurePosixPath(module) + normalized = path.as_posix() + if ( + not path.is_absolute() + or normalized != module + or ".." in path.parts + or path == _CONTAINER_ROOT + or _CONTAINER_ROOT not in path.parents + ): + raise ContainerBuildError(error) + + +def _parse_graphs( + raw: object, + *, + reference_base: Path, + project_root: Path, + selected: dict[str, SelectedSource], + excluded: frozenset[Path], + preloaded: bool = False, +) -> Mapping[str, str | StructuredGraphV1]: + if not isinstance(raw, dict) or not raw: + raise ContainerBuildError("graphs must be a non-empty object.") + graphs: dict[str, str | StructuredGraphV1] = {} + for name, value in raw.items(): + if not isinstance(name, str) or not name: + raise ContainerBuildError("graph names must be non-empty strings.") + references: list[tuple[str, SourceReason]] = [] + if isinstance(value, str): + graph: str | StructuredGraphV1 = value + references.append((value, SourceReason.GRAPH)) + elif isinstance(value, dict): + _validate_allowed( + value, + { + "graph", + "prepare_input", + "extract_output", + "name", + "description", + "input_schema", + "output_schema", + }, + f"graphs.{name}", + ) + if not isinstance(value.get("graph"), str): + raise ContainerBuildError(f"graphs.{name}.graph must be a string.") + optional_strings: dict[str, str | None] = {} + for key in ("prepare_input", "extract_output", "name", "description"): + item = value.get(key) + if item is not None and not isinstance(item, str): + raise ContainerBuildError(f"graphs.{name}.{key} must be a string.") + optional_strings[key] = item + input_schema = value.get("input_schema") + output_schema = value.get("output_schema") + if input_schema is not None and not isinstance(input_schema, dict): + raise ContainerBuildError( + f"graphs.{name}.input_schema must be an object." + ) + if output_schema is not None and not isinstance(output_schema, dict): + raise ContainerBuildError( + f"graphs.{name}.output_schema must be an object." + ) + graph = StructuredGraphV1( + graph=value["graph"], + prepare_input=optional_strings["prepare_input"], + extract_output=optional_strings["extract_output"], + name=optional_strings["name"], + description=optional_strings["description"], + input_schema=None + if input_schema is None + else _freeze_json(input_schema, location=f"graphs.{name}.input_schema"), # type: ignore[arg-type] + output_schema=None + if output_schema is None + else _freeze_json( + output_schema, location=f"graphs.{name}.output_schema" + ), # type: ignore[arg-type] + ) + references.append((graph.graph, SourceReason.GRAPH)) + if graph.prepare_input is not None: + references.append((graph.prepare_input, SourceReason.GRAPH_HOOK)) + if graph.extract_output is not None: + references.append((graph.extract_output, SourceReason.GRAPH_HOOK)) + else: + raise ContainerBuildError(f"graphs.{name} must be a string or object.") + for reference, reason in references: + if preloaded: + _validate_preloaded_reference( + reference, location=f"graphs.{name}.{reason.value}" + ) + continue + located = _module_file(reference, base=reference_base) + if located is None: + continue + module_file, symbol = located + module_file = _safe_regular( + module_file, project_root=project_root, purpose=reason.value + ) + _select_file( + selected, + source=module_file, + project_root=project_root, + reason=reason, + excluded=excluded, + ) + package_root = module_file.parent + if (package_root / "__init__.py").exists(): + _select_file( + selected, + source=package_root / "__init__.py", + project_root=project_root, + reason=reason, + excluded=excluded, + ) + module_text = reference.rsplit(":", 1)[0] + if ( + module_text.endswith(".py") + or module_text.startswith(".") + or "/" in module_text + or "\\" in module_text + ): + normalized = ( + f"{_container_path(module_file.relative_to(project_root))}:{symbol}" + ) + if isinstance(graph, str): + graph = normalized + elif reference == graph.graph: + graph = replace(graph, graph=normalized) + elif reference == graph.prepare_input: + graph = replace(graph, prepare_input=normalized) + elif reference == graph.extract_output: + graph = replace(graph, extract_output=normalized) + graphs[name] = graph + return MappingProxyType(graphs) + + +def _parse_store( + raw: object, + *, + reference_base: Path, + project_root: Path, + selected: dict[str, SelectedSource], + excluded: frozenset[Path], + preloaded: bool = False, +) -> StoreManifestV1 | None: + if raw is None: + return None + if not isinstance(raw, dict): + raise ContainerBuildError("store must be an object.") + _validate_allowed(raw, {"ttl", "index"}, "store") + ttl: StoreTtlManifestV1 | None = None + if (raw_ttl := raw.get("ttl")) is not None: + if not isinstance(raw_ttl, dict): + raise ContainerBuildError("store.ttl must be an object.") + _validate_allowed( + raw_ttl, + {"refresh_on_read", "default_ttl", "sweep_interval_minutes"}, + "store.ttl", + ) + ttl = StoreTtlManifestV1( + refresh_on_read=_optional_bool( + raw_ttl.get("refresh_on_read"), location="store.ttl.refresh_on_read" + ), + default_ttl=_optional_number( + raw_ttl.get("default_ttl"), location="store.ttl.default_ttl" + ), # type: ignore[arg-type] + sweep_interval_minutes=_optional_number( + raw_ttl.get("sweep_interval_minutes"), + location="store.ttl.sweep_interval_minutes", + integer=True, + ), # type: ignore[arg-type] + ) + index: StoreIndexManifestV1 | None = None + if (raw_index := raw.get("index")) is not None: + if not isinstance(raw_index, dict): + raise ContainerBuildError("store.index must be an object.") + _validate_allowed(raw_index, {"embed", "dims", "fields"}, "store.index") + embed = raw_index.get("embed") + if embed is not None and not isinstance(embed, str): + raise ContainerBuildError("store.index.embed must be a string.") + if isinstance(embed, str) and _is_path_reference(embed): + if preloaded: + _validate_preloaded_reference(embed, location="store.index.embed") + else: + located = _module_file(embed, base=reference_base) + if located is not None: + path, symbol = located + path = _select_file( + selected, + source=path, + project_root=project_root, + reason=SourceReason.STORE_HOOK, + excluded=excluded, + ) + embed = ( + f"{_container_path(path.relative_to(project_root))}:{symbol}" + ) + index = StoreIndexManifestV1( + embed=embed, + dims=_optional_number( + raw_index.get("dims"), location="store.index.dims", integer=True + ), # type: ignore[arg-type] + fields=_string_tuple( + raw_index.get("fields"), location="store.index.fields" + ), + ) + return StoreManifestV1(ttl=ttl, index=index) + + +def _parse_cors(raw: object) -> CorsManifestV1 | None: + if raw is None: + return None + if not isinstance(raw, dict): + raise ContainerBuildError("http.cors must be an object.") + _validate_allowed( + raw, + { + "allow_origins", + "allow_origin_regex", + "allow_methods", + "allow_headers", + "allow_credentials", + "expose_headers", + "max_age", + }, + "http.cors", + ) + regex = raw.get("allow_origin_regex") + if regex is not None and not isinstance(regex, str): + raise ContainerBuildError("http.cors.allow_origin_regex must be a string.") + return CorsManifestV1( + allow_origins=_string_tuple( + raw.get("allow_origins"), location="http.cors.allow_origins" + ), + allow_origin_regex=regex, + allow_methods=_string_tuple( + raw.get("allow_methods"), location="http.cors.allow_methods" + ), + allow_headers=_string_tuple( + raw.get("allow_headers"), location="http.cors.allow_headers" + ), + allow_credentials=_optional_bool( + raw.get("allow_credentials"), location="http.cors.allow_credentials" + ), + expose_headers=_string_tuple( + raw.get("expose_headers"), location="http.cors.expose_headers" + ), + max_age=_optional_number( + raw.get("max_age"), location="http.cors.max_age", integer=True + ), # type: ignore[arg-type] + ) + + +def _parse_http( + raw: object, + *, + reference_base: Path, + project_root: Path, + selected: dict[str, SelectedSource], + excluded: frozenset[Path], + preloaded: bool = False, +) -> HttpManifestV1 | None: + if raw is None: + return None + if not isinstance(raw, dict): + raise ContainerBuildError("http must be an object.") + _validate_allowed(raw, {"app", "cors", "disable_mcp", "disable_a2a"}, "http") + app = raw.get("app") + if app is not None and not isinstance(app, str): + raise ContainerBuildError("http.app must be a string.") + if isinstance(app, str) and preloaded: + _validate_preloaded_reference(app, location="http.app", allow_empty=True) + elif isinstance(app, str) and _is_path_reference(app): + located = _module_file(app, base=reference_base) + if located is not None: + path, symbol = located + path = _select_file( + selected, + source=path, + project_root=project_root, + reason=SourceReason.HTTP_APP, + excluded=excluded, + ) + app = f"{_container_path(path.relative_to(project_root))}:{symbol}" + return HttpManifestV1( + app=app, + cors=_parse_cors(raw.get("cors")), + disable_mcp=_optional_bool(raw.get("disable_mcp"), location="http.disable_mcp"), + disable_a2a=_optional_bool(raw.get("disable_a2a"), location="http.disable_a2a"), + ) + + +def _validate_security_scheme(name: str, raw: object) -> Mapping[str, JsonValue]: + if not isinstance(raw, dict): + raise ContainerBuildError( + f"auth.openapi.securitySchemes.{name} must be an object." + ) + location = f"auth.openapi.securitySchemes.{name}" + scheme_type = raw.get("type") + if not isinstance(scheme_type, str): + raise ContainerBuildError(f"{location}.type is required and must be a string.") + allowed_by_type = { + "apiKey": {"type", "description", "name", "in"}, + "http": {"type", "description", "scheme", "bearerFormat"}, + "oauth2": {"type", "description", "flows"}, + "openIdConnect": {"type", "description", "openIdConnectUrl"}, + } + allowed = allowed_by_type.get(scheme_type) + if allowed is None: + raise ContainerBuildError(f"{location}.type is unsupported.") + _validate_allowed(raw, allowed, location) + required_by_type = { + "apiKey": {"name", "in"}, + "http": {"scheme"}, + "oauth2": {"flows"}, + "openIdConnect": {"openIdConnectUrl"}, + } + missing = required_by_type[scheme_type] - raw.keys() + if missing: + raise ContainerBuildError(f"{location} is missing required fields.") + if scheme_type == "apiKey" and raw.get("in") not in {"query", "header", "cookie"}: + raise ContainerBuildError(f"{location}.in has an unsupported value.") + for key, value in raw.items(): + if key.startswith("x-"): + raise ContainerBuildError( + "OpenAPI security metadata cannot contain extensions." + ) + if key == "flows": + _validate_oauth_flows(value, location=f"{location}.flows") + continue + if not isinstance(value, str): + raise ContainerBuildError(f"{location}.{key} must be a string.") + if key.endswith("Url"): + _reject_credential_url(value) + return _freeze_json(raw, location=location) # type: ignore[return-value] + + +def _reject_credential_url(value: str) -> None: + parsed = urlsplit(value) + if parsed.username is not None or parsed.password is not None: + raise ContainerBuildError( + "OpenAPI security metadata has a credential-bearing URL." + ) + if parsed.query: + raise ContainerBuildError( + "OpenAPI security metadata URL query parameters are not permitted." + ) + if parsed.fragment and not re.fullmatch(r"sha256=[0-9a-fA-F]{64}", parsed.fragment): + raise ContainerBuildError( + "OpenAPI security metadata URL fragment is not permitted." + ) + + +def _validate_oauth_flows(value: object, *, location: str) -> None: + if not isinstance(value, dict) or not value: + raise ContainerBuildError(f"{location} must be an object.") + _validate_allowed( + value, + {"implicit", "password", "clientCredentials", "authorizationCode"}, + location, + ) + for flow_name, flow in value.items(): + if not isinstance(flow, dict): + raise ContainerBuildError(f"{location}.{flow_name} must be an object.") + allowed = {"tokenUrl", "refreshUrl", "scopes"} + if flow_name in {"implicit", "authorizationCode"}: + allowed.add("authorizationUrl") + _validate_allowed(flow, allowed, f"{location}.{flow_name}") + required = { + "implicit": {"authorizationUrl", "scopes"}, + "password": {"tokenUrl", "scopes"}, + "clientCredentials": {"tokenUrl", "scopes"}, + "authorizationCode": {"authorizationUrl", "tokenUrl", "scopes"}, + }[flow_name] + if required - flow.keys(): + raise ContainerBuildError( + f"{location}.{flow_name} is missing required flow fields." + ) + scopes = flow.get("scopes") + if not isinstance(scopes, dict) or not all( + isinstance(key, str) and isinstance(item, str) + for key, item in scopes.items() + ): + raise ContainerBuildError( + f"{location}.{flow_name}.scopes must be a string map." + ) + for key in ("authorizationUrl", "tokenUrl", "refreshUrl"): + url = flow.get(key) + if url is not None: + if not isinstance(url, str): + raise ContainerBuildError( + f"{location}.{flow_name}.{key} must be a string." + ) + _reject_credential_url(url) + + +def _parse_auth(raw: object) -> tuple[AuthPolicyManifestV1 | None, str | None]: + if raw is None: + return None, None + if not isinstance(raw, dict): + raise ContainerBuildError("auth must be an object.") + _validate_allowed(raw, {"path", "openapi", "disable_studio_auth"}, "auth") + auth_path = raw.get("path") + if auth_path is not None and not isinstance(auth_path, str): + raise ContainerBuildError("auth.path must be a string.") + openapi: AuthOpenApiManifestV1 | None = None + if (raw_openapi := raw.get("openapi")) is not None: + if not isinstance(raw_openapi, dict): + raise ContainerBuildError("auth.openapi must be an object.") + _validate_allowed(raw_openapi, {"securitySchemes", "security"}, "auth.openapi") + schemes: Mapping[str, Mapping[str, JsonValue]] | None = None + if (raw_schemes := raw_openapi.get("securitySchemes")) is not None: + if not isinstance(raw_schemes, dict): + raise ContainerBuildError( + "auth.openapi.securitySchemes must be an object." + ) + schemes = MappingProxyType( + { + str(name): _validate_security_scheme(str(name), scheme) + for name, scheme in raw_schemes.items() + } + ) + security: tuple[Mapping[str, tuple[str, ...]], ...] | None = None + if (raw_security := raw_openapi.get("security")) is not None: + if not isinstance(raw_security, list): + raise ContainerBuildError("auth.openapi.security must be an array.") + parsed: list[Mapping[str, tuple[str, ...]]] = [] + for requirement in raw_security: + if not isinstance(requirement, dict): + raise ContainerBuildError( + "auth.openapi.security requirements must be objects." + ) + item: dict[str, tuple[str, ...]] = {} + for name, scopes in requirement.items(): + if name not in (raw_schemes or {}): + raise ContainerBuildError( + "auth.openapi.security references an unknown scheme." + ) + parsed_scopes = _string_tuple( + scopes, location="auth.openapi.security scopes" + ) + assert parsed_scopes is not None + item[name] = parsed_scopes + parsed.append(MappingProxyType(item)) + security = tuple(parsed) + openapi = AuthOpenApiManifestV1(security_schemes=schemes, security=security) + policy = AuthPolicyManifestV1( + openapi=openapi, + disable_studio_auth=_optional_bool( + raw.get("disable_studio_auth"), location="auth.disable_studio_auth" + ), + ) + return policy, auth_path + + +def _parse_container_runtime_manifest_v1_object( + document: object, +) -> ContainerRuntimeManifestV1: + if not isinstance(document, dict): + raise ContainerBuildError("The runtime manifest must be an object.") + _validate_allowed( + document, + { + "schema_version", + "runtime", + "graphs", + "dependencies", + "store", + "http", + "auth", + }, + "runtime manifest", + ) + runtime = document.get("runtime") + if not isinstance(runtime, dict): + raise ContainerBuildError("The runtime manifest identity is incompatible.") + _validate_allowed(runtime, {"distribution", "version", "contract"}, "runtime") + if ( + type(document.get("schema_version")) is not int + or document.get("schema_version") != 1 + or runtime + != { + "distribution": "agentseek-api", + "version": _RUNTIME_VERSION, + "contract": "preloaded-v1", + } + ): + raise ContainerBuildError("The runtime manifest identity is incompatible.") + dependencies = document.get("dependencies") + if not isinstance(dependencies, list) or not all( + isinstance(item, str) and item for item in dependencies + ): + raise ContainerBuildError( + "Runtime manifest dependencies must be copied container directories." + ) + for item in dependencies: + path = PurePosixPath(item) + if ( + not path.is_absolute() + or path.as_posix() != item + or ".." in path.parts + or (path != _CONTAINER_ROOT and _CONTAINER_ROOT not in path.parents) + ): + raise ContainerBuildError( + "Runtime manifest dependencies must be normalized copied container directories." + ) + selected: dict[str, SelectedSource] = {} + graphs = _parse_graphs( + document.get("graphs"), + reference_base=Path("/"), + project_root=Path("/"), + selected=selected, + excluded=frozenset(), + preloaded=True, + ) + store = _parse_store( + document.get("store"), + reference_base=Path("/"), + project_root=Path("/"), + selected=selected, + excluded=frozenset(), + preloaded=True, + ) + http = _parse_http( + document.get("http"), + reference_base=Path("/"), + project_root=Path("/"), + selected=selected, + excluded=frozenset(), + preloaded=True, + ) + raw_auth = document.get("auth") + if isinstance(raw_auth, dict) and "path" in raw_auth: + raise ContainerBuildError("Runtime manifest auth.path is not supported.") + auth, _ = _parse_auth(raw_auth) + return ContainerRuntimeManifestV1( + schema_version=1, + runtime=RuntimeManifestV1( + distribution="agentseek-api", version="0.3.0", contract="preloaded-v1" + ), + graphs=graphs, + dependencies=tuple(dependencies), + store=store, + http=http, + auth=auth, + ) + + +def load_container_runtime_manifest_v1(path: Path) -> ContainerRuntimeManifestV1: + """Load exactly one canonical preloaded-v1 runtime manifest.""" + + try: + document = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContainerBuildError( + "The runtime manifest is missing or invalid JSON." + ) from exc + return _parse_container_runtime_manifest_v1_object(document) + + +def _dependency_is_local(value: str) -> bool: + if re.match(r"^[A-Za-z]:[\\/]", value): + return True + try: + Requirement(value) + except InvalidRequirement: + pass + else: + return False + if re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value) or re.match( + r"^[^/@\s]+@[^/:\s]+:", value + ): + return False + return ( + value in {".", ".."} + or value.startswith(("./", "../", "/", "~", ".\\", "..\\")) + or "/" in value + or "\\" in value + ) + + +def _dependency_error(message: str) -> ContainerBuildError: + return ContainerBuildError( + f"{message}; use pip_config_file for private dependencies." + ) + + +def _validate_dependency_fragment(fragment: str) -> None: + if not fragment: + return + seen: set[str] = set() + for component in fragment.split("&"): + raw_name, separator, raw_value = component.partition("=") + if not separator: + raise _dependency_error("Dependency URL fragment components are invalid") + name = unquote(raw_name) + value = unquote(raw_value) + if name in seen or any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in name + value + ): + raise _dependency_error("Dependency URL fragment components are invalid") + if re.search( + r"(?i)(?:^|[?&#;/])(?:token|auth|password|passwd|secret|credential|api[_-]?key)=", + value, + ): + raise _dependency_error( + "Dependency URL fragment contains credential-like data" + ) + seen.add(name) + if name == "sha256": + if not re.fullmatch(r"[0-9a-fA-F]{64}", value): + raise _dependency_error("Dependency URL sha256 fragment is invalid") + continue + if name == "subdirectory": + normalized = value.replace("\\", "/") + path = PurePosixPath(normalized) + if ( + not normalized + or normalized.startswith("/") + or any(part in {"", ".", ".."} for part in normalized.split("/")) + or path.as_posix() != normalized + ): + raise _dependency_error( + "Dependency URL subdirectory fragment must be normalized and relative" + ) + continue + raise _dependency_error("Dependency URL fragment component is not supported") + + +def validate_dependency_specification(value: str) -> None: + """Validate the V1 dependency grammar without disclosing the operand.""" + + if _dependency_is_local(value): + return + try: + requirement = Requirement(value) + except InvalidRequirement: + requirement = None + candidate = requirement.url if requirement is not None else None + if candidate is None and re.match(r"^[A-Za-z][A-Za-z0-9+.-]*:", value): + candidate = value + if candidate is None: + if requirement is not None: + return + raise ContainerBuildError("A dependency is not valid PEP 508 or HTTPS.") + + decoded_candidate = unquote(candidate) + if any( + ord(character) < 0x20 or ord(character) == 0x7F + for character in decoded_candidate + ): + raise _dependency_error("Dependency URL contains a control character") + try: + parsed = urlsplit(candidate) + decoded = urlsplit(decoded_candidate) + credentials_present = any( + part is not None + for part in ( + parsed.username, + parsed.password, + decoded.username, + decoded.password, + ) + ) + except ValueError as exc: + raise _dependency_error("Dependency URL is invalid") from exc + if credentials_present: + raise _dependency_error("Dependency URLs cannot contain credentials") + if parsed.query or decoded.query: + raise _dependency_error("Dependency URL query parameters are not permitted") + if parsed.scheme.lower() != "https" or not parsed.netloc: + raise _dependency_error("Dependency URLs must use direct HTTPS artifacts") + _validate_dependency_fragment(parsed.fragment) + if decoded.fragment != parsed.fragment: + _validate_dependency_fragment(decoded.fragment) + + +def _validate_requirement_url(value: str) -> None: + validate_dependency_specification(value) + + +def _check_runtime_requirement(text: str, *, location: str) -> None: + try: + requirement = Requirement(text) + except InvalidRequirement: + return + if requirement.name.lower().replace("_", "-") != "agentseek-api": + return + if requirement.specifier and not requirement.specifier.contains( + Version(_RUNTIME_VERSION), prereleases=True + ): + raise ContainerBuildError( + f"{location} excludes agentseek-api 0.3.0; migrate the project runtime pin to 0.3.0." + ) + + +def _check_static_metadata(root: Path) -> None: + requirements = root / "requirements.txt" + if requirements.is_file(): + for line in requirements.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + if stripped.startswith(("-r ", "--requirement ", "-r=", "--requirement=")): + raise ContainerBuildError( + "Nested requirements files are ambiguous; consolidate requirements.txt." + ) + if stripped.startswith( + ( + "--index-url", + "--extra-index-url", + "--find-links", + "-e ", + "--editable ", + ) + ): + _, _, option_value = stripped.replace("=", " ", 1).partition(" ") + _validate_requirement_url(option_value.strip()) + elif not stripped.startswith("-"): + _validate_requirement_url(stripped) + _check_runtime_requirement(stripped, location="requirements.txt") + pyproject = root / "pyproject.toml" + if pyproject.is_file(): + try: + payload = tomllib.loads(pyproject.read_text(encoding="utf-8")) + except (OSError, UnicodeError, tomllib.TOMLDecodeError) as exc: + raise ContainerBuildError( + "The selected pyproject.toml is invalid." + ) from exc + project = payload.get("project") + if isinstance(project, dict) and "dependencies" not in set( + project.get("dynamic", []) + ): + dependencies = project.get("dependencies", []) + if isinstance(dependencies, list): + for dependency in dependencies: + if isinstance(dependency, str): + _validate_requirement_url(dependency) + _check_runtime_requirement( + dependency, location="pyproject.toml" + ) + + +def _classify_local_dependency( + path: Path, *, project_root: Path +) -> tuple[InstallAction, bool]: + _safe_directory(path, project_root=project_root, purpose="dependency") + _check_static_metadata(path) + container = _container_path(path.relative_to(project_root)) + if (path / "pyproject.toml").is_file() or (path / "setup.py").is_file(): + return InstallAction(InstallActionKind.PROJECT, container), False + requirements = sorted(path.glob("requirements*.txt")) + if len(requirements) > 1 and not (path / "requirements.txt").is_file(): + raise ContainerBuildError("A dependency has ambiguous requirements files.") + if (path / "requirements.txt").is_file(): + return ( + InstallAction( + InstallActionKind.REQUIREMENTS, + _container_path((path / "requirements.txt").relative_to(project_root)), + ), + False, + ) + return InstallAction(InstallActionKind.SOURCE_ONLY, container), True + + +def _wheel_metadata_bytes(data: bytes) -> tuple[str, str]: + try: + with zipfile.ZipFile(io.BytesIO(data)) as archive: + matches = [ + name + for name in archive.namelist() + if name.endswith(".dist-info/METADATA") + ] + if len(matches) != 1: + raise ContainerBuildError("The candidate wheel metadata is ambiguous.") + text = archive.read(matches[0]).decode("utf-8") + except (OSError, UnicodeError, zipfile.BadZipFile, KeyError) as exc: + raise ContainerBuildError( + "The candidate wheel metadata could not be read." + ) from exc + fields: dict[str, str] = {} + for line in text.splitlines(): + if ":" in line: + name, _, value = line.partition(":") + fields.setdefault(name.lower(), value.strip()) + return fields.get("name", ""), fields.get("version", "") + + +def candidate_runtime_artifact( + wheel_path: Path, expected_sha256: str +) -> RuntimeArtifactV1: + wheel = Path(wheel_path).absolute() + try: + status = wheel.lstat() + except OSError as exc: + raise ContainerBuildError("The candidate wheel is missing.") from exc + if stat.S_ISLNK(status.st_mode) or not stat.S_ISREG(status.st_mode): + raise ContainerBuildError("The candidate wheel must be a regular file.") + if not re.fullmatch(r"[0-9a-f]{64}", expected_sha256): + raise ContainerBuildError("The candidate wheel SHA-256 is invalid.") + data = _read_regular_source(wheel, expected_identity=_file_identity(status)) + if hashlib.sha256(data).hexdigest() != expected_sha256: + raise ContainerBuildError("The candidate wheel SHA-256 does not match.") + name, version = _wheel_metadata_bytes(data) + if name.lower().replace("_", "-") != "agentseek-api" or version != _RUNTIME_VERSION: + raise ContainerBuildError("The candidate wheel identity is incompatible.") + return RuntimeArtifactV1( + distribution="agentseek-api", + extra="embedded", + version="0.3.0", + source=RuntimeArtifactSource.CANDIDATE_WHEEL, + candidate_wheel=wheel, + candidate_sha256=expected_sha256, + candidate_identity=_file_identity(status), + ) + + +def _file_identity(status: os.stat_result) -> tuple[int, int, int, int]: + return (status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns) + + +def _load_config(path: Path) -> dict[str, object]: + try: + value = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + raise ContainerBuildError( + "The container config is missing or invalid JSON." + ) from exc + if not isinstance(value, dict): + raise ContainerBuildError("The container config must be an object.") + return value + + +def _discover_project_root(config: Path) -> Path: + """Use the nearest installable/VCS ancestor, otherwise the config directory.""" + + start = config.parent.resolve() + for candidate in (start, *start.parents): + if any( + (candidate / marker).exists() + for marker in ("pyproject.toml", "setup.py", ".git") + ): + return candidate + return start + + +def _logical_runtime_reference( + reference: object, + *, + config_path: Path | None, +) -> str | None: + if not isinstance(reference, str): + return None + module, separator, symbol = reference.rpartition(":") + if not separator: + return reference + if module.startswith("/deps/agent/"): + return f"{module.removeprefix('/deps/agent/')}:{symbol}" + if config_path is not None and _is_path_reference(reference): + path = Path(module).expanduser() + if not path.is_absolute(): + path = config_path.parent / path + resolved = path.resolve() + project_root = _discover_project_root(config_path) + try: + relative = resolved.relative_to(project_root) + except ValueError: + return reference + return f"{relative.as_posix()}:{symbol}" + return reference + + +def _effective_runtime_policy( + document: Mapping[str, object], + *, + config_path: Path | None, +) -> EffectiveRuntimePolicyV1: + raw_http = document.get("http") + http = raw_http if isinstance(raw_http, Mapping) else {} + raw_cors = http.get("cors") + cors = raw_cors if isinstance(raw_cors, Mapping) else {} + origins = cors.get("allow_origins", ["*"]) + allow_credentials = cors.get("allow_credentials", origins not in (["*"], "*")) + cors_middleware: dict[str, JsonValue] = { + "allow_origins": _freeze_json(origins, location="http.cors.allow_origins"), + "allow_credentials": bool(allow_credentials), + "allow_methods": _freeze_json( + cors.get("allow_methods", ["*"]), location="http.cors.allow_methods" + ), + "allow_headers": _freeze_json( + cors.get("allow_headers", ["*"]), location="http.cors.allow_headers" + ), + "allow_origin_regex": _freeze_json( + cors.get("allow_origin_regex"), location="http.cors.allow_origin_regex" + ), + "expose_headers": _freeze_json( + cors.get("expose_headers", ["Content-Location", "Location"]), + location="http.cors.expose_headers", + ), + "max_age": _freeze_json(cors.get("max_age", 600), location="http.cors.max_age"), + } + raw_auth = document.get("auth") + auth = raw_auth if isinstance(raw_auth, Mapping) else {} + raw_openapi = auth.get("openapi") + openapi = ( + _freeze_json(dict(raw_openapi), location="auth.openapi") + if isinstance(raw_openapi, Mapping) + else None + ) + return EffectiveRuntimePolicyV1( + mcp_enabled=http.get("disable_mcp") is not True, + a2a_enabled=http.get("disable_a2a") is not True, + cors_middleware=cors_middleware, + custom_app=_logical_runtime_reference(http.get("app"), config_path=config_path), + auth_openapi=openapi, # type: ignore[arg-type] + studio_auth_disabled=auth.get("disable_studio_auth") is True, + ) + + +def interpret_host_runtime_policy( + payload: Mapping[str, object], *, config_path: Path +) -> EffectiveRuntimePolicyV1: + """Interpret released host config through the V1 policy comparison seam.""" + + return _effective_runtime_policy(payload, config_path=config_path) + + +def interpret_manifest_runtime_policy( + manifest: ContainerRuntimeManifestV1, +) -> EffectiveRuntimePolicyV1: + """Interpret the sanitized manifest through the same effective-policy seam.""" + + return _effective_runtime_policy(manifest.to_json_object(), config_path=None) + + +def plan_container_image( + *, + config_path: Path, + dotenv_paths: Sequence[Path] = (), + build_include: Sequence[str] | None = None, + base_image_override: str | None = None, + runtime_artifact: RuntimeArtifactV1 = PUBLISHED_RUNTIME_ARTIFACT, + invocation_cwd: Path | None = None, +) -> ContainerBuildPlan: + config = Path(config_path).absolute() + if invocation_cwd is None: + project_root = _discover_project_root(config) + resolved_invocation_cwd = project_root + else: + resolved_invocation_cwd = Path(invocation_cwd).resolve() + try: + invocation_status = resolved_invocation_cwd.lstat() + except OSError as exc: + raise ContainerBuildError("The invocation cwd is missing.") from exc + if stat.S_ISLNK(invocation_status.st_mode) or not stat.S_ISDIR( + invocation_status.st_mode + ): + raise ContainerBuildError("The invocation cwd must be a directory.") + project_root = resolved_invocation_cwd + reference_base = config.parent.resolve() + config = _safe_regular(config, project_root=project_root, purpose="config") + payload = _load_config(config) + raw_env = payload.get("env") + configured_dotenv: list[Path] = [] + if isinstance(raw_env, str): + path = Path(raw_env).expanduser() + if not path.is_absolute(): + path = reference_base / path + configured_dotenv.append(path) + all_dotenv = [*configured_dotenv, *(Path(item) for item in dotenv_paths)] + resolved_dotenv: list[Path] = [] + for dotenv in all_dotenv: + path = dotenv if dotenv.is_absolute() else project_root / dotenv + try: + parse_dotenv_file(path, ambient={}) + except DotenvFileError as exc: + raise ContainerBuildError(str(exc)) from exc + resolved_dotenv.append(path.resolve()) + raw_pip = payload.get("pip_config_file") + pip_path: Path | None = None + pip_identity: tuple[int, int, int, int] | None = None + if raw_pip is not None: + if not isinstance(raw_pip, str) or not raw_pip.strip(): + raise ContainerBuildError("pip_config_file must be a non-empty string.") + pip_candidate = Path(raw_pip).expanduser() + if not pip_candidate.is_absolute(): + pip_candidate = reference_base / pip_candidate + pip_path, pip_identity = _pip_config_source(pip_candidate) + candidate_source: Path | None = None + if runtime_artifact.source is RuntimeArtifactSource.CANDIDATE_WHEEL: + assert runtime_artifact.candidate_wheel is not None + assert runtime_artifact.candidate_sha256 is not None + candidate_source = _safe_regular( + runtime_artifact.candidate_wheel, + project_root=project_root, + purpose="candidate runtime artifact", + ) + rechecked_artifact = candidate_runtime_artifact( + candidate_source, runtime_artifact.candidate_sha256 + ) + if rechecked_artifact.candidate_identity != runtime_artifact.candidate_identity: + raise ContainerBuildError( + "The candidate wheel identity changed before planning." + ) + excluded = frozenset( + { + config, + *resolved_dotenv, + *(() if pip_path is None else (pip_path,)), + *(() if candidate_source is None else (candidate_source,)), + } + ) + + selected: dict[str, SelectedSource] = {} + graphs = _parse_graphs( + payload.get("graphs"), + reference_base=reference_base, + project_root=project_root, + selected=selected, + excluded=excluded, + ) + store = _parse_store( + payload.get("store"), + reference_base=reference_base, + project_root=project_root, + selected=selected, + excluded=excluded, + ) + http = _parse_http( + payload.get("http"), + reference_base=reference_base, + project_root=project_root, + selected=selected, + excluded=excluded, + ) + auth, dedicated_auth = _parse_auth(payload.get("auth")) + + static_auth: str | None = dedicated_auth + static_auth_base = reference_base + if static_auth is None and isinstance(raw_env, dict): + value = raw_env.get("AUTH_MODULE_PATH") + if isinstance(value, str): + static_auth = value + static_auth_base = resolved_invocation_cwd + if static_auth and _is_path_reference(static_auth): + located = _module_file(static_auth, base=static_auth_base) + if located is not None: + auth_file, _ = located + _select_file( + selected, + source=auth_file, + project_root=project_root, + reason=SourceReason.AUTH, + excluded=excluded, + ) + + includes = ( + tuple(payload.get("build_include", ())) + if build_include is None + else tuple(build_include) + ) + if not all(isinstance(item, str) for item in includes): + raise ContainerBuildError("build_include must contain strings.") + for item in includes: + candidate = Path(item).expanduser() + if not candidate.is_absolute(): + candidate = reference_base / candidate + try: + status = candidate.absolute().lstat() + except OSError as exc: + raise ContainerBuildError( + "A build_include source is missing from the project." + ) from exc + if stat.S_ISDIR(status.st_mode): + _select_tree( + selected, + root=candidate, + project_root=project_root, + reason=SourceReason.BUILD_INCLUDE, + excluded=excluded, + ) + else: + _select_file( + selected, + source=candidate, + project_root=project_root, + reason=SourceReason.BUILD_INCLUDE, + excluded=excluded, + ) + + raw_dependencies = payload.get("dependencies", []) + if not isinstance(raw_dependencies, list) or not all( + isinstance(item, str) and item for item in raw_dependencies + ): + raise ContainerBuildError("dependencies must be an array of non-empty strings.") + actions: list[InstallAction] = [] + runtime_roots: list[str] = [] + for dependency in raw_dependencies: + assert isinstance(dependency, str) + if _dependency_is_local(dependency): + local = Path(dependency).expanduser() + if not local.is_absolute(): + local = reference_base / local + local = _safe_directory( + local, project_root=project_root, purpose="dependency" + ) + action, needs_runtime_path = _classify_local_dependency( + local, project_root=project_root + ) + actions.append(action) + _select_tree( + selected, + root=local, + project_root=project_root, + reason=SourceReason.DEPENDENCY, + excluded=excluded, + ) + if needs_runtime_path and action.operand not in runtime_roots: + runtime_roots.append(action.operand) + else: + _validate_requirement_url(dependency) + _check_runtime_requirement(dependency, location="dependencies") + try: + Requirement(dependency) + except InvalidRequirement: + if not dependency.startswith("https://"): + raise ContainerBuildError( + "A dependency is not valid PEP 508 or HTTPS." + ) + actions.append(InstallAction(InstallActionKind.PEP508, dependency)) + + if runtime_artifact.source is RuntimeArtifactSource.CANDIDATE_WHEEL: + assert candidate_source is not None + destination = f"runtime/{candidate_source.name}" + _add_selected( + selected, + destination=destination, + source=candidate_source, + reason=SourceReason.RUNTIME_ARTIFACT, + project_root=project_root, + ) + + raw_base = payload.get("base_image") + raw_python = payload.get("python_version", "3.12") + raw_distro = payload.get("image_distro", "debian") + raw_lines = payload.get("dockerfile_lines", []) + if raw_base is not None and not isinstance(raw_base, str): + raise ContainerBuildError("base_image must be a string.") + if not isinstance(raw_python, str) or not isinstance(raw_distro, str): + raise ContainerBuildError( + "Container Python and distro settings must be strings." + ) + if not isinstance(raw_lines, list) or not all( + isinstance(line, str) for line in raw_lines + ): + raise ContainerBuildError("dockerfile_lines must be an array of strings.") + if base_image_override or raw_base: + base = base_image_override or raw_base + assert base is not None + elif raw_distro in {"", "debian"}: + base = f"python:{raw_python}-slim" + elif raw_distro in {"bookworm", "bullseye"}: + base = f"python:{raw_python}-slim-{raw_distro}" + else: + raise ContainerBuildError( + "image_distro is not supported without an explicit base_image." + ) + manifest = ContainerRuntimeManifestV1( + schema_version=1, + runtime=RuntimeManifestV1( + distribution="agentseek-api", version="0.3.0", contract="preloaded-v1" + ), + graphs=graphs, + dependencies=tuple(runtime_roots), + store=store, + http=http, + auth=auth, + ) + manifest = _parse_container_runtime_manifest_v1_object(manifest.to_json_object()) + return ContainerBuildPlan( + base_image=base, + python_version=raw_python, + image_distro=raw_distro, + dockerfile_lines=tuple(raw_lines), + runtime_artifact=runtime_artifact, + install_actions=tuple(actions), + pip_config_file=pip_path, + manifest=manifest, + selected_sources=selected, + config_path=config, + project_root=project_root, + project_root_identity=_directory_identity(project_root.lstat()), + invocation_cwd=resolved_invocation_cwd, + excluded_paths=excluded, + pip_config_identity=pip_identity, + ) + + +def _without_auth_reasons(plan: ContainerBuildPlan) -> dict[str, SelectedSource]: + selected: dict[str, SelectedSource] = {} + for destination, source in plan.selected_sources.items(): + reasons = source.reasons - frozenset({SourceReason.AUTH}) + if reasons: + selected[destination] = SelectedSource( + source.source_path, + reasons, + source_identity=source.source_identity, + source_sha256=source.source_sha256, + ancestor_identities=source.ancestor_identities, + ) + return selected + + +def plan_generated_up_auth( + plan: ContainerBuildPlan, selection: FinalAuthSelection | None +) -> tuple[ContainerBuildPlan, AuthPayloadPatch | None]: + selected = _without_auth_reasons(plan) + if selection is None: + return replace(plan, selected_sources=selected), None + if selection.value == "": + return replace(plan, selected_sources=selected), AuthPayloadPatch("") + if not _is_path_reference(selection.value): + return replace(plan, selected_sources=selected), AuthPayloadPatch( + selection.value + ) + auth_base = ( + plan.config_path.parent + if selection.origin.source_kind == "auth" + else plan.invocation_cwd + ) + located = _module_file(selection.value, base=auth_base) + if located is None: + return replace(plan, selected_sources=selected), AuthPayloadPatch( + selection.value + ) + source, symbol = located + source = _select_file( + selected, + source=source, + project_root=plan.project_root, + reason=SourceReason.AUTH, + excluded=plan.excluded_paths, + ) + rewritten = f"{_container_path(source.relative_to(plan.project_root))}:{symbol}" + return replace(plan, selected_sources=selected), AuthPayloadPatch(rewritten) + + +def _docker_exec_run(argv: Sequence[str], *, pip_secret: bool = False) -> str: + mount = ( + "--mount=type=secret,id=pip_config,target=/etc/pip.conf " if pip_secret else "" + ) + return f"RUN {mount}{json.dumps(list(argv), ensure_ascii=False)}" + + +def _pip_install_argv(action: InstallAction) -> tuple[str, ...] | None: + base = ( + "python", + "-m", + "pip", + "install", + "--no-cache-dir", + "--constraint", + "/opt/agentseek/runtime-constraints.txt", + ) + if action.kind is InstallActionKind.PROJECT: + return (*base, action.operand) + if action.kind is InstallActionKind.REQUIREMENTS: + return (*base, "--requirement", action.operand) + if action.kind is InstallActionKind.PEP508: + return (*base, action.operand) + if action.kind is InstallActionKind.SOURCE_ONLY: + return None + raise ContainerBuildError("The build plan contains an unsupported install action.") + + +def _candidate_destination(plan: ContainerBuildPlan) -> str | None: + if plan.runtime_artifact.source is not RuntimeArtifactSource.CANDIDATE_WHEEL: + return None + candidates = [ + destination + for destination, source in plan.selected_sources.items() + if SourceReason.RUNTIME_ARTIFACT in source.reasons + ] + if len(candidates) != 1 or not candidates[0].startswith("runtime/"): + raise ContainerBuildError( + "The candidate runtime artifact selection is invalid." + ) + return candidates[0] + + +def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: + """Render final Dockerfile bytes using only the immutable build plan.""" + + if ( + not plan.base_image + or any(character.isspace() for character in plan.base_image) + or "\0" in plan.base_image + ): + raise ContainerBuildError("The build plan base image is invalid.") + has_app = any( + destination == "app" or destination.startswith("app/") + for destination in plan.selected_sources + ) + pip_secret = plan.pip_config_file is not None + candidate_source = _candidate_destination(plan) + artifact = plan.runtime_artifact + manifest_sha256 = hashlib.sha256(plan.manifest.to_json_bytes()).hexdigest() + + lines = [ + "# syntax=docker/dockerfile:1.7", + f"FROM {plan.base_image}", + f"# agentseek-python-version={json.dumps(plan.python_version)}", + f"# agentseek-image-distro={json.dumps(plan.image_distro)}", + "ENV PYTHONDONTWRITEBYTECODE=1", + "ENV PYTHONUNBUFFERED=1", + "WORKDIR /deps/agent", + ] + if has_app: + lines.append("COPY app /deps/agent") + lines.append("COPY runtime-constraints.txt /opt/agentseek/runtime-constraints.txt") + if candidate_source is not None: + candidate_runtime_path = f"/opt/agentseek/runtime/{_CANDIDATE_RUNTIME_FILENAME}" + lines.append( + "COPY " + + json.dumps( + [candidate_source, candidate_runtime_path], + ensure_ascii=False, + ) + ) + + for action in plan.install_actions: + argv = _pip_install_argv(action) + if argv is not None: + lines.append(_docker_exec_run(argv, pip_secret=pip_secret)) + lines.extend(plan.dockerfile_lines) + + if candidate_source is not None: + if artifact.candidate_sha256 is None: + raise ContainerBuildError( + "The candidate runtime artifact selection is invalid." + ) + candidate_check = ( + "import hashlib,pathlib,sys;" + f"p=pathlib.Path('{candidate_runtime_path}');" + "sys.exit('candidate runtime hash mismatch') if " + f"hashlib.sha256(p.read_bytes()).hexdigest()!='{artifact.candidate_sha256}' " + "else None" + ) + lines.append(_docker_exec_run(("python", "-c", candidate_check))) + runtime_operand = f"{candidate_runtime_path}[embedded]" + else: + runtime_operand = artifact.requirement + lines.append( + _docker_exec_run( + ( + "python", + "-m", + "pip", + "install", + "--no-cache-dir", + "--force-reinstall", + "--constraint", + "/opt/agentseek/runtime-constraints.txt", + runtime_operand, + ), + pip_secret=pip_secret, + ) + ) + lines.append("COPY manifest.v1.json /opt/agentseek/manifest.v1.json") + manifest_check = ( + "import hashlib,json,pathlib,sys;" + "p=pathlib.Path('/opt/agentseek/manifest.v1.json');raw=p.read_bytes();" + "doc=json.loads(raw);canonical=(json.dumps(doc,ensure_ascii=False,sort_keys=True," + "separators=(',',':'),allow_nan=False)+'\\n').encode();" + "sys.exit('runtime manifest integrity mismatch') if " + f"raw!=canonical or hashlib.sha256(raw).hexdigest()!='{manifest_sha256}' " + "else None" + ) + lines.append(_docker_exec_run(("python", "-c", manifest_check))) + lines.append(_docker_exec_run(("python", "-m", "pip", "check"))) + runtime_check = ( + "import importlib.metadata,pathlib,sys,sysconfig,agentseek_api.cli;" + "distribution=importlib.metadata.distribution('agentseek-api');" + "sys.exit('runtime distribution version mismatch') if " + f"distribution.version!='{artifact.version}' else None;" + "module=pathlib.Path(agentseek_api.cli.__file__).resolve();" + "files=distribution.files;" + "sys.exit('runtime distribution file inventory missing') if " + "files is None else None;" + "owned={pathlib.Path(distribution.locate_file(item)).resolve() for item in files};" + "sys.exit('runtime module is not owned by distribution') if " + "module not in owned else None;" + "roots={pathlib.Path(value).resolve() for key,value in sysconfig.get_paths().items() " + "if key in {'purelib','platlib'}};" + "sys.exit('runtime module is outside site packages') if " + "not roots or not any(module.is_relative_to(root) for root in roots) else None;" + "sys.exit('Python 3.12 or newer is required') if " + "sys.version_info[:2]<(3,12) else None" + ) + lines.append(_docker_exec_run(("python", "-c", runtime_check))) + lines.extend( + ( + "LABEL org.agentseek.environment-contract=preloaded-v1", + "LABEL org.agentseek.runtime-manifest=/opt/agentseek/manifest.v1.json", + f"LABEL org.agentseek.runtime-distribution={artifact.distribution}", + f"LABEL org.agentseek.runtime-version={artifact.version}", + "ENTRYPOINT []", + "CMD " + + json.dumps( + [ + "python", + "-I", + "-m", + "agentseek_api.cli", + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + str(DEFAULT_API_PORT), + ] + ), + ) + ) + return ("\n".join(lines) + "\n").encode("utf-8") + + +def _write_file(path: Path, data: bytes) -> None: + target = path.absolute() + if os.name == "nt": # pragma: no cover - native Windows only + current = Path(target.anchor) + for part in target.parts[1:-1]: + current /= part + try: + status = current.lstat() + except FileNotFoundError: + current.mkdir(mode=0o700) + status = current.lstat() + expected = _directory_identity(status) + if not _directory_identity_matches(current, expected): + raise ContainerBuildError( + "An output ancestor changed or became unsafe." + ) + _write_file_descriptor(os.open(target, _output_file_flags(), 0o600), data) + return + + with _opened_output_parent(target, anchor=Path(target.anchor), create=False) as ( + directory_fd, + _, + ): + file_fd = os.open( + target.name, + _output_file_flags(), + 0o600, + dir_fd=directory_fd, + ) + _write_file_descriptor(file_fd, data) + + +def _prepare_output_ancestors( + path: Path, *, anchor: Path +) -> dict[Path, tuple[int, int]]: + target = path.absolute() + root = anchor.absolute() + try: + relative_parent = target.parent.relative_to(root) + except ValueError as exc: + raise ContainerBuildError("A build output escaped its verified root.") from exc + + identities: dict[Path, tuple[int, int]] = {} + if os.name == "nt": # pragma: no cover - native Windows only + current = root + for part in ("", *relative_parent.parts): + if part: + current /= part + try: + status = current.lstat() + except FileNotFoundError: + current.mkdir(mode=0o700) + status = current.lstat() + identity = _directory_identity(status) + if not _directory_identity_matches(current, identity): + raise ContainerBuildError( + "A build output ancestor changed or became unsafe." + ) + identities[current] = identity + return identities + + with _opened_output_parent(target, anchor=root, create=True) as ( + _, + identities, + ): + return identities + + +@contextmanager +def _opened_output_parent( + target: Path, *, anchor: Path, create: bool +) -> Iterator[tuple[int, dict[Path, tuple[int, int]]]]: + try: + relative_parent = target.parent.relative_to(anchor) + except ValueError as exc: + raise ContainerBuildError("A build output escaped its verified root.") from exc + + directory_flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + descriptors: list[int] = [] + identities: dict[Path, tuple[int, int]] = {} + try: + directory_fd = os.open(anchor, directory_flags) + descriptors.append(directory_fd) + root_status = os.fstat(directory_fd) + identities[anchor] = _directory_identity(root_status) + current = anchor + for part in relative_parent.parts: + try: + child_fd = os.open(part, directory_flags, dir_fd=directory_fd) + except FileNotFoundError: + if not create: + raise + os.mkdir(part, 0o700, dir_fd=directory_fd) + child_fd = os.open(part, directory_flags, dir_fd=directory_fd) + child_status = os.fstat(child_fd) + if not stat.S_ISDIR(child_status.st_mode): + os.close(child_fd) + raise ContainerBuildError( + "A build output ancestor changed or became unsafe." + ) + current /= part + identities[current] = _directory_identity(child_status) + descriptors.append(child_fd) + directory_fd = child_fd + yield directory_fd, identities + except ContainerBuildError: + raise + except OSError as exc: + raise ContainerBuildError( + "A build output ancestor changed or became unsafe." + ) from exc + finally: + for descriptor in reversed(descriptors): + try: + os.close(descriptor) + except OSError: + pass + + +def _output_file_flags() -> int: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + return flags + + +def _source_file_flags() -> int: + return os.O_RDONLY | getattr(os, "O_BINARY", 0) + + +def _write_file_descriptor(fd: int, data: bytes) -> None: + try: + remaining = memoryview(data) + while remaining: + written = os.write(fd, remaining) + if written <= 0: + raise ContainerBuildError("Could not write the build bundle.") + remaining = remaining[written:] + os.fsync(fd) + finally: + os.close(fd) + + +def _read_regular_source( + path: Path, + *, + expected_identity: tuple[int, int, int, int] | None = None, + identity_error: str = "A selected build source identity changed before materialization.", +) -> bytes: + try: + before = path.lstat() + if stat.S_ISLNK(before.st_mode) or not stat.S_ISREG(before.st_mode): + raise ContainerBuildError("A selected build source changed before copy.") + flags = _source_file_flags() + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags) + except ContainerBuildError: + raise + except OSError as exc: + raise ContainerBuildError( + "A selected build source changed before copy." + ) from exc + try: + opened = os.fstat(fd) + opened_identity = _file_identity(opened) + if opened_identity != _file_identity(before): + raise ContainerBuildError( + "A selected build source identity changed before copy." + ) + if expected_identity is not None and opened_identity != expected_identity: + raise ContainerBuildError(identity_error) + chunks: list[bytes] = [] + while True: + chunk = os.read(fd, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + finally: + os.close(fd) + + +def materialize_build_bundle( + plan: ContainerBuildPlan, + *, + dockerfile_bytes: bytes, + output_root: Path, +) -> ContainerBuildBundle: + try: + if _directory_identity(plan.project_root.lstat()) != plan.project_root_identity: + raise ContainerBuildError( + "The project root identity changed before materialization." + ) + except OSError as exc: + raise ContainerBuildError( + "The project root changed before materialization." + ) from exc + requested_root = Path(output_root).absolute() + try: + root_status = requested_root.lstat() + except FileNotFoundError: + root_status = None + except OSError as exc: + raise ContainerBuildError( + "The build output root could not be verified." + ) from exc + root_was_created = root_status is None + if root_status is not None: + if ( + stat.S_ISLNK(root_status.st_mode) + or not stat.S_ISDIR(root_status.st_mode) + or any(requested_root.iterdir()) + ): + raise ContainerBuildError( + "The build output root must be a new empty directory." + ) + root = requested_root.resolve(strict=True) + else: + root = requested_root.parent.resolve() / requested_root.name + try: + root.parent.mkdir(parents=True, exist_ok=True) + create_private_directory(root) + except (OSError, SecureArtifactError) as exc: + raise ContainerBuildError( + "The build output root could not be created." + ) from exc + _verify_private_output_root(root) + root_identity = _directory_identity(root.lstat()) + context = root / "context" + context.mkdir(mode=0o700) + context_identity = _directory_identity(context.lstat()) + created: list[Path] = [] + frozen_outputs: dict[Path, bytes] = {} + output_directory_identities = { + root: root_identity, + context: context_identity, + } + + def verify_output_directories() -> None: + for directory, expected in output_directory_identities.items(): + if directory == root: + message = "The build output root identity changed." + elif directory == context: + message = "The build output context identity changed." + else: + message = "A build output ancestor identity changed." + _verify_directory_identity( + directory, + expected=expected, + message=message, + ) + + def freeze_output_ancestors(path: Path) -> None: + try: + relative_parent = path.parent.relative_to(root) + except ValueError as exc: + raise ContainerBuildError( + "A build output escaped its verified root." + ) from exc + current = root + for part in relative_parent.parts: + current /= part + try: + status = current.lstat() + except OSError as exc: + raise ContainerBuildError("A build output ancestor changed.") from exc + identity = _directory_identity(status) + if not _directory_identity_matches(current, identity): + raise ContainerBuildError( + "A build output ancestor changed or became unsafe." + ) + expected = output_directory_identities.setdefault(current, identity) + if identity != expected: + raise ContainerBuildError("A build output ancestor identity changed.") + + def write_output(path: Path, data: bytes) -> None: + verify_output_directories() + for directory, identity in _prepare_output_ancestors(path, anchor=root).items(): + expected = output_directory_identities.setdefault(directory, identity) + if identity != expected: + raise ContainerBuildError("A build output ancestor identity changed.") + verify_output_directories() + _write_file(path, data) + verify_output_directories() + freeze_output_ancestors(path) + verify_output_directories() + frozen_outputs[path] = data + + try: + for destination, selected in sorted(plan.selected_sources.items()): + source = selected.source_path + try: + resolved_source = source.resolve(strict=True) + resolved_source.relative_to(plan.project_root) + except (OSError, ValueError) as exc: + raise ContainerBuildError( + "A selected source escaped the project root before materialization." + ) from exc + if resolved_source != source: + raise ContainerBuildError( + "A selected source gained an unsafe symlink before materialization." + ) + for ancestor, expected_identity in selected.ancestor_identities: + try: + current_identity = _directory_identity(ancestor.lstat()) + except OSError as exc: + raise ContainerBuildError( + "A selected source ancestor changed before materialization." + ) from exc + if current_identity != expected_identity: + raise ContainerBuildError( + "A selected source ancestor identity changed before materialization." + ) + if selected.source_identity is None: + raise ContainerBuildError("A selected source has no frozen identity.") + if selected.source_sha256 is None: + raise ContainerBuildError("A selected source has no frozen hash.") + data = _read_regular_source( + source, + expected_identity=selected.source_identity, + identity_error=( + "The candidate wheel identity changed before materialization." + if selected.reasons == frozenset({SourceReason.RUNTIME_ARTIFACT}) + else "A selected build source identity changed before materialization." + ), + ) + if hashlib.sha256(data).hexdigest() != selected.source_sha256: + raise ContainerBuildError( + "A selected build source hash changed before materialization." + ) + if selected.reasons == frozenset({SourceReason.RUNTIME_ARTIFACT}): + expected = plan.runtime_artifact.candidate_sha256 + if hashlib.sha256(data).hexdigest() != expected: + raise ContainerBuildError( + "The candidate wheel changed before materialization." + ) + name, version = _wheel_metadata_bytes(data) + if ( + name.lower().replace("_", "-") != "agentseek-api" + or version != _RUNTIME_VERSION + ): + raise ContainerBuildError("The candidate wheel identity changed.") + target = context / PurePosixPath(destination) + write_output(target, data) + created.append(target) + manifest = context / "manifest.v1.json" + write_output(manifest, plan.manifest.to_json_bytes()) + constraints = context / "runtime-constraints.txt" + write_output(constraints, b"agentseek-api==0.3.0\n") + dockerfile = context / "Dockerfile" + write_output(dockerfile, bytes(dockerfile_bytes)) + inventory = tuple( + BuildInventoryEntry( + relative_path=path.relative_to(context).as_posix(), + sha256=hashlib.sha256(frozen_outputs[path]).hexdigest(), + size=len(frozen_outputs[path]), + ) + for path in sorted(created + [manifest, constraints, dockerfile]) + ) + inventory_path = root / "inventory.json" + write_output( + inventory_path, + ( + json.dumps( + [ + { + "relative_path": entry.relative_path, + "sha256": entry.sha256, + "size": entry.size, + } + for entry in inventory + ], + sort_keys=True, + separators=(",", ":"), + ).encode() + + b"\n" + ), + ) + return ContainerBuildBundle( + root=root, + context=context, + dockerfile=dockerfile, + manifest=manifest, + inventory=inventory, + plan_fingerprint=container_build_plan_fingerprint(plan), + ) + except Exception: + root_is_original = _directory_identity_matches(root, root_identity) + context_is_original = root_is_original and _directory_identity_matches( + context, context_identity + ) + if context_is_original: + shutil.rmtree(context, ignore_errors=True) + inventory_path = root / "inventory.json" + if ( + root_is_original + and inventory_path.exists() + and not inventory_path.is_symlink() + ): + try: + inventory_path.unlink() + except OSError: + pass + if root_was_created and root_is_original: + try: + root.rmdir() + except OSError: + pass + raise + + +def _verify_private_output_root(root: Path) -> None: + try: + verify_private_directory(root) + except SecureArtifactError as exc: + raise ContainerBuildError( + "The build output root could not be verified private." + ) from exc + + +def _directory_identity_matches(path: Path, expected: tuple[int, int]) -> bool: + try: + status = path.lstat() + except OSError: + return False + is_junction = getattr(path, "is_junction", None) + return ( + stat.S_ISDIR(status.st_mode) + and not stat.S_ISLNK(status.st_mode) + and not (is_junction is not None and is_junction()) + and _directory_identity(status) == expected + ) + + +def _verify_directory_identity( + path: Path, + *, + expected: tuple[int, int], + message: str, +) -> None: + if not _directory_identity_matches(path, expected): + raise ContainerBuildError(message) + + +def _directory_identity(status: os.stat_result) -> tuple[int, int]: + return (status.st_dev, status.st_ino) + + +def create_deterministic_context_archive( + *, context: Path, expected_inventory: Sequence[BuildInventoryEntry] +) -> bytes: + expected = {entry.relative_path: entry for entry in expected_inventory} + actual_files: dict[str, Path] = {} + for path in context.rglob("*"): + status = path.lstat() + if stat.S_ISLNK(status.st_mode) or ( + not stat.S_ISDIR(status.st_mode) and not stat.S_ISREG(status.st_mode) + ): + raise ContainerBuildError("The build context contains an unsafe entry.") + if stat.S_ISREG(status.st_mode): + actual_files[path.relative_to(context).as_posix()] = path + if set(actual_files) != set(expected): + raise ContainerBuildError("The build context inventory changed.") + frozen_data: dict[str, bytes] = {} + for name, entry in expected.items(): + path = actual_files[name] + data = _read_regular_source(path) + digest = hashlib.sha256(data).hexdigest() + size = len(data) + if digest != entry.sha256 or size != entry.size: + raise ContainerBuildError("A build context file changed after inventory.") + frozen_data[name] = data + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w", format=tarfile.PAX_FORMAT) as archive: + for name in sorted(expected): + data = frozen_data[name] + info = tarfile.TarInfo(name) + info.size = len(data) + info.mode = 0o644 + info.uid = info.gid = info.mtime = 0 + info.uname = info.gname = "" + archive.addfile(info, io.BytesIO(data)) + return output.getvalue() + + +__all__ = [ + "AuthOpenApiManifestV1", + "AuthPayloadPatch", + "AuthPolicyManifestV1", + "BuildInventoryEntry", + "ContainerBuildBundle", + "ContainerBuildError", + "ContainerBuildPlan", + "ContainerRuntimeManifestV1", + "CorsManifestV1", + "EffectiveRuntimePolicyV1", + "FinalAuthSelection", + "HttpManifestV1", + "InstallAction", + "InstallActionKind", + "PUBLISHED_RUNTIME_ARTIFACT", + "RuntimeArtifactSource", + "RuntimeArtifactV1", + "RuntimeManifestV1", + "SelectedSource", + "SourceReason", + "StoreIndexManifestV1", + "StoreManifestV1", + "StoreTtlManifestV1", + "StructuredGraphV1", + "candidate_runtime_artifact", + "container_build_plan_fingerprint", + "create_deterministic_context_archive", + "interpret_host_runtime_policy", + "interpret_manifest_runtime_policy", + "load_container_runtime_manifest_v1", + "materialize_build_bundle", + "plan_container_image", + "plan_generated_up_auth", + "render_build_dockerfile", + "validate_dependency_specification", +] diff --git a/src/agentseek_api/container_policy.py b/src/agentseek_api/container_policy.py new file mode 100644 index 0000000..5b2e475 --- /dev/null +++ b/src/agentseek_api/container_policy.py @@ -0,0 +1,350 @@ +"""Explicit, immutable selection policies for container process boundaries.""" + +from __future__ import annotations + +import re +import sys +from collections.abc import Mapping +from dataclasses import dataclass +from types import MappingProxyType + +from agentseek_api.environment import ( + ContainerPolicyError, + EnvironmentPlan, + EnvironmentTarget, + NameScope, + ResolutionPolicy, + ResolvedEnvironment, +) + + +DOCKER_CONTROL_KEYS_BY_PLATFORM = MappingProxyType( + { + "common": frozenset( + { + "PATH", + "TMP", + "TEMP", + "TMPDIR", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + "DOCKER_AUTH_CONFIG", + "DOCKER_BUILDKIT", + "DOCKER_DEFAULT_PLATFORM", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "NO_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + "no_proxy", + "SSH_AUTH_SOCK", + } + ), + "linux": frozenset({"HOME", "XDG_CONFIG_HOME", "XDG_RUNTIME_DIR"}), + "darwin": frozenset({"HOME", "XDG_CONFIG_HOME"}), + "win32": frozenset({"USERPROFILE", "SYSTEMROOT", "COMSPEC", "PATHEXT"}), + } +) + +APPLICATION_COMPATIBILITY_KEYS = frozenset( + { + "AGENTSEEK_API_BASE", + "AGENTSEEK_API_KEY", + "AGENTSEEK_GRAPHS", + "AGENTSEEK_MODEL", + "AGENTSEEK_MODEL_API_KEY", + "AGENTSEEK_MODEL_PROVIDER", + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_URL", + "APP_NAME", + "AUTH_MODULE_PATH", + "BUB_API_BASE", + "BUB_API_KEY", + "BUB_MODEL", + "BUB_OPENAI_API_BASE", + "BUB_OPENAI_API_KEY", + "DAYTONA_API_KEY", + "DEEPAGENTS_MODEL", + "EMBEDDING_API_KEY", + "EMBEDDING_BASE_URL", + "EXECUTOR_BACKEND", + "GOOGLE_API_BASE", + "GOOGLE_API_KEY", + "LANGSMITH_API_KEY", + "METADATA_DB_BACKEND", + "METADATA_DB_URL", + "OCEANBASE_DB_NAME", + "OCEANBASE_HOST", + "OCEANBASE_PASSWORD", + "OCEANBASE_PORT", + "OCEANBASE_USER", + "OPENAI_API_BASE", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "PORT", + "REDIS_RUN_PROCESSING_KEY", + "REDIS_RUN_QUEUE_KEY", + "REDIS_SCHEDULER_LOCK_KEY", + "REDIS_SCHEDULER_LOCK_TTL_SECONDS", + "REDIS_STREAM_MAXLEN", + "REDIS_STREAM_TTL_SECONDS", + "REDIS_URL", + "REDIS_WORKER_LOCK_KEY", + "REDIS_WORKER_LOCK_TTL_SECONDS", + "REDIS_WORKER_POLL_TIMEOUT_SECONDS", + "SCHEDULER_CLAIM_LIMIT", + "SCHEDULER_POLL_INTERVAL_SECONDS", + "SCHEDULER_STARTED_TICK_STALE_AFTER_SECONDS", + "SEEKDB_EMBED", + "SEEKDB_EMBED_DIR", + "SEEKDB_URL", + "SILICONFLOW_API_KEY", + "STUDIO_AUTH_LOCAL_DEV", + "TAVILY_API_KEY", + "VLM_API_KEY", + "VLM_BASE_URL", + "WORKER_CONCURRENT_JOBS", + } +) + +HOST_RUNTIME_POLICY = ResolutionPolicy( + target=EnvironmentTarget.HOST_RUNTIME, + interpolation_scope=NameScope.ALL, + assignment_scope=NameScope.ALL, + export_scope=NameScope.ALL, + malformed="error", + unresolved="empty", +) +DOCKER_CONTROL_POLICY = ResolutionPolicy( + target=EnvironmentTarget.DOCKER_CONTROL_PLANE, + interpolation_scope=NameScope.DOCKER_CONTROL, + assignment_scope=NameScope.DOCKER_CONTROL, + export_scope=NameScope.DOCKER_CONTROL, + malformed="error", + unresolved="error", +) +APP_CONTAINER_POLICY = ResolutionPolicy( + target=EnvironmentTarget.APP_CONTAINER, + interpolation_scope=NameScope.CONTAINER_ELIGIBLE, + assignment_scope=NameScope.CONTAINER_ELIGIBLE, + export_scope=NameScope.CONTAINER_ELIGIBLE, + malformed="error", + unresolved="error", +) +COMPOSE_CONTROL_POLICY = ResolutionPolicy( + target=EnvironmentTarget.COMPOSE_CONTROL_PLANE, + interpolation_scope=NameScope.NONE, + assignment_scope=NameScope.NONE, + export_scope=NameScope.COMPOSE_SELECTED, + malformed="error", + unresolved="error", +) + +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") +_ALL_DOCKER_CONTROL_KEYS = frozenset().union(*DOCKER_CONTROL_KEYS_BY_PLATFORM.values()) +_WINDOWS_SPAWNED_NAMES = { + "path": "Path", + "systemroot": "SystemRoot", + "userprofile": "UserProfile", + "comspec": "ComSpec", + "pathext": "PATHEXT", +} + + +@dataclass(frozen=True) +class ContainerSelection: + pass_env: frozenset[str] + compose_env: frozenset[str] + + def __post_init__(self) -> None: + object.__setattr__(self, "pass_env", frozenset(self.pass_env)) + object.__setattr__(self, "compose_env", frozenset(self.compose_env)) + + +def _validate_name(name: str) -> None: + if "\0" in name or not _ENVIRONMENT_NAME.fullmatch(name): + raise ContainerPolicyError(f"Invalid environment name '{name}'.") + + +def _validate_value(name: str, value: str) -> None: + if "\0" in value: + raise ContainerPolicyError(f"Environment value for '{name}' contains NUL.") + + +def _windows_index( + mapping: Mapping[str, str], *, label: str, platform: str +) -> dict[str, tuple[str, str]]: + if platform != "win32": + return {name: (name, value) for name, value in mapping.items()} + indexed: dict[str, tuple[str, str]] = {} + for name, value in mapping.items(): + normalized = name.casefold() + if normalized in indexed and indexed[normalized][0] != name: + raise ContainerPolicyError( + f"{label} contains duplicate Windows environment name '{indexed[normalized][0]}' and '{name}'." + ) + indexed[normalized] = (name, value) + return indexed + + +def _check_windows_selected_names(names: frozenset[str], *, platform: str) -> None: + if platform != "win32": + return + seen: dict[str, str] = {} + for name in names: + normalized = name.casefold() + previous = seen.get(normalized) + if previous is not None and previous != name: + raise ContainerPolicyError( + f"Selected environment names contain duplicate Windows name '{previous}' and '{name}'." + ) + seen[normalized] = name + + +def _canonical_name(name: str, *, platform: str) -> str: + if platform == "win32": + return _WINDOWS_SPAWNED_NAMES.get(name.casefold(), name.upper()) + return name + + +def _control_collision(name: str, control: Mapping[str, str], *, platform: str) -> bool: + if platform == "win32": + return name.casefold() in _windows_index( + control, label="docker control environment", platform=platform + ) + return name in control + + +def docker_control_environment( + plan: EnvironmentPlan, + *, + platform: str = sys.platform, +) -> Mapping[str, str]: + """Select only Docker-client controls directly from the launch snapshot.""" + + selected = DOCKER_CONTROL_KEYS_BY_PLATFORM[ + "common" + ] | DOCKER_CONTROL_KEYS_BY_PLATFORM.get(platform, frozenset()) + launch = _windows_index( + plan.launch_environment, label="launch environment", platform=platform + ) + payload: dict[str, str] = {} + for name in selected: + lookup = name.casefold() if platform == "win32" else name + item = launch.get(lookup) + if item is None: + continue + source_name, value = item + _validate_name(source_name) + _validate_value(source_name, value) + payload[_canonical_name(source_name, platform=platform)] = value + return MappingProxyType(dict(payload)) + + +def select_application_payload( + resolved: ResolvedEnvironment, + selection: ContainerSelection, + *, + platform: str = sys.platform, +) -> Mapping[str, str]: + """Produce the application-only payload from final resolved values.""" + + values = _windows_index( + resolved.values, label="application environment", platform=platform + ) + selected = ( + set(resolved.declared_keys) + | set(APPLICATION_COMPATIBILITY_KEYS) + | set(selection.pass_env) + ) + _check_windows_selected_names(selection.pass_env, platform=platform) + for name in selected: + _validate_name(name) + for name in selection.pass_env: + lookup = name.casefold() if platform == "win32" else name + if lookup not in values: + raise ContainerPolicyError( + f"Explicitly selected environment name '{name}' is not present." + ) + + payload: dict[str, str] = {} + for name in selected: + lookup = name.casefold() if platform == "win32" else name + item = values.get(lookup) + if item is None: + continue + source_name, value = item + if (source_name.casefold() if platform == "win32" else source_name) in { + key.casefold() if platform == "win32" else key + for key in _ALL_DOCKER_CONTROL_KEYS + }: + raise ContainerPolicyError( + f"Application environment name '{source_name}' collides with Docker control plane." + ) + if source_name in resolved.unresolved_references: + raise ContainerPolicyError( + f"Application environment key '{source_name}' has unresolved reference(s): " + f"{', '.join(sorted(resolved.unresolved_references[source_name]))}." + ) + _validate_name(source_name) + _validate_value(source_name, value) + payload[_canonical_name(source_name, platform=platform)] = value + return MappingProxyType(dict(payload)) + + +def select_compose_payload( + *, + application_payload: Mapping[str, str], + selected_names: frozenset[str], + docker_control: Mapping[str, str], + platform: str = sys.platform, +) -> Mapping[str, str]: + """Copy selected, already-final application values without re-resolution.""" + + application = _windows_index( + application_payload, label="application payload", platform=platform + ) + _windows_index( + docker_control, label="docker control environment", platform=platform + ) + _check_windows_selected_names(selected_names, platform=platform) + payload: dict[str, str] = {} + for name in selected_names: + _validate_name(name) + lookup = name.casefold() if platform == "win32" else name + item = application.get(lookup) + if item is None: + raise ContainerPolicyError( + f"Compose-selected environment name '{name}' is not present in application payload." + ) + source_name, value = item + if _control_collision(source_name, docker_control, platform=platform): + raise ContainerPolicyError( + f"Compose-selected environment name '{source_name}' collides with Docker control plane." + ) + _validate_value(source_name, value) + payload[_canonical_name(source_name, platform=platform)] = value + return MappingProxyType(dict(payload)) + + +__all__ = [ + "APPLICATION_COMPATIBILITY_KEYS", + "APP_CONTAINER_POLICY", + "COMPOSE_CONTROL_POLICY", + "ContainerPolicyError", + "ContainerSelection", + "DOCKER_CONTROL_KEYS_BY_PLATFORM", + "DOCKER_CONTROL_POLICY", + "HOST_RUNTIME_POLICY", + "docker_control_environment", + "select_application_payload", + "select_compose_payload", +] diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py new file mode 100644 index 0000000..b70017d --- /dev/null +++ b/src/agentseek_api/docker_runtime.py @@ -0,0 +1,720 @@ +"""Value-redacted, shell-free process transport for Docker boundaries.""" + +from __future__ import annotations + +import json +import math +import os +import re +import shutil +import stat +import subprocess +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import Protocol + +from agentseek_api.container_policy import select_compose_payload +from agentseek_api.container_build import ( + ContainerBuildBundle, + ContainerBuildPlan, + container_build_plan_fingerprint, +) +from agentseek_api.environment import ContainerPolicyError +from agentseek_api.constants import DEFAULT_API_PORT + +DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS = 10.0 +MINIMUM_COMPOSE_VERSION = (2, 24, 0) +MINIMUM_BUILDX_VERSION = (0, 12, 0) +IMAGE_COMPATIBILITY_FORMAT = ( + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]" +) +PRELOADED_MANIFEST_PATH = "/opt/agentseek/manifest.v1.json" + +_SEMANTIC_VERSION = r"[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?" +_COMPOSE_VERSION = re.compile(rf"^v?(?P{_SEMANTIC_VERSION})$") +_BUILDX_VERSION = re.compile( + rf"^github\.com/docker/buildx v?(?P{_SEMANTIC_VERSION})(?:\s+\S.*)?$" +) +_ENVIRONMENT_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +class DockerRuntimeError(RuntimeError): + """A value-free Docker transport failure.""" + + +class ImageContractError(DockerRuntimeError): + """A value-free incompatible custom-image failure.""" + + +@dataclass(frozen=True, kw_only=True) +class ProcessInvocation: + argv: tuple[str, ...] + environment: Mapping[str, str] = field(repr=False) + cwd: Path + stdin_bytes: bytes | None = field(default=None, repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "argv", tuple(self.argv)) + _validate_argv(self.argv) + object.__setattr__( + self, "environment", MappingProxyType(dict(self.environment)) + ) + object.__setattr__(self, "cwd", Path(self.cwd)) + + +@dataclass(frozen=True, kw_only=True) +class BuildImageInvocation(ProcessInvocation): + stdin_bytes: bytes = field(repr=False) + + def __post_init__(self) -> None: + super().__post_init__() + if not self.stdin_bytes: + raise ContainerPolicyError("Docker image build input must not be empty.") + + +@dataclass(frozen=True, kw_only=True) +class ControlQueryInvocation(ProcessInvocation): + """A bounded query whose captured output never falls through to the terminal.""" + + timeout_seconds: float = field( + default=DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS, repr=False + ) + + def __post_init__(self) -> None: + super().__post_init__() + if not math.isfinite(self.timeout_seconds) or self.timeout_seconds <= 0: + raise ContainerPolicyError( + "Docker control query timeout must be a positive finite number." + ) + + +@dataclass(frozen=True, kw_only=True) +class DockerRunInvocation(ProcessInvocation): + application_names: frozenset[str] + + def __post_init__(self) -> None: + super().__post_init__() + object.__setattr__(self, "application_names", frozenset(self.application_names)) + + +@dataclass(frozen=True, kw_only=True) +class ProcessResult: + returncode: int + stdout: bytes = field(default=b"", repr=False) + stderr: bytes = field(default=b"", repr=False) + + +@dataclass(frozen=True, kw_only=True) +class DockerImageConfig: + labels: Mapping[str, str] = field(repr=False) + entrypoint: tuple[str, ...] | str | None = field(repr=False) + command: tuple[str, ...] | str | None = field(repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "labels", MappingProxyType(dict(self.labels))) + if isinstance(self.entrypoint, list): + object.__setattr__(self, "entrypoint", tuple(self.entrypoint)) + if isinstance(self.command, list): + object.__setattr__(self, "command", tuple(self.command)) + + +@dataclass(frozen=True) +class PreloadedImageContract: + manifest_path: str + container_argv: tuple[str, ...] + + +class ProcessTransport(Protocol): + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: ... + + +LegacyRunner = Callable[..., int] + + +def _validate_argv(argv: tuple[str, ...]) -> None: + if not argv: + raise ContainerPolicyError("Docker invocation argv must not be empty.") + if any("\0" in item for item in argv): + raise ContainerPolicyError("Docker invocation argv contains NUL.") + if not Path(argv[0]).is_absolute(): + raise ContainerPolicyError( + "Docker invocation executable must be an absolute path." + ) + + +def resolve_docker_executable( + docker_control: Mapping[str, str], + *, + platform: str = sys.platform, +) -> str: + """Resolve Docker once from the selected control-plane search path.""" + + if platform == "win32": + selected = [ + value for name, value in docker_control.items() if name.casefold() == "path" + ] + path_value = selected[0] if len(selected) == 1 else None + else: + path_value = docker_control.get("PATH") + if not path_value: + raise DockerRuntimeError( + "Docker executable could not be resolved from the selected PATH." + ) + executable = shutil.which("docker", path=path_value) + if executable is None: + raise DockerRuntimeError( + "Docker executable could not be resolved from the selected PATH." + ) + if platform == "win32": + from pathlib import PureWindowsPath + + is_absolute = PureWindowsPath(executable).is_absolute() + else: + is_absolute = Path(executable).is_absolute() + if not is_absolute: + raise DockerRuntimeError( + "Docker executable could not be resolved from the selected PATH." + ) + return executable + + +def _validated_environment( + environment: Mapping[str, str], + *, + label: str = "Docker invocation environment", + platform: str = sys.platform, +) -> dict[str, str]: + validated: dict[str, str] = {} + windows_names: set[str] = set() + for name, value in environment.items(): + if "\0" in name: + raise ContainerPolicyError( + "Docker invocation environment name contains NUL." + ) + if "\0" in value: + raise ContainerPolicyError( + f"Docker invocation environment value for '{name}' contains NUL." + ) + if platform == "win32": + logical_name = name.casefold() + if logical_name in windows_names: + raise ContainerPolicyError( + f"{label} contains duplicate Windows environment names." + ) + windows_names.add(logical_name) + validated[name] = value + return validated + + +def build_docker_run_invocation( + *, + base_argv: tuple[str, ...], + image: str, + docker_control: Mapping[str, str], + application_payload: Mapping[str, str], + container_argv: tuple[str, ...], + cwd: Path, + platform: str = sys.platform, +) -> DockerRunInvocation: + control = _validated_environment( + docker_control, + label="Docker control environment", + platform=platform, + ) + application = _validated_environment( + application_payload, + label="Application payload", + platform=platform, + ) + if platform == "win32": + control_names = {name.casefold() for name in control} + collisions = {name for name in application if name.casefold() in control_names} + else: + collisions = control.keys() & application.keys() + if collisions: + names = ", ".join(sorted(collisions)) + raise ContainerPolicyError( + f"Application payload collides with Docker control keys: {names}" + ) + argv = [*base_argv] + for name in sorted(application): + argv.extend(("-e", name)) + argv.extend((image, *container_argv)) + immutable_argv = tuple(argv) + _validate_argv(immutable_argv) + return DockerRunInvocation( + argv=immutable_argv, + environment=MappingProxyType({**control, **application}), + cwd=cwd, + stdin_bytes=None, + application_names=frozenset(application), + ) + + +def build_docker_control_invocation( + *, + argv: tuple[str, ...], + docker_control: Mapping[str, str], + cwd: Path, + stdin_bytes: bytes | None = None, +) -> ProcessInvocation: + _validate_argv(argv) + return ProcessInvocation( + argv=argv, + environment=_validated_environment(docker_control), + cwd=cwd, + stdin_bytes=stdin_bytes, + ) + + +def build_docker_query_invocation( + *, + argv: tuple[str, ...], + docker_control: Mapping[str, str], + cwd: Path, + timeout_seconds: float = DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS, +) -> ControlQueryInvocation: + _validate_argv(argv) + return ControlQueryInvocation( + argv=argv, + environment=_validated_environment(docker_control), + cwd=cwd, + stdin_bytes=None, + timeout_seconds=timeout_seconds, + ) + + +def _pip_config_identity(status: os.stat_result) -> tuple[int, int, int, int]: + return (status.st_dev, status.st_ino, status.st_size, status.st_mtime_ns) + + +def validate_pip_config_identity(plan: ContainerBuildPlan) -> None: + if plan.pip_config_file is None: + return + try: + status = plan.pip_config_file.lstat() + except OSError as exc: + raise DockerRuntimeError( + "The pip config identity changed before build." + ) from exc + if ( + stat.S_ISLNK(status.st_mode) + or not stat.S_ISREG(status.st_mode) + or plan.pip_config_identity is None + or _pip_config_identity(status) != plan.pip_config_identity + ): + raise DockerRuntimeError("The pip config identity changed before build.") + + +def build_image_invocation( + bundle: ContainerBuildBundle, + *, + plan: ContainerBuildPlan, + docker_executable: str, + docker_control: Mapping[str, str], + tag: str | None = None, + platform: str | None = None, + pull: bool = False, +) -> BuildImageInvocation: + """Freeze a Buildx invocation whose only build context is verified tar stdin.""" + + if bundle.plan_fingerprint != container_build_plan_fingerprint(plan): + raise DockerRuntimeError("The build bundle does not match the supplied plan.") + archive = bundle.archive_bytes() + validate_pip_config_identity(plan) + argv = [docker_executable, "buildx", "build", "--load", "--file", "Dockerfile"] + if platform: + argv.extend(("--platform", platform)) + if pull: + argv.append("--pull") + if tag: + argv.extend(("--tag", tag)) + if plan.pip_config_file is not None: + source = str(plan.pip_config_file) + argv.extend(("--secret", f"id=pip_config,src={source}")) + argv.append("-") + immutable_argv = tuple(argv) + _validate_argv(immutable_argv) + environment = { + name: value + for name, value in _validated_environment(docker_control).items() + if name != "DOCKER_BUILDKIT" + } + return BuildImageInvocation( + argv=immutable_argv, + environment=environment, + cwd=plan.invocation_cwd, + stdin_bytes=archive, + ) + + +def validate_environment_name(name: str) -> None: + if not _ENVIRONMENT_NAME.fullmatch(name): + raise ContainerPolicyError("Compose environment name is invalid.") + + +def encode_compose_environment(values: Mapping[str, str]) -> str: + """Encode values with the one supported literal Compose dotenv grammar.""" + + lines: list[str] = [] + for name, value in sorted(values.items()): + validate_environment_name(name) + if "\x00" in value or any( + ord(character) < 0x20 and character not in "\n\r\t" for character in value + ): + raise DockerRuntimeError( + f"Compose value for {name} contains an unsupported control." + ) + literal = ( + value.replace("\\", "\\\\") + .replace('"', '\\"') + .replace("$", "$$") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + ) + lines.append(f'{name}="{literal}"') + return "\n".join(lines) + "\n" + + +def build_compose_invocation( + *, + docker_executable: str, + compose_file: Path, + env_file: Path, + docker_control: Mapping[str, str], + application_payload: Mapping[str, str], + selected_names: frozenset[str], + cwd: Path, + recreate: bool = False, + platform: str = sys.platform, +) -> ProcessInvocation: + """Build an explicit-env-file Compose invocation with no carrier values.""" + + select_compose_payload( + application_payload=application_payload, + selected_names=selected_names, + docker_control=docker_control, + platform=platform, + ) + argv = [ + docker_executable, + "compose", + "--env-file", + str(env_file), + "-f", + str(compose_file), + "up", + "-d", + ] + if recreate: + argv.append("--force-recreate") + return build_docker_control_invocation( + argv=tuple(argv), docker_control=docker_control, cwd=cwd + ) + + +def require_supported_compose( + *, + docker_executable: str, + transport: ProcessTransport, + docker_control: Mapping[str, str], + cwd: Path, +) -> tuple[int, int, int]: + """Reject old or unavailable Compose before private artifacts are created.""" + + query = build_docker_query_invocation( + argv=(docker_executable, "compose", "version", "--short"), + docker_control=docker_control, + cwd=cwd, + ) + version_text = parse_compose_version_result(transport(query)) + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_text) + if match is None: + raise DockerRuntimeError( + "Docker Compose version query returned an invalid result." + ) + version = tuple(int(component) for component in match.groups()) + prerelease = "-" in version_text + if version < MINIMUM_COMPOSE_VERSION or ( + version == MINIMUM_COMPOSE_VERSION and prerelease + ): + required = ".".join(str(component) for component in MINIMUM_COMPOSE_VERSION) + raise DockerRuntimeError(f"Docker Compose {required} or newer is required.") + return version + + +@dataclass(frozen=True) +class SubprocessTransport: + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + is_query = isinstance(invocation, ControlQueryInvocation) + try: + completed = subprocess.run( + list(invocation.argv), + env=dict(invocation.environment), + cwd=invocation.cwd, + input=invocation.stdin_bytes, + stdout=subprocess.PIPE if is_query else None, + stderr=subprocess.PIPE if is_query else None, + shell=False, + check=False, + timeout=invocation.timeout_seconds if is_query else None, + ) + except subprocess.TimeoutExpired as exc: + raise DockerRuntimeError("Docker control query timed out.") from exc + except OSError as exc: + raise DockerRuntimeError("Docker process could not be started.") from exc + return ProcessResult( + returncode=completed.returncode, + stdout=(completed.stdout or b"") if is_query else b"", + stderr=(completed.stderr or b"") if is_query else b"", + ) + + +@dataclass(frozen=True) +class LegacyRunnerAdapter: + runner: LegacyRunner = field(repr=False) + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if isinstance(invocation, ControlQueryInvocation): + raise DockerRuntimeError( + "Legacy runners cannot represent Docker control queries safely." + ) + if invocation.stdin_bytes is not None: + raise DockerRuntimeError( + "Legacy runners cannot represent Docker standard input safely." + ) + return ProcessResult( + returncode=self.runner( + list(invocation.argv), + env=dict(invocation.environment), + cwd=str(invocation.cwd), + ) + ) + + +def _one_private_output_line(result: ProcessResult, *, error_message: str) -> str: + if result.returncode != 0: + raise DockerRuntimeError(error_message) + try: + text = result.stdout.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise DockerRuntimeError(error_message) from exc + lines = text.splitlines() + if len(lines) != 1 or not lines[0] or lines[0] != lines[0].strip(): + raise DockerRuntimeError(error_message) + return lines[0] + + +def parse_compose_version_result(result: ProcessResult) -> str: + error_message = "Docker Compose version query returned an invalid result." + line = _one_private_output_line(result, error_message=error_message) + match = _COMPOSE_VERSION.fullmatch(line) + if match is None: + raise DockerRuntimeError(error_message) + return match.group("version") + + +def parse_buildx_version_result(result: ProcessResult) -> str: + error_message = "Docker Buildx version query returned an invalid result." + line = _one_private_output_line(result, error_message=error_message) + match = _BUILDX_VERSION.fullmatch(line) + if match is None: + raise DockerRuntimeError(error_message) + return match.group("version") + + +def require_buildx_available(result: ProcessResult) -> None: + if result.returncode != 0: + raise DockerRuntimeError("Docker Buildx builder is unavailable.") + + +def require_supported_buildx( + *, + docker_executable: str, + transport: ProcessTransport, + docker_control: Mapping[str, str], + cwd: Path, + plan: ContainerBuildPlan | None = None, +) -> tuple[int, int, int]: + """Require a usable Buildx plugin before any side-effecting build call.""" + + version_query = build_docker_query_invocation( + argv=(docker_executable, "buildx", "version"), + docker_control=docker_control, + cwd=cwd, + ) + version_text = parse_buildx_version_result(transport(version_query)) + match = re.match(r"^(\d+)\.(\d+)\.(\d+)", version_text) + if match is None: # pragma: no cover - parser already enforces this + raise DockerRuntimeError( + "Docker Buildx version query returned an invalid result." + ) + version = tuple(int(component) for component in match.groups()) + prerelease = "-" in version_text + if version < MINIMUM_BUILDX_VERSION or ( + version == MINIMUM_BUILDX_VERSION and prerelease + ): + required = ".".join(str(component) for component in MINIMUM_BUILDX_VERSION) + raise DockerRuntimeError( + f"Docker Buildx {required} or newer is required for BuildKit secrets." + ) + inspect_query = build_docker_query_invocation( + argv=(docker_executable, "buildx", "inspect"), + docker_control=docker_control, + cwd=cwd, + ) + require_buildx_available(transport(inspect_query)) + if plan is not None: + validate_pip_config_identity(plan) + return version + + +def _parse_string_or_argv(value: object) -> tuple[str, ...] | str | None: + if value is None or isinstance(value, str): + return value + if isinstance(value, list) and all(isinstance(item, str) for item in value): + return tuple(value) + raise TypeError + + +def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig: + error_message = "Docker image compatibility query returned an invalid result." + line = _one_private_output_line(result, error_message=error_message) + try: + payload = json.loads(line) + if not isinstance(payload, list) or len(payload) != 3: + raise TypeError + raw_labels, raw_entrypoint, raw_command = payload + if raw_labels is None: + labels: dict[str, str] = {} + elif isinstance(raw_labels, dict) and all( + isinstance(name, str) and isinstance(value, str) + for name, value in raw_labels.items() + ): + labels = dict(raw_labels) + else: + raise TypeError + entrypoint = _parse_string_or_argv(raw_entrypoint) + command = _parse_string_or_argv(raw_command) + except (json.JSONDecodeError, TypeError, ValueError) as exc: + raise DockerRuntimeError(error_message) from exc + return DockerImageConfig( + labels=labels, + entrypoint=entrypoint, + command=command, + ) + + +_PRELOADED_V1_LABELS = { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": PRELOADED_MANIFEST_PATH, + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", +} + + +def require_preloaded_v1(labels: Mapping[str, str]) -> str: + """Require the complete immutable preloaded-v1 image label contract.""" + + if any(labels.get(name) != value for name, value in _PRELOADED_V1_LABELS.items()): + raise ImageContractError("The custom image must implement preloaded-v1.") + return labels["org.agentseek.runtime-manifest"] + + +def inspect_image_contract( + image: str, + *, + docker_executable: str, + transport: ProcessTransport, + docker_control: Mapping[str, str], + cwd: Path, +) -> PreloadedImageContract: + """Inspect only labels/entrypoint/cmd and require an explicit-mode carrier.""" + + query = build_docker_query_invocation( + argv=( + docker_executable, + "image", + "inspect", + "--format", + IMAGE_COMPATIBILITY_FORMAT, + image, + ), + docker_control=docker_control, + cwd=cwd, + ) + try: + config = parse_image_compatibility_result(transport(query)) + manifest_path = require_preloaded_v1(config.labels) + except DockerRuntimeError as exc: + if isinstance(exc, ImageContractError): + raise + raise ImageContractError( + "The custom image compatibility check failed." + ) from exc + serve = ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + str(DEFAULT_API_PORT), + ) + if config.entrypoint in (None, ()): + command = ("agentseek-api", *serve) + elif config.entrypoint in ( + ("agentseek-api",), + ("python", "-m", "agentseek_api.cli"), + ): + command = serve + else: + raise ImageContractError( + "The custom image entrypoint cannot receive the preloaded-v1 command." + ) + return PreloadedImageContract( + manifest_path=manifest_path, + container_argv=command, + ) + + +__all__ = [ + "BuildImageInvocation", + "ControlQueryInvocation", + "DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS", + "DockerRunInvocation", + "DockerImageConfig", + "DockerRuntimeError", + "ImageContractError", + "LegacyRunnerAdapter", + "IMAGE_COMPATIBILITY_FORMAT", + "MINIMUM_BUILDX_VERSION", + "MINIMUM_COMPOSE_VERSION", + "PRELOADED_MANIFEST_PATH", + "ProcessInvocation", + "ProcessResult", + "ProcessTransport", + "PreloadedImageContract", + "SubprocessTransport", + "build_docker_control_invocation", + "build_image_invocation", + "build_compose_invocation", + "build_docker_query_invocation", + "build_docker_run_invocation", + "encode_compose_environment", + "parse_buildx_version_result", + "parse_compose_version_result", + "parse_image_compatibility_result", + "inspect_image_contract", + "require_preloaded_v1", + "require_buildx_available", + "require_supported_buildx", + "require_supported_compose", + "resolve_docker_executable", + "validate_pip_config_identity", + "validate_environment_name", +] diff --git a/src/agentseek_api/dotenv_adapter.py b/src/agentseek_api/dotenv_adapter.py index d9955c0..2f966c7 100644 --- a/src/agentseek_api/dotenv_adapter.py +++ b/src/agentseek_api/dotenv_adapter.py @@ -3,10 +3,13 @@ from __future__ import annotations from collections.abc import Mapping +from dataclasses import dataclass, field +from io import StringIO from pathlib import Path +from typing import Iterator, Sequence from dotenv.parser import parse_stream -from dotenv.variables import parse_variables +from dotenv.variables import Variable, parse_variables class DotenvFileError(ValueError): @@ -23,22 +26,54 @@ def __init__( super().__init__(f"Env file '{path}' {message}{location}.") -def _resolve_value( - value: str, - *, - context: Mapping[str, str | None], -) -> str: - return "".join(atom.resolve(context) for atom in parse_variables(value)) +@dataclass(frozen=True) +class DotenvBinding: + """One strict dotenv binding, retaining source order but not its value in repr.""" + key: str | None + atoms: tuple[object, ...] = field(repr=False) + line: int = 0 + referenced_names: frozenset[str] = frozenset() + has_value: bool = False + + def __post_init__(self) -> None: + object.__setattr__(self, "atoms", tuple(self.atoms)) + object.__setattr__(self, "referenced_names", frozenset(self.referenced_names)) + + +@dataclass(frozen=True) +class DotenvDocument(Sequence[DotenvBinding]): + """An immutable, validated dotenv source document.""" + + path: Path + bindings: tuple[DotenvBinding, ...] = field(repr=False) + + def __post_init__(self) -> None: + object.__setattr__(self, "bindings", tuple(self.bindings)) + + def __len__(self) -> int: + return len(self.bindings) + + def __getitem__( + self, index: int | slice + ) -> DotenvBinding | tuple[DotenvBinding, ...]: + return self.bindings[index] + + def __iter__(self) -> Iterator[DotenvBinding]: + return iter(self.bindings) + + +def parse_dotenv_document(path: Path) -> DotenvDocument: + """Read a dotenv file once and return strict physical-order bindings. + + This adapter deliberately keeps python-dotenv's supported parser and + interpolation atoms behind one boundary. The atoms retain enough + information for target-specific resolution while dataclass repr output + stays value-free. + """ -def parse_dotenv_file( - path: Path, - *, - ambient: Mapping[str, str], -) -> dict[str, str | None]: try: - with path.open(encoding="utf-8") as stream: - bindings = list(parse_stream(stream)) + contents = path.read_text(encoding="utf-8") except FileNotFoundError as exc: raise DotenvFileError(path, "does not exist") from exc except UnicodeDecodeError as exc: @@ -47,6 +82,7 @@ def parse_dotenv_file( reason = exc.strerror or type(exc).__name__ raise DotenvFileError(path, f"could not be read: {reason}") from exc + bindings = list(parse_stream(StringIO(contents))) malformed = next((binding for binding in bindings if binding.error), None) if malformed is not None: raise DotenvFileError( @@ -55,15 +91,51 @@ def parse_dotenv_file( line=malformed.original.line, ) + document_bindings: list[DotenvBinding] = [] + for binding in bindings: + atoms = ( + tuple(parse_variables(binding.value)) if binding.value is not None else () + ) + references = frozenset( + atom.name for atom in atoms if isinstance(atom, Variable) + ) + document_bindings.append( + DotenvBinding( + key=binding.key, + atoms=atoms, + line=binding.original.line, + referenced_names=references, + has_value=binding.value is not None, + ) + ) + return DotenvDocument(path=path, bindings=tuple(document_bindings)) + + +def resolve_dotenv_document( + document: DotenvDocument, + *, + ambient: Mapping[str, str | None], +) -> dict[str, str | None]: + """Resolve a validated document with python-dotenv's file-local ordering.""" + context: dict[str, str | None] = dict(ambient) values: dict[str, str | None] = {} - for binding in bindings: + for binding in document: if binding.key is None: continue - if binding.value is None: - value = None - else: - value = _resolve_value(binding.value, context=context) + value = ( + "".join(atom.resolve(context) for atom in binding.atoms) + if binding.has_value + else None + ) values[binding.key] = value context[binding.key] = value return values + + +def parse_dotenv_file( + path: Path, + *, + ambient: Mapping[str, str], +) -> dict[str, str | None]: + return resolve_dotenv_document(parse_dotenv_document(path), ambient=ambient) diff --git a/src/agentseek_api/environment.py b/src/agentseek_api/environment.py new file mode 100644 index 0000000..c7f1544 --- /dev/null +++ b/src/agentseek_api/environment.py @@ -0,0 +1,509 @@ +"""Typed, value-redacted environment resolution primitives.""" + +from __future__ import annotations + +import sys +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType +from typing import Literal + +from dotenv.variables import Variable + +from agentseek_api.dotenv_adapter import DotenvDocument, parse_dotenv_document + + +class EnvironmentTarget(StrEnum): + HOST_RUNTIME = "host-runtime" + DOCKER_CONTROL_PLANE = "docker-control-plane" + APP_CONTAINER = "app-container" + COMPOSE_CONTROL_PLANE = "compose-control-plane" + + +class EnvironmentMode(StrEnum): + RESOLVE = "resolve" + PRELOADED_V1 = "preloaded-v1" + + +class NameScope(StrEnum): + NONE = "none" + ALL = "all" + DOCKER_CONTROL = "docker-control" + CONTAINER_ELIGIBLE = "container-eligible" + COMPOSE_SELECTED = "compose-selected" + + +@dataclass(frozen=True) +class CommandDerivedAssignment: + targets: frozenset[EnvironmentTarget] + values: Mapping[str, str] = field(repr=False) + reason: str + + def __post_init__(self) -> None: + object.__setattr__(self, "targets", frozenset(self.targets)) + object.__setattr__(self, "values", MappingProxyType(dict(self.values))) + + +@dataclass(frozen=True) +class EnvironmentPlan: + config_path: Path | None + config_dotenv: Path | None + config_mapping: Mapping[str, str] = field(repr=False) + auth_path: str | None = field(repr=False) + cli_dotenv: Path | None = None + launch_environment: Mapping[str, str] = field(default_factory=dict, repr=False) + command_assignments: tuple[CommandDerivedAssignment, ...] = field( + default=(), repr=False + ) + explicit_names: frozenset[str] = frozenset() + + def __post_init__(self) -> None: + object.__setattr__( + self, "config_mapping", MappingProxyType(dict(self.config_mapping)) + ) + object.__setattr__( + self, "launch_environment", MappingProxyType(dict(self.launch_environment)) + ) + object.__setattr__(self, "command_assignments", tuple(self.command_assignments)) + object.__setattr__(self, "explicit_names", frozenset(self.explicit_names)) + + +@dataclass(frozen=True) +class ResolutionPolicy: + target: EnvironmentTarget + interpolation_scope: NameScope + assignment_scope: NameScope + export_scope: NameScope + malformed: Literal["error"] + unresolved: Literal["empty", "error"] + + +PRELOADED_V1_POLICY = ResolutionPolicy( + target=EnvironmentTarget.HOST_RUNTIME, + interpolation_scope=NameScope.NONE, + assignment_scope=NameScope.ALL, + export_scope=NameScope.ALL, + malformed="error", + unresolved="error", +) + + +@dataclass(frozen=True) +class EnvironmentOrigin: + source_kind: Literal[ + "config-dotenv", "config-mapping", "auth", "cli-dotenv", "launch", "command" + ] + source_name: str + line: int | None = None + + +@dataclass(frozen=True) +class EnvironmentDiagnostic: + code: str + key: str + source_name: str + line: int | None = None + + +@dataclass(frozen=True) +class ResolvedEnvironment: + values: Mapping[str, str] = field(repr=False) + origins: Mapping[str, EnvironmentOrigin] + declared_keys: frozenset[str] + unresolved_references: Mapping[str, frozenset[str]] + diagnostics: tuple[EnvironmentDiagnostic, ...] + + def __post_init__(self) -> None: + object.__setattr__(self, "values", MappingProxyType(dict(self.values))) + object.__setattr__(self, "origins", MappingProxyType(dict(self.origins))) + object.__setattr__(self, "declared_keys", frozenset(self.declared_keys)) + object.__setattr__( + self, + "unresolved_references", + MappingProxyType( + { + key: frozenset(names) + for key, names in self.unresolved_references.items() + } + ), + ) + object.__setattr__(self, "diagnostics", tuple(self.diagnostics)) + + +class ContainerPolicyError(ValueError): + pass + + +class _WindowsEnvironment(dict[str, str | None]): + """Case-insensitive lookup while retaining the documented source spelling.""" + + def get(self, key: str, default: str | None = None) -> str | None: + value = super().get(key, None) + if value is not None or key in self: + return value + normalized = key.casefold() + for candidate, candidate_value in self.items(): + if candidate.casefold() == normalized: + return candidate_value + return default + + +def _source_name(path: Path) -> str: + return str(path) + + +def _document_declared_names(document: DotenvDocument) -> set[str]: + return { + binding.key + for binding in document + if binding.key is not None and binding.has_value + } + + +def _resolve_document( + document: DotenvDocument, + *, + ambient: Mapping[str, str], + source_kind: Literal["config-dotenv", "cli-dotenv"], + platform: str, +) -> tuple[ + dict[str, str | None], + dict[str, EnvironmentOrigin], + set[str], + dict[str, frozenset[str]], + tuple[EnvironmentDiagnostic, ...], +]: + context: dict[str, str | None] + if platform == "win32": + context = _WindowsEnvironment(ambient) + else: + context = dict(ambient) + values: dict[str, str | None] = {} + origins: dict[str, EnvironmentOrigin] = {} + declared: set[str] = set() + unresolved: dict[str, frozenset[str]] = {} + for binding in document: + if binding.key is None: + continue + if not binding.has_value: + values[binding.key] = None + context[binding.key] = None + continue + missing = frozenset( + atom.name + for atom in binding.atoms + if isinstance(atom, Variable) + and atom.default is None + and context.get(atom.name) is None + ) + value = "".join(atom.resolve(context) for atom in binding.atoms) + values[binding.key] = value + context[binding.key] = value + origins[binding.key] = EnvironmentOrigin( + source_kind, _source_name(document.path), binding.line + ) + declared.add(binding.key) + if missing: + unresolved[binding.key] = missing + else: + unresolved.pop(binding.key, None) + diagnostics = tuple( + EnvironmentDiagnostic( + "unresolved-reference", key, _source_name(document.path), origins[key].line + ) + for key, names in unresolved.items() + for _ in names + ) + return values, origins, declared, unresolved, diagnostics + + +def _check_windows_duplicates( + mapping: Mapping[str, object], *, label: str, platform: str +) -> None: + if platform != "win32": + return + seen: dict[str, str] = {} + for name in mapping: + normalized = name.casefold() + previous = seen.get(normalized) + if previous is not None and previous != name: + raise ContainerPolicyError( + f"{label} contains duplicate Windows environment name '{previous}' and '{name}'." + ) + seen[normalized] = name + + +def _check_windows_names( + names: set[str] | frozenset[str] | list[str], *, label: str, platform: str +) -> None: + if platform != "win32": + return + seen: dict[str, str] = {} + for name in names: + normalized = name.casefold() + previous = seen.get(normalized) + if previous is not None and previous != name: + raise ContainerPolicyError( + f"{label} contains duplicate Windows environment name '{previous}' and '{name}'." + ) + seen[normalized] = name + + +def _merge_values( + destination: dict[str, str], + origins: dict[str, EnvironmentOrigin], + unresolved: dict[str, frozenset[str]], + values: Mapping[str, str | None], + value_origins: Mapping[str, EnvironmentOrigin], + value_unresolved: Mapping[str, frozenset[str]], + *, + platform: str, +) -> None: + for key, value in values.items(): + if value is None: + continue + if platform == "win32": + previous = next( + ( + candidate + for candidate in destination + if candidate.casefold() == key.casefold() + ), + None, + ) + if previous is not None and previous != key: + destination.pop(previous) + origins.pop(previous, None) + unresolved.pop(previous, None) + destination[key] = value + origins[key] = value_origins[key] + if key in value_unresolved: + unresolved[key] = value_unresolved[key] + else: + unresolved.pop(key, None) + + +def _allowed_ambient( + launch: Mapping[str, str], + *, + policy: ResolutionPolicy, + eligible_names: set[str], + platform: str, +) -> dict[str, str]: + if policy.interpolation_scope is NameScope.ALL: + return dict(launch) + if policy.interpolation_scope is NameScope.CONTAINER_ELIGIBLE: + if platform == "win32": + normalized = {name.casefold() for name in eligible_names} + return { + key: value + for key, value in launch.items() + if key.casefold() in normalized + } + return {key: value for key, value in launch.items() if key in eligible_names} + return {} + + +def resolve_environment( + plan: EnvironmentPlan, + policy: ResolutionPolicy, + *, + platform: str = sys.platform, +) -> ResolvedEnvironment: + """Resolve one policy target without ever mutating the input plan.""" + + if policy.target is EnvironmentTarget.COMPOSE_CONTROL_PLANE: + raise ContainerPolicyError( + "Compose control policy is export-only and cannot resolve source environments." + ) + + _check_windows_duplicates( + plan.launch_environment, label="launch environment", platform=platform + ) + _check_windows_duplicates( + plan.config_mapping, label="config mapping", platform=platform + ) + documents: list[tuple[DotenvDocument, Literal["config-dotenv", "cli-dotenv"]]] = [] + if plan.config_dotenv is not None: + documents.append((parse_dotenv_document(plan.config_dotenv), "config-dotenv")) + if plan.cli_dotenv is not None: + documents.append((parse_dotenv_document(plan.cli_dotenv), "cli-dotenv")) + _check_windows_names( + list(plan.explicit_names), + label="explicit environment selection", + platform=platform, + ) + for document, _ in documents: + _check_windows_names( + [binding.key for binding in document if binding.key is not None], + label="dotenv document", + platform=platform, + ) + + eligible_names = set(plan.explicit_names) + eligible_names.update(plan.config_mapping) + if plan.auth_path is not None: + eligible_names.add("AUTH_MODULE_PATH") + for document, _ in documents: + eligible_names.update(_document_declared_names(document)) + if policy.interpolation_scope is NameScope.CONTAINER_ELIGIBLE: + from agentseek_api.container_policy import APPLICATION_COMPATIBILITY_KEYS + + eligible_names.update(APPLICATION_COMPATIBILITY_KEYS) + + ambient = _allowed_ambient( + plan.launch_environment, + policy=policy, + eligible_names=eligible_names, + platform=platform, + ) + final: dict[str, str] = {} + origins: dict[str, EnvironmentOrigin] = {} + unresolved: dict[str, frozenset[str]] = {} + declared: set[str] = set() + diagnostics: list[EnvironmentDiagnostic] = [] + + for document, source_kind in documents: + if source_kind != "config-dotenv": + continue + ( + values, + document_origins, + document_declared, + document_unresolved, + document_diagnostics, + ) = _resolve_document( + document, ambient=ambient, source_kind=source_kind, platform=platform + ) + _merge_values( + final, + origins, + unresolved, + values, + document_origins, + document_unresolved, + platform=platform, + ) + declared.update(document_declared) + diagnostics.extend(document_diagnostics) + + mapping_values = {key: value for key, value in plan.config_mapping.items()} + mapping_origins = { + key: EnvironmentOrigin( + "config-mapping", + _source_name(plan.config_path) if plan.config_path else "config", + ) + for key in mapping_values + } + _merge_values( + final, + origins, + unresolved, + mapping_values, + mapping_origins, + {}, + platform=platform, + ) + declared.update(mapping_values) + + if plan.auth_path is not None: + auth_values = {"AUTH_MODULE_PATH": plan.auth_path} + _merge_values( + final, + origins, + unresolved, + auth_values, + { + "AUTH_MODULE_PATH": EnvironmentOrigin( + "auth", + _source_name(plan.config_path) if plan.config_path else "config", + ) + }, + {}, + platform=platform, + ) + declared.add("AUTH_MODULE_PATH") + + for document, source_kind in documents: + if source_kind != "cli-dotenv": + continue + ( + values, + document_origins, + document_declared, + document_unresolved, + document_diagnostics, + ) = _resolve_document( + document, ambient=ambient, source_kind=source_kind, platform=platform + ) + _merge_values( + final, + origins, + unresolved, + values, + document_origins, + document_unresolved, + platform=platform, + ) + declared.update(document_declared) + diagnostics.extend(document_diagnostics) + + if policy.assignment_scope is NameScope.ALL: + launch_values = dict(plan.launch_environment) + elif policy.assignment_scope is NameScope.CONTAINER_ELIGIBLE: + launch_values = _allowed_ambient( + plan.launch_environment, + policy=policy, + eligible_names=eligible_names, + platform=platform, + ) + else: + launch_values = {} + _merge_values( + final, + origins, + unresolved, + launch_values, + {key: EnvironmentOrigin("launch", "launch") for key in launch_values}, + {}, + platform=platform, + ) + + for assignment in plan.command_assignments: + if policy.target not in assignment.targets: + continue + _check_windows_duplicates( + assignment.values, label="command assignment", platform=platform + ) + _merge_values( + final, + origins, + unresolved, + assignment.values, + { + key: EnvironmentOrigin("command", assignment.reason) + for key in assignment.values + }, + {}, + platform=platform, + ) + + if policy.unresolved == "error": + failed = next( + ((key, names) for key, names in unresolved.items() if key in final), None + ) + if failed is not None: + key, names = failed + raise ContainerPolicyError( + f"Container environment key '{key}' has unresolved reference(s): {', '.join(sorted(names))}." + ) + + return ResolvedEnvironment( + values=final, + origins=origins, + declared_keys=frozenset(declared), + unresolved_references=unresolved, + diagnostics=tuple(diagnostics), + ) diff --git a/src/agentseek_api/runtime_entrypoint.py b/src/agentseek_api/runtime_entrypoint.py index f9ae427..a7f3c1e 100644 --- a/src/agentseek_api/runtime_entrypoint.py +++ b/src/agentseek_api/runtime_entrypoint.py @@ -1,9 +1,15 @@ from __future__ import annotations import importlib +import importlib.metadata +import importlib.util +import json +import os import runpy import sys from collections.abc import Sequence +from pathlib import Path +from urllib.parse import unquote, urlparse from pydantic import ValidationError @@ -14,6 +20,130 @@ "scheduler": "agentseek_api.scheduler", } +TARGET_DISTRIBUTIONS = { + "uvicorn": ("uvicorn", "uvicorn", None), + "worker": ("agentseek-api", "agentseek_api", "0.3.0"), + "scheduler": ("agentseek-api", "agentseek_api", "0.3.0"), +} + + +class RuntimeBootstrapError(RuntimeError): + pass + + +def _owned_distribution_locations( + distribution_name: str, + package_name: str, + *, + expected_version: str | None, +) -> tuple[frozenset[Path], tuple[Path, ...]]: + try: + distribution = importlib.metadata.distribution(distribution_name) + except importlib.metadata.PackageNotFoundError as exc: + raise RuntimeBootstrapError from exc + if expected_version is not None and distribution.version != expected_version: + raise RuntimeBootstrapError + + owned_files = frozenset( + distribution.locate_file(item).resolve() + for item in (distribution.files or ()) + if item.parts[:1] == (package_name,) + ) + editable_roots: tuple[Path, ...] = () + try: + direct_url = json.loads(distribution.read_text("direct_url.json") or "{}") + url = direct_url.get("url") + editable = direct_url.get("dir_info", {}).get("editable") is True + if editable and isinstance(url, str): + parsed = urlparse(url) + if parsed.scheme == "file": + checkout = Path(unquote(parsed.path)).resolve() + editable_roots = tuple( + candidate.resolve() + for candidate in ( + checkout / "src" / package_name, + checkout / package_name, + ) + if candidate.is_dir() + ) + except (json.JSONDecodeError, OSError, TypeError): + editable_roots = () + if not owned_files and not editable_roots: + raise RuntimeBootstrapError + return owned_files, editable_roots + + +def _owned_runtime_locations() -> tuple[frozenset[Path], tuple[Path, ...]]: + return _owned_distribution_locations( + "agentseek-api", "agentseek_api", expected_version="0.3.0" + ) + + +def _is_owned_path( + path: Path, *, owned_files: frozenset[Path], editable_roots: tuple[Path, ...] +) -> bool: + return path in owned_files or any( + path == root or root in path.parents for root in editable_roots + ) + + +def _require_distribution_owned_runtime() -> None: + owned_files, editable_roots = _owned_runtime_locations() + for module_name, module in tuple(sys.modules.items()): + if module_name != "agentseek_api" and not module_name.startswith( + "agentseek_api." + ): + continue + module_file = getattr(module, "__file__", None) + if module_file is None: + continue + path = Path(module_file).resolve() + if _is_owned_path(path, owned_files=owned_files, editable_roots=editable_roots): + continue + raise RuntimeBootstrapError + + +def _require_trusted_target_module(target_name: str) -> None: + distribution_name, package_name, expected_version = TARGET_DISTRIBUTIONS[ + target_name + ] + target_module = TARGET_MODULES[target_name] + owned_files, editable_roots = _owned_distribution_locations( + distribution_name, + package_name, + expected_version=expected_version, + ) + try: + spec = importlib.util.find_spec(target_module) + except (ImportError, ModuleNotFoundError, ValueError) as exc: + raise RuntimeBootstrapError from exc + if spec is None or spec.origin is None: + raise RuntimeBootstrapError + if not _is_owned_path( + Path(spec.origin).resolve(), + owned_files=owned_files, + editable_roots=editable_roots, + ): + raise RuntimeBootstrapError + + +def _activate_preloaded_runtime() -> None: + from agentseek_api.container_build import ( + ContainerBuildError, + load_container_runtime_manifest_v1, + ) + + manifest_value = os.environ.get("AGENTSEEK_GRAPHS") + if not manifest_value: + raise RuntimeBootstrapError + try: + manifest = load_container_runtime_manifest_v1(Path(manifest_value)) + except ContainerBuildError as exc: + raise RuntimeBootstrapError from exc + for dependency in manifest.dependencies: + if dependency not in sys.path: + sys.path.append(dependency) + def _format_settings_validation_error(exc: ValidationError) -> str: fields = sorted( @@ -27,6 +157,9 @@ def _format_settings_validation_error(exc: ValidationError) -> str: def main(argv: Sequence[str] | None = None) -> int: arguments = list(sys.argv[1:] if argv is None else argv) + preloaded = arguments[:1] == ["--preloaded-v1"] + if preloaded: + arguments = arguments[1:] if not arguments or arguments[0] not in TARGET_MODULES: sys.stderr.write("Invalid internal runtime target.\n") return 2 @@ -38,10 +171,19 @@ def main(argv: Sequence[str] | None = None) -> int: sys.argv = [target_module, *target_argv] try: try: + if preloaded: + _require_distribution_owned_runtime() + _require_trusted_target_module(target_name) + _activate_preloaded_runtime() importlib.import_module("agentseek_api.settings") + if preloaded: + _require_distribution_owned_runtime() except ValidationError as exc: sys.stderr.write(_format_settings_validation_error(exc) + "\n") return 2 + except RuntimeBootstrapError: + sys.stderr.write("The preloaded runtime identity is incompatible.\n") + return 2 try: runpy.run_module(target_module, run_name="__main__") except SystemExit as exc: diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py new file mode 100644 index 0000000..54f916b --- /dev/null +++ b/src/agentseek_api/secure_temp.py @@ -0,0 +1,1046 @@ +"""Fail-closed lifecycle helpers for user-private temporary artifacts.""" + +from __future__ import annotations + +import contextlib +import ctypes +import os +import secrets +import shutil +import stat +import tempfile +import time +from collections.abc import Iterator +from contextlib import contextmanager +from pathlib import Path + + +class SecureArtifactError(RuntimeError): + """A value-free failure to prove exclusive access to a temporary object.""" + + +_PRIVATE_FILE_MODE = 0o600 +_PRIVATE_DIRECTORY_MODE = 0o700 +_MAX_CREATE_ATTEMPTS = 128 +_WINDOWS_DACL_PRESENT = 0x0004 +_WINDOWS_DACL_DEFAULTED = 0x0008 +_WINDOWS_DACL_AUTO_INHERIT_REQ = 0x0100 +_WINDOWS_DACL_AUTO_INHERITED = 0x0400 +_WINDOWS_DACL_PROTECTED = 0x1000 +_WINDOWS_OBJECT_INHERIT_ACE = 0x01 +_WINDOWS_CONTAINER_INHERIT_ACE = 0x02 +_WINDOWS_NO_PROPAGATE_INHERIT_ACE = 0x04 +_WINDOWS_INHERIT_ONLY_ACE = 0x08 +_WINDOWS_INHERITED_ACE = 0x10 +_WINDOWS_FULL_CONTROL = 0x001F01FF + + +def _current_uid() -> int: + return os.getuid() + + +def _is_link_or_junction(path: Path, metadata: os.stat_result) -> bool: + if stat.S_ISLNK(metadata.st_mode): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _private_root(tmp_root: Path | None) -> Path: + root = Path(tempfile.gettempdir()) if tmp_root is None else Path(tmp_root) + try: + metadata = root.lstat() + except OSError as exc: + raise SecureArtifactError( + "Could not verify the private temporary root." + ) from exc + if _is_link_or_junction(root, metadata) or not stat.S_ISDIR(metadata.st_mode): + raise SecureArtifactError("Could not verify the private temporary root.") + return root + + +def _validate_prefix(prefix: str) -> None: + if ( + not prefix + or prefix in {".", ".."} + or Path(prefix).name != prefix + or "\x00" in prefix + ): + raise SecureArtifactError("Temporary artifact prefix is invalid.") + + +def _candidate_path(root: Path, prefix: str) -> Path: + return root / f"{prefix}{secrets.token_hex(16)}" + + +def _same_object(left: os.stat_result, right: os.stat_result) -> bool: + return (left.st_dev, left.st_ino) == (right.st_dev, right.st_ino) + + +def _verify_open_posix(fd: int, path: Path) -> os.stat_result: + try: + opened = os.fstat(fd) + named = path.lstat() + except OSError as exc: + raise SecureArtifactError("Could not prove exclusive access.") from exc + if ( + not stat.S_ISREG(opened.st_mode) + or not stat.S_ISREG(named.st_mode) + or not _same_object(opened, named) + or opened.st_uid != _current_uid() + or stat.S_IMODE(opened.st_mode) != _PRIVATE_FILE_MODE + or opened.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + raise SecureArtifactError("Could not prove exclusive access.") + return opened + + +def _verify_closed_posix(path: Path, expected: os.stat_result) -> None: + try: + named = path.lstat() + except OSError as exc: + raise SecureArtifactError("Could not prove exclusive access.") from exc + if ( + not stat.S_ISREG(named.st_mode) + or not _same_object(named, expected) + or named.st_uid != _current_uid() + or stat.S_IMODE(named.st_mode) != _PRIVATE_FILE_MODE + or named.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + raise SecureArtifactError("Could not prove exclusive access.") + + +def _verify_directory_posix(path: Path) -> os.stat_result: + try: + metadata = path.lstat() + except OSError as exc: + raise SecureArtifactError( + "Could not prove exclusive directory access." + ) from exc + if ( + not stat.S_ISDIR(metadata.st_mode) + or metadata.st_uid != _current_uid() + or stat.S_IMODE(metadata.st_mode) != _PRIVATE_DIRECTORY_MODE + or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + raise SecureArtifactError("Could not prove exclusive directory access.") + return metadata + + +def _verify_open_directory_posix(fd: int, path: Path, expected: os.stat_result) -> None: + try: + opened = os.fstat(fd) + named = path.lstat() + except OSError as exc: + raise SecureArtifactError( + "Could not prove exclusive directory access." + ) from exc + if ( + not stat.S_ISDIR(opened.st_mode) + or not stat.S_ISDIR(named.st_mode) + or not _same_object(opened, expected) + or not _same_object(named, expected) + or opened.st_uid != _current_uid() + or stat.S_IMODE(opened.st_mode) != _PRIVATE_DIRECTORY_MODE + or opened.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + raise SecureArtifactError("Could not prove exclusive directory access.") + + +def _verify_closed_directory_posix(path: Path, expected: os.stat_result) -> None: + metadata = _verify_directory_posix(path) + if not _same_object(metadata, expected): + raise SecureArtifactError("Could not prove exclusive directory access.") + + +def _verify_open_windows( # pragma: no cover - native Windows only + fd: int, path: Path +) -> os.stat_result: + try: + opened = os.fstat(fd) + named = path.lstat() + except OSError as exc: + raise SecureArtifactError("Could not prove exclusive Windows access.") from exc + if ( + not stat.S_ISREG(opened.st_mode) + or not stat.S_ISREG(named.st_mode) + or not _same_object(opened, named) + ): + raise SecureArtifactError("Could not prove exclusive Windows access.") + _verify_private_dacl(path, directory=False) + return opened + + +def _verify_closed_windows( # pragma: no cover - native Windows only + path: Path, expected: os.stat_result, *, directory: bool +) -> None: + try: + named = path.lstat() + except OSError as exc: + raise SecureArtifactError("Could not prove exclusive Windows access.") from exc + type_matches = ( + stat.S_ISDIR(named.st_mode) if directory else stat.S_ISREG(named.st_mode) + ) + if not type_matches or not _same_object(named, expected): + raise SecureArtifactError("Could not prove exclusive Windows access.") + _verify_private_dacl(path, directory=directory) + + +def verify_private_directory( + path: Path, *, expected: os.stat_result | None = None +) -> os.stat_result: + """Recheck a private output directory with native ownership/ACL semantics.""" + + directory = Path(path) + try: + metadata = directory.lstat() + except OSError as exc: + raise SecureArtifactError( + "Could not prove exclusive directory access." + ) from exc + if _is_link_or_junction(directory, metadata) or not stat.S_ISDIR(metadata.st_mode): + raise SecureArtifactError("Could not prove exclusive directory access.") + frozen = metadata if expected is None else expected + if os.name == "nt": # pragma: no cover - native Windows only + _verify_closed_windows(directory, frozen, directory=True) + else: + _verify_closed_directory_posix(directory, frozen) + return metadata + + +def create_private_directory(path: Path) -> os.stat_result: + """Create one exact persistent directory with the private-root contract.""" + + directory = Path(path) + fd: int | None = None + expected: os.stat_result | None = None + try: + if os.name == "nt": # pragma: no cover - native Windows only + expected = _create_private_windows_directory_at(directory) + else: + os.mkdir(directory, _PRIVATE_DIRECTORY_MODE) + expected = directory.lstat() + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(directory, flags) + os.fchmod(fd, _PRIVATE_DIRECTORY_MODE) + _verify_open_directory_posix(fd, directory, expected) + os.close(fd) + fd = None + _verify_closed_directory_posix(directory, expected) + return expected + except FileExistsError: + raise + except (OSError, SecureArtifactError) as exc: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + if expected is not None: + _quarantine_then_rmtree(directory, expected) + if isinstance(exc, SecureArtifactError): + raise + raise SecureArtifactError("Could not create a private directory.") from exc + + +def _win32_libraries( # pragma: no cover - native Windows only +) -> tuple[ctypes.WinDLL, ctypes.WinDLL]: # type: ignore[name-defined] + if os.name != "nt": + raise SecureArtifactError("Windows security APIs are unavailable.") + from ctypes import wintypes + + advapi32 = ctypes.WinDLL("advapi32", use_last_error=True) + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.LocalFree.argtypes = [ctypes.c_void_p] + kernel32.LocalFree.restype = ctypes.c_void_p + kernel32.CreateDirectoryW.argtypes = [wintypes.LPCWSTR, ctypes.c_void_p] + kernel32.CreateDirectoryW.restype = wintypes.BOOL + advapi32.OpenProcessToken.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + ctypes.POINTER(wintypes.HANDLE), + ] + advapi32.OpenProcessToken.restype = wintypes.BOOL + advapi32.GetTokenInformation.argtypes = [ + wintypes.HANDLE, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + advapi32.GetTokenInformation.restype = wintypes.BOOL + advapi32.GetLengthSid.argtypes = [ctypes.c_void_p] + advapi32.GetLengthSid.restype = wintypes.DWORD + advapi32.CopySid.argtypes = [wintypes.DWORD, ctypes.c_void_p, ctypes.c_void_p] + advapi32.CopySid.restype = wintypes.BOOL + advapi32.CreateWellKnownSid.argtypes = [ + wintypes.DWORD, + ctypes.c_void_p, + ctypes.c_void_p, + ctypes.POINTER(wintypes.DWORD), + ] + advapi32.CreateWellKnownSid.restype = wintypes.BOOL + advapi32.ConvertSidToStringSidW.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(wintypes.LPWSTR), + ] + advapi32.ConvertSidToStringSidW.restype = wintypes.BOOL + advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(wintypes.ULONG), + ] + advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW.restype = ( + wintypes.BOOL + ) + advapi32.SetFileSecurityW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + ctypes.c_void_p, + ] + advapi32.SetFileSecurityW.restype = wintypes.BOOL + advapi32.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p] + advapi32.EqualSid.restype = wintypes.BOOL + advapi32.GetNamedSecurityInfoW.argtypes = [ + wintypes.LPWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.GetNamedSecurityInfoW.restype = wintypes.DWORD + advapi32.GetSecurityDescriptorControl.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(wintypes.WORD), + ctypes.POINTER(wintypes.DWORD), + ] + advapi32.GetSecurityDescriptorControl.restype = wintypes.BOOL + advapi32.GetAclInformation.argtypes = [ + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ] + advapi32.GetAclInformation.restype = wintypes.BOOL + advapi32.GetAce.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_void_p), + ] + advapi32.GetAce.restype = wintypes.BOOL + return advapi32, kernel32 + + +def _win32_error( # pragma: no cover - native Windows only + message: str, +) -> SecureArtifactError: + return SecureArtifactError(message) + + +def _current_user_sid() -> bytes: # pragma: no cover - native Windows only + """Return a stable copy of the current process token's user SID.""" + + from ctypes import wintypes + + advapi32, kernel32 = _win32_libraries() + token = wintypes.HANDLE() + token_query = 0x0008 + token_user = 1 + process = kernel32.GetCurrentProcess() + if not advapi32.OpenProcessToken(process, token_query, ctypes.byref(token)): + raise _win32_error("Could not verify the Windows user identity.") + try: + size = wintypes.DWORD() + advapi32.GetTokenInformation(token, token_user, None, 0, ctypes.byref(size)) + if size.value == 0: + raise _win32_error("Could not verify the Windows user identity.") + buffer = ctypes.create_string_buffer(size.value) + if not advapi32.GetTokenInformation( + token, token_user, buffer, size, ctypes.byref(size) + ): + raise _win32_error("Could not verify the Windows user identity.") + + class SidAndAttributes(ctypes.Structure): + _fields_ = [("Sid", ctypes.c_void_p), ("Attributes", wintypes.DWORD)] + + sid = ctypes.cast(buffer, ctypes.POINTER(SidAndAttributes)).contents.Sid + length = advapi32.GetLengthSid(sid) + if length <= 0: + raise _win32_error("Could not verify the Windows user identity.") + copied = ctypes.create_string_buffer(length) + if not advapi32.CopySid(length, copied, sid): + raise _win32_error("Could not verify the Windows user identity.") + return bytes(copied.raw) + finally: + kernel32.CloseHandle(token) + + +def _well_known_system_sid() -> bytes: # pragma: no cover - native Windows only + from ctypes import wintypes + + advapi32, _ = _win32_libraries() + size = wintypes.DWORD(68) + buffer = ctypes.create_string_buffer(size.value) + if not advapi32.CreateWellKnownSid(22, None, buffer, ctypes.byref(size)): + raise _win32_error("Could not verify the Windows SYSTEM identity.") + return bytes(buffer.raw[: size.value]) + + +def _sid_string(sid_bytes: bytes) -> str: # pragma: no cover - native Windows only + from ctypes import wintypes + + advapi32, kernel32 = _win32_libraries() + sid = ctypes.create_string_buffer(sid_bytes) + pointer = wintypes.LPWSTR() + if not advapi32.ConvertSidToStringSidW(sid, ctypes.byref(pointer)): + raise _win32_error("Could not encode the Windows user identity.") + try: + return pointer.value + finally: + kernel32.LocalFree(ctypes.cast(pointer, ctypes.c_void_p)) + + +@contextmanager +def _private_security_descriptor( # pragma: no cover - native Windows only + *, directory: bool +) -> Iterator[ctypes.c_void_p]: + from ctypes import wintypes + + advapi32, kernel32 = _win32_libraries() + user = _sid_string(_current_user_sid()) + inheritance = "OICI" if directory else "" + sddl = f"O:{user}D:P(A;{inheritance};FA;;;{user})(A;{inheritance};FA;;;SY)" + descriptor = ctypes.c_void_p() + descriptor_size = wintypes.ULONG() + if not advapi32.ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl, 1, ctypes.byref(descriptor), ctypes.byref(descriptor_size) + ): + raise _win32_error("Could not establish exclusive Windows access.") + try: + yield descriptor + finally: + kernel32.LocalFree(descriptor) + + +def _apply_private_dacl( # pragma: no cover - native Windows only + path: Path, *, directory: bool = False +) -> None: + """Install a protected owner/DACL containing only user and SYSTEM access.""" + + advapi32, _ = _win32_libraries() + with _private_security_descriptor(directory=directory) as descriptor: + owner_information = 0x00000001 + dacl_information = 0x00000004 + protected_dacl_information = 0x80000000 + if not advapi32.SetFileSecurityW( + str(path), + owner_information | dacl_information | protected_dacl_information, + descriptor, + ): + raise _win32_error("Could not establish exclusive Windows access.") + + +def _create_private_windows_directory_at( # pragma: no cover - native Windows only + candidate: Path, +) -> os.stat_result: + from ctypes import wintypes + + _, kernel32 = _win32_libraries() + + class SecurityAttributes(ctypes.Structure): + _fields_ = [ + ("nLength", wintypes.DWORD), + ("lpSecurityDescriptor", ctypes.c_void_p), + ("bInheritHandle", wintypes.BOOL), + ] + + with _private_security_descriptor(directory=True) as descriptor: + attributes = SecurityAttributes( + ctypes.sizeof(SecurityAttributes), descriptor, False + ) + if kernel32.CreateDirectoryW(str(candidate), ctypes.byref(attributes)): + expected = candidate.lstat() + try: + _verify_private_dacl(candidate, directory=True) + except SecureArtifactError: + _quarantine_then_rmtree(candidate, expected) + raise + return expected + if ctypes.get_last_error() == 183: + raise FileExistsError(str(candidate)) + raise SecureArtifactError("Could not create a private directory.") + + +def _create_private_windows_directory( # pragma: no cover - native Windows only + root: Path, prefix: str +) -> tuple[Path, os.stat_result]: + for _ in range(_MAX_CREATE_ATTEMPTS): + candidate = _candidate_path(root, prefix) + try: + expected = _create_private_windows_directory_at(candidate) + except FileExistsError: + continue + return candidate, expected + raise SecureArtifactError("Could not create a private directory.") + + +def _sid_matches( # pragma: no cover - native Windows only + left: ctypes.c_void_p, right_bytes: bytes +) -> bool: + advapi32, _ = _win32_libraries() + right = ctypes.create_string_buffer(right_bytes) + return bool(advapi32.EqualSid(left, right)) + + +def _validate_windows_dacl_entries( + *, + entries: tuple[tuple[int, int, int, str], ...], + expected_flags: int | None, +) -> None: + if len(entries) != 2: + raise _win32_error("Could not prove exclusive Windows access.") + principals: set[str] = set() + allowed_descendant_flags = ( + _WINDOWS_OBJECT_INHERIT_ACE + | _WINDOWS_CONTAINER_INHERIT_ACE + | _WINDOWS_INHERITED_ACE + ) + for ace_type, ace_flags, mask, principal in entries: + if ( + ace_type != 0 + or mask != _WINDOWS_FULL_CONTROL + or (expected_flags is not None and ace_flags != expected_flags) + or ( + expected_flags is None + and ( + ace_flags & ~allowed_descendant_flags + or ace_flags & _WINDOWS_NO_PROPAGATE_INHERIT_ACE + or ace_flags & _WINDOWS_INHERIT_ONLY_ACE + ) + ) + ): + raise _win32_error("Could not prove exclusive Windows access.") + principals.add(principal) + if principals != {"user", "system"}: + raise _win32_error("Could not prove exclusive Windows access.") + + +def _validate_windows_private_dacl( + *, + control: int, + entries: tuple[tuple[int, int, int, str], ...], + directory: bool, +) -> None: + required_control = _WINDOWS_DACL_PRESENT | _WINDOWS_DACL_PROTECTED + forbidden_control = ( + _WINDOWS_DACL_DEFAULTED + | _WINDOWS_DACL_AUTO_INHERIT_REQ + | _WINDOWS_DACL_AUTO_INHERITED + ) + if control & required_control != required_control or control & forbidden_control: + raise _win32_error("Could not prove exclusive Windows access.") + expected_flags = ( + _WINDOWS_OBJECT_INHERIT_ACE | _WINDOWS_CONTAINER_INHERIT_ACE if directory else 0 + ) + _validate_windows_dacl_entries( + entries=entries, + expected_flags=expected_flags, + ) + + +def _validate_windows_descendant_dacl( + *, + control: int, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + if ( + control & _WINDOWS_DACL_PRESENT != _WINDOWS_DACL_PRESENT + or control & _WINDOWS_DACL_DEFAULTED + ): + raise _win32_error("Could not prove exclusive Windows access.") + _validate_windows_dacl_entries(entries=entries, expected_flags=None) + + +def _verify_windows_dacl( # pragma: no cover - native Windows only + path: Path, + *, + directory: bool, + descendant: bool, +) -> None: + """Read owner and effective ACEs back without localized command output.""" + + from ctypes import wintypes + + advapi32, kernel32 = _win32_libraries() + owner = ctypes.c_void_p() + dacl = ctypes.c_void_p() + descriptor = ctypes.c_void_p() + owner_information = 0x00000001 + dacl_information = 0x00000004 + result = advapi32.GetNamedSecurityInfoW( + str(path), + 1, + owner_information | dacl_information, + ctypes.byref(owner), + None, + ctypes.byref(dacl), + None, + ctypes.byref(descriptor), + ) + if result != 0 or not owner.value or not dacl.value or not descriptor.value: + if descriptor.value: + kernel32.LocalFree(descriptor) + raise _win32_error("Could not prove exclusive Windows access.") + try: + user_sid = _current_user_sid() + system_sid = _well_known_system_sid() + # The protected root has a strict current-user owner. Ordinary children can + # receive the process token's default owner on Windows, so descendants are + # trusted by their exact inherited user+SYSTEM DACL instead of owner SID. + if not descendant and not _sid_matches(owner, user_sid): + raise _win32_error("Could not prove exclusive Windows access.") + + control = wintypes.WORD() + revision = wintypes.DWORD() + if not advapi32.GetSecurityDescriptorControl( + descriptor, ctypes.byref(control), ctypes.byref(revision) + ): + raise _win32_error("Could not prove exclusive Windows access.") + + class AclSizeInformation(ctypes.Structure): + _fields_ = [ + ("AceCount", wintypes.DWORD), + ("AclBytesInUse", wintypes.DWORD), + ("AclBytesFree", wintypes.DWORD), + ] + + info = AclSizeInformation() + if not advapi32.GetAclInformation( + dacl, ctypes.byref(info), ctypes.sizeof(info), 2 + ): + raise _win32_error("Could not prove exclusive Windows access.") + entries: list[tuple[int, int, int, str]] = [] + for index in range(info.AceCount): + ace = ctypes.c_void_p() + if not advapi32.GetAce(dacl, index, ctypes.byref(ace)) or not ace.value: + raise _win32_error("Could not prove exclusive Windows access.") + header = ctypes.string_at(ace, 8) + ace_type = header[0] + ace_flags = header[1] + mask = int.from_bytes(header[4:8], byteorder="little") + ace_sid = ctypes.c_void_p(ace.value + 8) + if _sid_matches(ace_sid, user_sid): + principal = "user" + elif _sid_matches(ace_sid, system_sid): + principal = "system" + else: + principal = "other" + entries.append((ace_type, ace_flags, mask, principal)) + normalized_entries = tuple(entries) + if descendant: + _validate_windows_descendant_dacl( + control=control.value, + entries=normalized_entries, + ) + else: + _validate_windows_private_dacl( + control=control.value, + entries=normalized_entries, + directory=directory, + ) + finally: + kernel32.LocalFree(descriptor) + + +def _verify_private_dacl( # pragma: no cover - native Windows only + path: Path, + *, + directory: bool, +) -> None: + _verify_windows_dacl(path, directory=directory, descendant=False) + + +def _verify_descendant_dacl( # pragma: no cover - native Windows only + path: Path, + *, + directory: bool, +) -> None: + _verify_windows_dacl(path, directory=directory, descendant=True) + + +def _move_to_quarantine(path: Path) -> Path | None: + """Atomically move a candidate to an unpredictable same-parent path.""" + + for _ in range(_MAX_CREATE_ATTEMPTS): + quarantine = path.parent / f".agentseek-quarantine-{secrets.token_hex(32)}" + try: + os.rename(path, quarantine) + except FileNotFoundError: + return None + except FileExistsError: + continue + except OSError: + return None + return quarantine + return None + + +def _quarantined_file_matches(path: Path, expected: os.stat_result) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + if ( + _is_link_or_junction(path, metadata) + or not stat.S_ISREG(metadata.st_mode) + or not _same_object(metadata, expected) + ): + return False + try: + if os.name == "nt": # pragma: no cover - native Windows only + _verify_private_dacl(path, directory=False) + elif ( + metadata.st_uid != _current_uid() + or stat.S_IMODE(metadata.st_mode) != _PRIVATE_FILE_MODE + or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + return False + except SecureArtifactError: + return False + return True + + +def _quarantined_directory_matches(path: Path, expected: os.stat_result) -> bool: + try: + metadata = path.lstat() + except OSError: + return False + if ( + _is_link_or_junction(path, metadata) + or not stat.S_ISDIR(metadata.st_mode) + or not _same_object(metadata, expected) + ): + return False + try: + if os.name == "nt": # pragma: no cover - native Windows only + _verify_private_dacl(path, directory=True) + elif ( + metadata.st_uid != _current_uid() + or stat.S_IMODE(metadata.st_mode) != _PRIVATE_DIRECTORY_MODE + or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + return False + except SecureArtifactError: + return False + return True + + +def _verify_windows_private_tree( # pragma: no cover - native Windows only + root: Path, +) -> bool: + pending = [root] + while pending: + directory = pending.pop() + try: + entries = tuple(os.scandir(directory)) + except OSError: + return False + for entry in entries: + path = Path(entry.path) + try: + metadata = path.lstat() + except OSError: + return False + if _is_link_or_junction(path, metadata): + return False + try: + if stat.S_ISDIR(metadata.st_mode): + _verify_descendant_dacl(path, directory=True) + pending.append(path) + elif stat.S_ISREG(metadata.st_mode): + _verify_descendant_dacl(path, directory=False) + else: + return False + except SecureArtifactError: + return False + return True + + +def _quarantine_then_unlink(path: Path, expected: os.stat_result) -> bool: + quarantine = _move_to_quarantine(path) + if quarantine is None or not _quarantined_file_matches(quarantine, expected): + return False + try: + quarantine.unlink() + except OSError: + return False + return True + + +def _quarantine_then_rmtree( + path: Path, + expected: os.stat_result, + *, + verify_windows_tree: bool = False, +) -> bool: + quarantine = _move_to_quarantine(path) + if quarantine is None or not _quarantined_directory_matches(quarantine, expected): + return False + if verify_windows_tree and not _verify_windows_private_tree(quarantine): + return False + try: + shutil.rmtree(quarantine) + except OSError: + return False + return True + + +@contextmanager +def private_artifact( + *, + contents: bytes, + prefix: str, + tmp_root: Path | None = None, +) -> Iterator[Path]: + """Create, verify, expose, and remove one private regular file.""" + + root = _private_root(tmp_root) + _validate_prefix(prefix) + path: Path | None = None + private_parent: Path | None = None + private_parent_expected: os.stat_result | None = None + fd: int | None = None + expected: os.stat_result | None = None + flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0) + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + if os.name == "nt": # pragma: no cover - native Windows only + private_parent, private_parent_expected = _create_private_windows_directory( + root, prefix + ) + creation_root = private_parent + creation_prefix = "environment-" + else: + creation_root = root + creation_prefix = prefix + for _ in range(_MAX_CREATE_ATTEMPTS): + candidate = _candidate_path(creation_root, creation_prefix) + try: + fd = os.open(candidate, flags, _PRIVATE_FILE_MODE) + except FileExistsError: + continue + except OSError as exc: + if private_parent is not None and private_parent_expected is not None: + _quarantine_then_rmtree(private_parent, private_parent_expected) + raise SecureArtifactError("Could not create a private artifact.") from exc + path = candidate + try: + expected = os.fstat(fd) + except OSError as exc: + with contextlib.suppress(OSError): + os.close(fd) + fd = None + raise SecureArtifactError( + "Could not capture the private artifact identity." + ) from exc + break + if path is None or fd is None or expected is None: + if private_parent is not None and private_parent_expected is not None: + _quarantine_then_rmtree(private_parent, private_parent_expected) + raise SecureArtifactError("Could not create a private artifact.") + + try: + view = memoryview(contents) + while view: + written = os.write(fd, view) + if written <= 0: + raise SecureArtifactError("Could not write a private artifact.") + view = view[written:] + os.fsync(fd) + if os.name == "nt": # pragma: no cover - native Windows only + _apply_private_dacl(path) + verified = _verify_open_windows(fd, path) + else: + os.fchmod(fd, _PRIVATE_FILE_MODE) + verified = _verify_open_posix(fd, path) + if not _same_object(verified, expected): + raise SecureArtifactError("Could not prove exclusive access.") + os.close(fd) + fd = None + if os.name == "nt": # pragma: no cover - native Windows only + _verify_closed_windows(path, expected, directory=False) + else: + _verify_closed_posix(path, expected) + except (OSError, SecureArtifactError) as exc: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + fd = None + removed = _quarantine_then_unlink(path, expected) + if ( + removed + and private_parent is not None + and private_parent_expected is not None + ): + _quarantine_then_rmtree(private_parent, private_parent_expected) + if isinstance(exc, SecureArtifactError): + raise + raise SecureArtifactError("Could not prepare a private artifact.") from exc + + try: + yield path + finally: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + removed = _quarantine_then_unlink(path, expected) + if ( + removed + and private_parent is not None + and private_parent_expected is not None + ): + _quarantine_then_rmtree(private_parent, private_parent_expected) + + +@contextmanager +def private_directory(*, prefix: str, tmp_root: Path | None = None) -> Iterator[Path]: + """Create, verify, expose, and recursively remove a private directory.""" + + root = _private_root(tmp_root) + _validate_prefix(prefix) + path: Path | None = None + expected: os.stat_result | None = None + fd: int | None = None + if os.name == "nt": # pragma: no cover - native Windows only + path, expected = _create_private_windows_directory(root, prefix) + else: + for _ in range(_MAX_CREATE_ATTEMPTS): + candidate = _candidate_path(root, prefix) + try: + os.mkdir(candidate, _PRIVATE_DIRECTORY_MODE) + except FileExistsError: + continue + except OSError as exc: + raise SecureArtifactError( + "Could not create a private directory." + ) from exc + path = candidate + try: + expected = path.lstat() + except OSError as exc: + raise SecureArtifactError( + "Could not capture the private directory identity." + ) from exc + break + if path is None or expected is None: + raise SecureArtifactError("Could not create a private directory.") + try: + if os.name == "nt": # pragma: no cover - native Windows only + _verify_closed_windows(path, expected, directory=True) + else: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags) + os.fchmod(fd, _PRIVATE_DIRECTORY_MODE) + _verify_open_directory_posix(fd, path, expected) + os.close(fd) + fd = None + _verify_closed_directory_posix(path, expected) + except (OSError, SecureArtifactError) as exc: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + fd = None + _quarantine_then_rmtree(path, expected) + if isinstance(exc, SecureArtifactError): + raise + raise SecureArtifactError("Could not prepare a private directory.") from exc + try: + yield path + finally: + _quarantine_then_rmtree(path, expected) + + +def sweep_expired_artifacts( + *, + prefix: str, + older_than_seconds: float, + tmp_root: Path | None = None, + now: float | None = None, +) -> tuple[Path, ...]: + """Remove only old, same-user, still-private artifact objects.""" + + if older_than_seconds < 0: + raise SecureArtifactError("Artifact expiry must not be negative.") + root = _private_root(tmp_root) + _validate_prefix(prefix) + cutoff = (time.time() if now is None else now) - older_than_seconds + removed: list[Path] = [] + try: + entries = list(os.scandir(root)) + except OSError as exc: + raise SecureArtifactError("Could not inspect stale private artifacts.") from exc + for entry in entries: + if not entry.name.startswith(prefix): + continue + candidate = root / entry.name + try: + metadata = candidate.lstat() + if ( + _is_link_or_junction(candidate, metadata) + or metadata.st_mtime >= cutoff + or candidate.resolve(strict=True).parent != root.resolve(strict=True) + ): + continue + if os.name != "nt": + expected_mode = ( + _PRIVATE_FILE_MODE + if stat.S_ISREG(metadata.st_mode) + else _PRIVATE_DIRECTORY_MODE + if stat.S_ISDIR(metadata.st_mode) + else None + ) + if ( + expected_mode is None + or metadata.st_uid != _current_uid() + or stat.S_IMODE(metadata.st_mode) != expected_mode + or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ): + continue + if stat.S_ISDIR(metadata.st_mode): + was_removed = _quarantine_then_rmtree( + candidate, + metadata, + verify_windows_tree=os.name == "nt", + ) + elif not stat.S_ISREG(metadata.st_mode): + continue + else: + was_removed = _quarantine_then_unlink(candidate, metadata) + except (OSError, SecureArtifactError): + continue + if was_removed: + removed.append(candidate) + return tuple(sorted(removed)) + + +__all__ = [ + "SecureArtifactError", + "create_private_directory", + "private_artifact", + "private_directory", + "sweep_expired_artifacts", + "verify_private_directory", +] diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py new file mode 100644 index 0000000..7ff3d64 --- /dev/null +++ b/tests/container_plan_helpers.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import json +import io +import os +import re +import subprocess +import tarfile +from collections.abc import Iterator, Mapping +from dataclasses import dataclass, field +from pathlib import Path +from types import MappingProxyType +from typing import AbstractSet + +from agentseek_api.environment import ResolvedEnvironment +from agentseek_api.container_build import ( + ContainerBuildBundle, + ContainerBuildPlan, + materialize_build_bundle, + plan_container_image, + render_build_dockerfile, +) + + +def make_graph_project(root: Path) -> Path: + project = root / "project" + package = project / "chat" + package.mkdir(parents=True) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") + (project / "agentseek.json").write_text( + json.dumps({"dependencies": ["."], "graphs": {"chat": "chat.graph:graph"}}), + encoding="utf-8", + ) + return project + + +def write_sanitized_manifest(root: Path) -> Path: + manifest = root / "manifest.v1.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "runtime": { + "distribution": "agentseek-api", + "version": "0.3.0", + "contract": "preloaded-v1", + }, + "dependencies": [], + "graphs": {"chat": "chat.graph:graph"}, + } + ), + encoding="utf-8", + ) + return manifest + + +def build_plan_fixture(root: Path) -> ContainerBuildPlan: + project = make_graph_project(root) + pip_config = project / "pip.conf" + pip_config.write_text( + "[global]\nindex-url = https://packages.example.invalid/simple\n", + encoding="utf-8", + ) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = "./pip.conf" + config.write_text(json.dumps(payload), encoding="utf-8") + return plan_container_image(config_path=config) + + +def package_only_build_plan_fixture(root: Path) -> ContainerBuildPlan: + project = root / "package-only" + project.mkdir() + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "installed.graph:graph"}, + "dependencies": ["installed-package>=1"], + } + ), + encoding="utf-8", + ) + return plan_container_image(config_path=config) + + +def bundle_fixture(root: Path) -> ContainerBuildBundle: + plan = build_plan_fixture(root) + dockerfile = render_build_dockerfile(plan) + return materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=root / "bundle", + ) + + +def read_archive_member(archive: bytes, name: str) -> bytes: + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as context: + member = context.extractfile(name) + if member is None: + raise KeyError(name) + return member.read() + + +@dataclass(frozen=True) +class ComposeDecodedEnvironment(Mapping[str, str]): + substitution: Mapping[str, str] = field(repr=False) + rendered: Mapping[str, str] = field(repr=False) + commands: Mapping[str, tuple[str, ...]] = field( + default_factory=lambda: MappingProxyType({}), repr=False + ) + runtime: Mapping[str, str] = field( + default_factory=lambda: MappingProxyType({}), repr=False + ) + + def __getitem__(self, key: str) -> str: + return self.rendered[key] + + def __iter__(self) -> Iterator[str]: + return iter(self.rendered) + + def __len__(self) -> int: + return len(self.rendered) + + +def _synthetic_docker_environment() -> dict[str, str]: + allowed = { + "PATH", + "HOME", + "USERPROFILE", + "SYSTEMROOT", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + } + return {name: value for name, value in os.environ.items() if name in allowed} + + +def _run_private(command: list[str], *, cwd: Path) -> bytes: + completed = subprocess.run( + command, + cwd=cwd, + env=_synthetic_docker_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=30, + ) + if completed.returncode != 0: + raise RuntimeError("Compose conformance query failed.") + return completed.stdout + + +def docker_daemon_available(*, cwd: Path) -> bool: + try: + completed = subprocess.run( + ["docker", "info", "--format", "{{json .ServerVersion}}"], + cwd=cwd, + env=_synthetic_docker_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return False + return completed.returncode == 0 + + +def docker_compose_available(*, cwd: Path) -> bool: + try: + completed = subprocess.run( + ["docker", "compose", "version", "--short"], + cwd=cwd, + env=_synthetic_docker_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=10, + ) + except (OSError, subprocess.TimeoutExpired): + return False + if completed.returncode != 0: + return False + match = re.fullmatch( + rb"v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9A-Za-z.-]+)?\r?\n?", completed.stdout + ) + return match is not None and tuple(int(part) for part in match.groups()) >= ( + 2, + 24, + 0, + ) + + +def decode_with_supported_compose( + encoded: str, + *, + tmp_path: Path, + run_service: bool = False, +) -> ComposeDecodedEnvironment: + """Inspect Compose substitution, rendered JSON, and optional runtime bytes.""" + + names = tuple( + line.partition("=")[0] for line in encoded.splitlines() if line.strip() + ) + if any(not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) for name in names): + raise ValueError("Encoded Compose input has an invalid name.") + env_path = tmp_path / "explicit-compose.env" + compose_path = tmp_path / "compose-conformance.json" + result_path = tmp_path / "compose-results" + result_path.mkdir(mode=0o700, exist_ok=True) + for name in names: + (result_path / name).touch(mode=0o600, exist_ok=False) + env_path.write_text(encoded, encoding="utf-8") + (tmp_path / ".env").write_text( + "PROJECT_DOTENV_CANARY=must-not-load\n", encoding="utf-8" + ) + services = { + f"probe-{name.lower().replace('_', '-')}": { + "image": "busybox:1.36", + "environment": { + name: f"${{{name}}}", + "PROJECT_DOTENV_CANARY": "${PROJECT_DOTENV_CANARY-unset}", + }, + "command": [ + "sh", + "-c", + f"umask 077; printf '%s' \"$${{{name}}}\" > /result/{name}", + ], + "volumes": [f"{result_path}:/result"], + } + for name in names + } + compose_path.write_text( + json.dumps({"name": "agentseek-compose-conformance", "services": services}), + encoding="utf-8", + ) + base = [ + "docker", + "compose", + "--env-file", + str(env_path), + "-f", + str(compose_path), + ] + rendered_document = json.loads( + _run_private([*base, "config", "--format", "json"], cwd=tmp_path) + ) + substitution: dict[str, str] = {} + rendered: dict[str, str] = {} + commands: dict[str, tuple[str, ...]] = {} + for name in names: + service = rendered_document["services"][ + f"probe-{name.lower().replace('_', '-')}" + ] + if service["environment"]["PROJECT_DOTENV_CANARY"] != "unset": + raise RuntimeError("Compose loaded the project dotenv unexpectedly.") + rendered[name] = service["environment"][name] + substitution[name] = rendered[name].replace("$$", "$") + commands[name] = tuple(service["command"]) + + runtime: dict[str, str] = {} + if run_service: + try: + _run_private( + [*base, "up", "--abort-on-container-exit", "--remove-orphans"], + cwd=tmp_path, + ) + runtime = { + name: (result_path / name).read_bytes().decode("utf-8") + for name in names + } + finally: + subprocess.run( + [*base, "down", "--remove-orphans"], + cwd=tmp_path, + env=_synthetic_docker_environment(), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=30, + ) + return ComposeDecodedEnvironment( + substitution=MappingProxyType(substitution), + rendered=MappingProxyType(rendered), + commands=MappingProxyType(commands), + runtime=MappingProxyType(runtime), + ) + + +def resolved_fixture( + *, + values: Mapping[str, str], + declared_keys: AbstractSet[str], + unresolved_references: Mapping[str, AbstractSet[str]] | None = None, +) -> ResolvedEnvironment: + return ResolvedEnvironment( + values=MappingProxyType(dict(values)), + origins=MappingProxyType({}), + declared_keys=frozenset(declared_keys), + unresolved_references=MappingProxyType( + { + key: frozenset(names) + for key, names in (unresolved_references or {}).items() + } + ), + diagnostics=(), + ) diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 568bcc4..da3abcb 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -3,11 +3,13 @@ import json import os import signal +import socket import subprocess import sys import time from pathlib import Path +import httpx import pytest from agentseek_api import __version__ @@ -135,6 +137,12 @@ def _stop_test_process(process: subprocess.Popen[str]) -> None: process.communicate(timeout=3) +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + def _cleanup_recorded_pids(pids: tuple[int, ...]) -> None: for pid in pids: _terminate_observed_pid(pid, timeout_seconds=3.0) @@ -248,6 +256,171 @@ def test_cli_import_does_not_import_runtime_settings(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr +def test_preloaded_runtime_child_ignores_hostile_cwd_and_pythonpath( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + manifest = tmp_path / "manifest.v1.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "runtime": { + "distribution": "agentseek-api", + "version": "0.3.0", + "contract": "preloaded-v1", + }, + "dependencies": [], + "graphs": { + "chat": "agentseek_api.services.langgraph_service:_build_echo_graph" + }, + } + ), + encoding="utf-8", + ) + hostile = tmp_path / "hostile" + package = hostile / "agentseek_api" + package.mkdir(parents=True) + marker = tmp_path / "hostile-imported" + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "runtime_entrypoint.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('canary')\nraise SystemExit(23)\n", + encoding="utf-8", + ) + monkeypatch.setenv("AGENTSEEK_GRAPHS", str(manifest)) + monkeypatch.setenv("PYTHONPATH", str(hostile)) + monkeypatch.setenv("PORT", "invalid-port-canary") + observed: dict[str, object] = {} + + def run(command: list[str], *, env: dict[str, str], cwd: str) -> int: + observed["command"] = command + completed = subprocess.run( + command, + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + observed["stderr"] = completed.stderr + return completed.returncode + + exit_code = main( + ["serve", "--environment-mode", "preloaded-v1"], + runner=run, + cwd=hostile, + ) + + assert exit_code == 2 + assert marker.exists() is False + assert observed["command"][:3] == [sys.executable, "-I", "-m"] + assert observed["stderr"] == "Invalid runtime setting(s): PORT (int_parsing).\n" + + +def test_preloaded_runtime_child_reaches_settings_and_manifest_graph_from_hostile_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + manifest = tmp_path / "manifest.v1.json" + manifest.write_text( + json.dumps( + { + "schema_version": 1, + "runtime": { + "distribution": "agentseek-api", + "version": "0.3.0", + "contract": "preloaded-v1", + }, + "dependencies": [], + "graphs": { + "review_graph": ( + "agentseek_api.services.langgraph_service:_build_echo_graph" + ) + }, + } + ), + encoding="utf-8", + ) + hostile = tmp_path / "hostile" + package = hostile / "agentseek_api" + package.mkdir(parents=True) + marker = tmp_path / "hostile-imported" + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "runtime_entrypoint.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('canary')\nraise SystemExit(23)\n", + encoding="utf-8", + ) + port = _free_port() + monkeypatch.setenv("AGENTSEEK_GRAPHS", str(manifest)) + monkeypatch.setenv("PYTHONPATH", str(hostile)) + monkeypatch.setenv("APP_NAME", "Trusted preloaded settings") + monkeypatch.setenv("METADATA_DB_BACKEND", "sqlite") + monkeypatch.setenv( + "METADATA_DB_URL", + f"sqlite+aiosqlite:///{(tmp_path / 'runtime.db').as_posix()}", + ) + + def run(command: list[str], *, env: dict[str, str], cwd: str) -> int: + process = subprocess.Popen( + command, + cwd=cwd, + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + try: + deadline = time.monotonic() + 20 + with httpx.Client(timeout=1, trust_env=False) as client: + while time.monotonic() < deadline: + if process.poll() is not None: + stdout, stderr = process.communicate() + raise AssertionError( + f"runtime child exited {process.returncode}: {stdout} {stderr}" + ) + try: + openapi = client.get(f"http://127.0.0.1:{port}/openapi.json") + if openapi.status_code != 200: + time.sleep(0.05) + continue + assert openapi.json()["info"]["title"] == ( + "Trusted preloaded settings" + ) + assistant = client.post( + f"http://127.0.0.1:{port}/assistants", + json={"name": "review", "graph_id": "review_graph"}, + ) + assert assistant.status_code == 200, assistant.text + assert assistant.json()["graph_id"] == "review_graph" + return 0 + except httpx.TransportError: + time.sleep(0.05) + raise AssertionError("runtime child did not become ready") + finally: + _stop_test_process(process) + + assert ( + main( + [ + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "127.0.0.1", + "--port", + str(port), + ], + runner=run, + cwd=hostile, + ) + == 0 + ) + assert not marker.exists() + + @pytest.mark.parametrize( "arguments", [ diff --git a/tests/unit/test_ci_workflow.py b/tests/unit/test_ci_workflow.py index 0ee5ec0..f6cc3ed 100644 --- a/tests/unit/test_ci_workflow.py +++ b/tests/unit/test_ci_workflow.py @@ -1,6 +1,8 @@ import re from pathlib import Path +import yaml + def test_cli_compatibility_step_runs_sqlite_runtime_regressions_on_every_os() -> None: workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") @@ -23,3 +25,87 @@ def test_cli_compatibility_step_runs_sqlite_runtime_regressions_on_every_os() -> "tests/integration/test_metadata_db_config.py", } assert required_tests <= command_tokens + + +def _job(workflow: str, name: str) -> str: + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(?P.*?)(?=^ \S|\Z)", + workflow, + ) + assert match is not None + return match.group("body") + + +def test_cli_docker_runtime_has_independent_current_and_floor_compose_legs() -> None: + workflow_path = Path(".github/workflows/ci.yml") + workflow = workflow_path.read_text(encoding="utf-8") + parsed = yaml.safe_load(workflow) + job = parsed["jobs"]["cli-docker-runtime"] + + assert job["strategy"]["fail-fast"] is False + assert job["strategy"]["matrix"]["compose-version"] == [ + "runner-current", + "v2.24.0", + ] + assert "${{ matrix.compose-version }}" in job["name"] + + steps = job["steps"] + assert any( + step.get("run") == "uv run python scripts/test_container_env_boundary.py" + for step in steps + ) + assert any(step.get("run") == "make test-cli-docker" for step in steps) + + +def test_docker_marked_tests_run_only_in_the_dedicated_docker_matrix() -> None: + parsed = yaml.safe_load( + Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + ) + + fast_steps = parsed["jobs"]["fast-tests"]["steps"] + fast_commands = tuple(step.get("run", "") for step in fast_steps) + assert any("-m 'not docker'" in command for command in fast_commands) + + docker_steps = parsed["jobs"]["cli-docker-runtime"]["steps"] + assert any( + step.get("run") + == "uv run pytest tests/unit/test_docker_runtime.py -m docker -q" + for step in docker_steps + ) + + +def test_compose_floor_action_is_commit_pinned_and_floor_only() -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + body = _job(workflow, "cli-docker-runtime") + references = re.findall(r"docker/setup-compose-action@([^\s#]+)", body) + + assert len(references) == 1 + assert re.fullmatch(r"[0-9a-f]{40}", references[0]) + assert "if: matrix.compose-version == 'v2.24.0'" in body + assert re.search(r"(?m)^\s+version: v2\.24\.0$", body) + + +def test_cli_compatibility_runs_platform_container_selection_and_artifact_tests() -> ( + None +): + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + body = _job(workflow, "cli-compatibility") + + assert "tests/unit/test_container_policy.py" in body + assert "tests/unit/test_secure_temp.py" in body + + +def test_cli_compatibility_uses_the_executable_bundle_smoke_for_dockerfile() -> None: + workflow = Path(".github/workflows/ci.yml").read_text(encoding="utf-8") + body = _job(workflow, "cli-compatibility") + step = re.search( + r"(?ms)^ - name: Dockerfile command renders a runnable config\n" + r"(?P.*?)(?=^ - name:|\Z)", + body, + ) + assert step is not None + + assert step.group("body").strip() == ( + "run: uv run python scripts/test_cli_config_autodiscovery.py " + "--config examples/external_graph/manifest.json" + ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 519bf84..2eb28f8 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,18 +1,232 @@ from __future__ import annotations import argparse +import hashlib import importlib import io +import json +import os +import shutil import signal +import subprocess +import sys +import tarfile import tomllib -from dataclasses import dataclass +import types +import zipfile +from dataclasses import dataclass, field from pathlib import Path +from unittest.mock import Mock, patch import pytest from pydantic import ValidationError from agentseek_api import __version__ +from agentseek_api.docker_runtime import ( + BuildImageInvocation, + ControlQueryInvocation, + DockerRunInvocation, + ProcessInvocation, + ProcessResult, + ProcessTransport, +) from agentseek_api.services.langgraph_service import LangGraphService +from tests.container_plan_helpers import write_sanitized_manifest + + +DOCKER_EXECUTABLE = str( + Path(sys.executable).parent / ("docker.exe" if os.name == "nt" else "docker") +) + + +@pytest.fixture(autouse=True) +def _fixed_unit_docker_executable(monkeypatch: pytest.MonkeyPatch) -> None: + original_which = shutil.which + + def fixed_which(executable: str, *, path: str | None = None) -> str | None: + if executable == "docker": + return DOCKER_EXECUTABLE + return original_which(executable, path=path) + + monkeypatch.setattr(shutil, "which", fixed_which) + + +def test_preloaded_mode_never_reads_config_environment_sources( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import resolve_runtime_for_mode + from agentseek_api.environment import EnvironmentMode + + manifest_root = tmp_path / "image" + manifest_root.mkdir() + manifest = write_sanitized_manifest(manifest_root) + (tmp_path / "agentseek.json").write_text( + '{"graphs":{"decoy":"decoy.py:graph"},"env":".env"}', + encoding="utf-8", + ) + (tmp_path / "langgraph.json").write_text( + '{"graphs":{"legacy":"legacy.py:graph"}}', encoding="utf-8" + ) + (tmp_path / ".env").write_text("OPENAI_API_KEY=decoy", encoding="utf-8") + monkeypatch.setattr( + "agentseek_api.environment.parse_dotenv_document", + Mock(side_effect=AssertionError("dotenv reopened")), + ) + + result = resolve_runtime_for_mode( + mode=EnvironmentMode.PRELOADED_V1, + config_path=None, + env_file=None, + inherited={ + "AGENTSEEK_GRAPHS": str(manifest), + "OPENAI_API_KEY": "runtime-only", + }, + cwd=tmp_path, + ) + + assert result.values["OPENAI_API_KEY"] == "runtime-only" + assert result.values["AGENTSEEK_GRAPHS"] == str(manifest) + + +@pytest.mark.parametrize("command", ["dev", "serve", "worker", "scheduler"]) +def test_runtime_commands_accept_explicit_preloaded_environment_mode( + command: str, +) -> None: + from agentseek_api.cli import create_parser + from agentseek_api.environment import EnvironmentMode + + args = create_parser().parse_args([command, "--environment-mode", "preloaded-v1"]) + assert args.environment_mode is EnvironmentMode.PRELOADED_V1 + + +@pytest.mark.parametrize( + ("manifest_value", "config", "env_file", "match"), + [ + (None, None, None, "AGENTSEEK_GRAPHS"), + ("relative.json", None, None, "absolute"), + ("absolute", "other.json", None, "config"), + ("absolute", None, ".env", "env-file"), + ], +) +def test_preloaded_mode_rejects_ambiguous_sources_before_loading( + tmp_path: Path, + manifest_value: str | None, + config: str | None, + env_file: str | None, + match: str, +) -> None: + from agentseek_api.cli import CliError, resolve_runtime_for_mode + from agentseek_api.environment import EnvironmentMode + + inherited = {} + if manifest_value is not None: + inherited["AGENTSEEK_GRAPHS"] = ( + str(tmp_path / "manifest.v1.json") + if manifest_value == "absolute" + else manifest_value + ) + config_path = str(tmp_path / config) if config is not None else None + + with pytest.raises(CliError, match=match): + resolve_runtime_for_mode( + mode=EnvironmentMode.PRELOADED_V1, + config_path=config_path, + env_file=env_file, + inherited=inherited, + cwd=tmp_path, + ) + + +def test_preloaded_mode_rejects_installed_distribution_mismatch_value_free( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api.cli import CliError + from agentseek_api.environment import EnvironmentMode + + manifest = write_sanitized_manifest(tmp_path) + monkeypatch.setattr(cli_module.importlib.metadata, "version", lambda _name: "9.9.9") + + with pytest.raises(CliError) as caught: + cli_module.resolve_runtime_for_mode( + mode=EnvironmentMode.PRELOADED_V1, + config_path=None, + env_file=None, + inherited={"AGENTSEEK_GRAPHS": str(manifest)}, + cwd=tmp_path, + ) + assert "9.9.9" not in str(caught.value) + + +def test_preloaded_public_child_ignores_hostile_cwd_and_dev_forces_studio_auth( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + manifest = write_sanitized_manifest(tmp_path) + (tmp_path / "agentseek.json").write_text( + '{"graphs":{"decoy":"decoy.py:graph"},"env":".env"}', encoding="utf-8" + ) + (tmp_path / "langgraph.json").write_text( + '{"graphs":{"legacy":"legacy.py:graph"}}', encoding="utf-8" + ) + (tmp_path / ".env").write_text("OPENAI_API_KEY=decoy", encoding="utf-8") + monkeypatch.setenv("AGENTSEEK_GRAPHS", str(manifest)) + monkeypatch.setenv("OPENAI_API_KEY", "runtime-only") + monkeypatch.setenv("STUDIO_AUTH_LOCAL_DEV", "false") + monkeypatch.setattr( + "agentseek_api.environment.parse_dotenv_document", + Mock(side_effect=AssertionError("dotenv reopened")), + ) + original_read_text = Path.read_text + opened: list[Path] = [] + + def tracked_read_text(path: Path, *args: object, **kwargs: object) -> str: + config_sources = { + manifest, + tmp_path / "agentseek.json", + tmp_path / "langgraph.json", + tmp_path / ".env", + } + if path in config_sources: + opened.append(path) + if path in config_sources - {manifest}: + raise AssertionError("hostile cwd source reopened") + return original_read_text(path, *args, **kwargs) + + monkeypatch.setattr(Path, "read_text", tracked_read_text) + capture = _RunCapture() + + assert ( + main( + ["dev", "--environment-mode", "preloaded-v1", "--no-browser"], + runner=capture, + cwd=tmp_path, + ) + == 0 + ) + assert capture.env is not None + assert capture.env["AGENTSEEK_GRAPHS"] == str(manifest) + assert capture.env["OPENAI_API_KEY"] == "runtime-only" + assert capture.env["STUDIO_AUTH_LOCAL_DEV"] == "true" + assert opened == [manifest] + + +def test_preloaded_mode_accepts_explicit_config_only_when_it_is_same_manifest( + tmp_path: Path, +) -> None: + from agentseek_api.cli import resolve_runtime_for_mode + from agentseek_api.environment import EnvironmentMode + + manifest = write_sanitized_manifest(tmp_path) + result = resolve_runtime_for_mode( + mode=EnvironmentMode.PRELOADED_V1, + config_path=str(manifest), + env_file=None, + inherited={"AGENTSEEK_GRAPHS": str(manifest)}, + cwd=tmp_path, + ) + assert result.values["AGENTSEEK_GRAPHS"] == str(manifest) def test_python_dotenv_dependency_is_available() -> None: @@ -21,6 +235,78 @@ def test_python_dotenv_dependency_is_available() -> None: assert callable(dotenv_values) +def test_up_parser_collects_explicit_container_and_compose_names() -> None: + from agentseek_api.cli import create_parser + + args = create_parser().parse_args( + [ + "up", + "--image", + "agentseek:test", + "--pass-env", + "TOKEN", + "--pass-env", + "OTHER", + "--compose-pass-env", + "TOKEN", + ] + ) + + assert args.pass_env == ["TOKEN", "OTHER"] + assert args.compose_pass_env == ["TOKEN"] + + +def test_up_pass_env_reaches_only_the_live_docker_carrier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + config_path = _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("ARBITRARY_HOST_SECRET", "must-not-reach-docker-run") + capture = _ProcessCapture() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--pass-env", + "ARBITRARY_HOST_SECRET", + ], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + run = next(call for call in capture.calls if isinstance(call, DockerRunInvocation)) + assert run.environment["ARBITRARY_HOST_SECRET"] == "must-not-reach-docker-run" + assert "must-not-reach-docker-run" not in " ".join(run.argv) + + +def test_container_selection_combines_normalized_config_and_cli_compose_names( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_container_selection + + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"compose_env":[" TOKEN ","FROM_CONFIG"]}', + encoding="utf-8", + ) + + selection = build_container_selection( + config_path=config_path, + pass_env=[" PASS_ENV "], + compose_pass_env=[" TOKEN ", "FROM_CLI"], + ) + + assert selection.pass_env == frozenset({"PASS_ENV"}) + assert selection.compose_env == frozenset({"TOKEN", "FROM_CONFIG", "FROM_CLI"}) + + @dataclass class _RunCapture: calls: list[list[str]] | None = None @@ -28,18 +314,212 @@ class _RunCapture: env: dict[str, str] | None = None cwd: str | None = None - def __call__(self, command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: + def __call__( + self, command: list[str], *, env: dict[str, str], cwd: str | None = None + ) -> int: if self.calls is None: self.calls = [] self.calls.append(command) self.command = command self.env = env self.cwd = cwd - if command[:3] == ["docker", "container", "inspect"]: + if command[:3] == [DOCKER_EXECUTABLE, "container", "inspect"]: return 1 return 0 +@dataclass +class _ProcessCapture: + calls: list[ProcessInvocation] | None = None + container_exists: bool = False + return_codes: dict[tuple[str, ...], int] | None = None + image_config: tuple[object, object, object] | None = None + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if self.calls is None: + self.calls = [] + self.calls.append(invocation) + docker_args = invocation.argv[1:] + if docker_args == ("compose", "version", "--short"): + return ProcessResult(returncode=0, stdout=b"2.40.3\n") + if docker_args == ("buildx", "version"): + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", + ) + if docker_args[:2] == ("image", "inspect"): + selected = self.image_config or ( + { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", + }, + [], + [], + ) + return ProcessResult(returncode=0, stdout=json.dumps(selected).encode()) + return_code = 0 + if docker_args[:2] == ("container", "inspect"): + return_code = 0 if self.container_exists else 1 + if self.return_codes is not None: + return_code = self.return_codes.get(invocation.argv, return_code) + return ProcessResult(returncode=return_code) + + +@dataclass(frozen=True) +class BoundaryCall: + argv: tuple[str, ...] + invocation_type: type[ProcessInvocation] + environment_names: frozenset[str] + cwd: Path + stdin_sha256: str | None + stdin_size: int + is_side_effecting: bool + _environment_items: tuple[tuple[str, str], ...] = field(repr=False) + + +@dataclass +class BoundaryRunner(ProcessTransport): + failure_case: str | None = None + calls: list[BoundaryCall] = field(default_factory=list) + + @staticmethod + def _is_read_only(invocation: ProcessInvocation) -> bool: + if not isinstance(invocation, ControlQueryInvocation): + return False + argv = tuple(invocation.argv[1:]) + if argv[:2] == ("image", "inspect"): + return True + if argv == ("compose", "version", "--short"): + return True + if argv in { + ("buildx", "version"), + ("buildx", "inspect"), + }: + return True + return argv[:1] == ("compose",) and "config" in argv[1:] + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + argv = tuple(invocation.argv) + docker_args = argv[1:] + environment_items = tuple(sorted(invocation.environment.items())) + stdin_bytes = invocation.stdin_bytes + self.calls.append( + BoundaryCall( + argv=argv, + invocation_type=type(invocation), + environment_names=frozenset(invocation.environment), + cwd=Path(invocation.cwd), + stdin_sha256=( + None + if stdin_bytes is None + else hashlib.sha256(stdin_bytes).hexdigest() + ), + stdin_size=0 if stdin_bytes is None else len(stdin_bytes), + is_side_effecting=not self._is_read_only(invocation), + _environment_items=environment_items, + ) + ) + if not isinstance(invocation, ControlQueryInvocation): + return ProcessResult(returncode=0) + if docker_args == ("compose", "version", "--short"): + version = ( + b"2.0.0\n" + if self.failure_case == "unsupported_compose_version" + else b"2.40.3\n" + ) + return ProcessResult(returncode=0, stdout=version) + if docker_args == ("buildx", "version"): + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", + ) + if docker_args == ("buildx", "inspect"): + return ProcessResult( + returncode=1 if self.failure_case == "unavailable_buildkit" else 0 + ) + if docker_args[:2] == ("image", "inspect"): + labels = { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", + } + if self.failure_case == "missing_contract_label": + labels.pop("org.agentseek.environment-contract") + if self.failure_case == "missing_manifest_label": + labels.pop("org.agentseek.runtime-manifest") + entrypoint: list[str] = [] + if self.failure_case == "incompatible_entrypoint": + entrypoint = ["/bin/sh"] + return ProcessResult( + returncode=0, + stdout=json.dumps([labels, entrypoint, []]).encode("utf-8"), + ) + if docker_args[:2] == ("container", "inspect"): + return ProcessResult(returncode=1) + return ProcessResult(returncode=0) + + +@pytest.mark.parametrize( + "argv", + [ + (DOCKER_EXECUTABLE, "compose", "version", "--short"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "fixture", + "agentseek:test", + ), + (DOCKER_EXECUTABLE, "compose", "-f", "compose.yaml", "config"), + ], +) +def test_boundary_runner_requires_control_query_type_for_read_only_call( + argv: tuple[str, ...], tmp_path: Path +) -> None: + runner = BoundaryRunner() + + result = runner( + ProcessInvocation(argv=argv, environment={}, cwd=tmp_path, stdin_bytes=None) + ) + + assert result.stdout == b"" + assert len(runner.calls) == 1 + assert runner.calls[0].is_side_effecting is True + + +def test_boundary_runner_treats_container_inspect_as_workload_boundary( + tmp_path: Path, +) -> None: + runner = BoundaryRunner() + + runner( + ControlQueryInvocation( + argv=(DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), + environment={}, + cwd=tmp_path, + stdin_bytes=None, + ) + ) + + assert len(runner.calls) == 1 + assert runner.calls[0].is_side_effecting is True + + +def _captured_image_build(capture: _ProcessCapture) -> BuildImageInvocation: + assert capture.calls is not None + return next( + invocation + for invocation in capture.calls + if isinstance(invocation, BuildImageInvocation) + ) + + class _EncodingTextStream: def __init__(self, encoding: str) -> None: self.encoding = encoding @@ -325,14 +805,10 @@ def start(command, *, env, cwd): assert child.close_calls == 1 -def _docker_env_from_run_command(command: list[str]) -> dict[str, str]: - values: dict[str, str] = {} - for index, token in enumerate(command): - if token != "-e": - continue - key, value = command[index + 1].split("=", maxsplit=1) - values[key] = value - return values +def _application_environment(capture: _ProcessCapture) -> dict[str, str]: + assert capture.calls is not None + run = next(call for call in capture.calls if isinstance(call, DockerRunInvocation)) + return {name: run.environment[name] for name in run.application_names} def _write_basic_langgraph_config(root: Path) -> Path: @@ -375,113 +851,587 @@ def _write_basic_manifest_config(root: Path) -> Path: return manifest_path -def test_onboard_banner_preserves_unicode_for_stringio(tmp_path: Path) -> None: +def invoke_failure_case( + command_path: str, + case: str, + *, + tmp_path: Path, + runner: BoundaryRunner, +) -> tuple[int, str, str]: from agentseek_api.cli import main - _write_basic_langgraph_config(tmp_path) - stdout = io.StringIO() - - exit_code = main( - ["serve"], - runner=_RunCapture(), - stdout=stdout, - cwd=tmp_path, - ) + config_path = _write_basic_langgraph_config(tmp_path) + payload = json.loads(config_path.read_text(encoding="utf-8")) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services: {}\n", encoding="utf-8") + env_path = tmp_path / "selected.env" + save_path = tmp_path / "standalone-bundle" + runner.failure_case = case + + if command_path == "build": + arguments = ["build", "--config", str(config_path), "-t", "agentseek:test"] + elif command_path == "dockerfile": + arguments = [ + "dockerfile", + "--config", + str(config_path), + str(save_path), + ] + elif command_path == "up-image": + arguments = [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + ] + else: + arguments = ["up", "--config", str(config_path)] + + if command_path == "up-compose": + arguments.extend(("--docker-compose", str(compose_path))) + + if case in {"missing_dotenv", "invalid_utf8_dotenv", "malformed_dotenv"}: + if case == "invalid_utf8_dotenv": + env_path.write_bytes(b"NAME=value\n\xff") + elif case == "malformed_dotenv": + env_path.write_text('NAME=value\nBROKEN "value"\n', encoding="utf-8") + arguments.extend(("--env-file", str(env_path))) + elif case == "unresolved_selected_reference": + env_path.write_text("SELECTED=${MISSING}\n", encoding="utf-8") + arguments.extend(("--env-file", str(env_path), "--pass-env", "SELECTED")) + elif case == "invalid_pass_env": + arguments.extend(("--pass-env", "not-valid-name")) + elif case == "missing_compose_selection": + payload["compose_env"] = ["MISSING"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + arguments.extend(("--docker-compose", str(compose_path))) + elif case == "docker_control_collision": + arguments.extend(("--pass-env", "DOCKER_HOST")) + elif case == "compose_control_collision": + payload["env"] = {"DOCKER_HOST": "application-control"} + payload["compose_env"] = ["DOCKER_HOST"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + arguments.extend(("--docker-compose", str(compose_path))) + elif case == "nul_value": + payload["env"] = {"AGENTSEEK_MODEL": "contains\x00nul"} + config_path.write_text(json.dumps(payload), encoding="utf-8") + elif case == "escaping_build_include": + payload["build_include"] = ["../outside.txt"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + elif case == "symlink_build_include": + target = tmp_path / "outside.txt" + target.write_text("outside", encoding="utf-8") + (tmp_path / "included-link").symlink_to(target) + payload["build_include"] = ["included-link"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + elif case == "special_file_build_include": + if not hasattr(os, "mkfifo"): + pytest.skip("requires a platform FIFO primitive") + fifo = tmp_path / "included-pipe" + os.mkfifo(fifo) + payload["build_include"] = ["included-pipe"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + elif case == "credential_dependency": + payload["dependencies"] = [ + "fixture @ https://user:dependency-secret@packages.example/fixture.whl" + ] + config_path.write_text(json.dumps(payload), encoding="utf-8") + elif case == "unsafe_temp": + unsafe_target = tmp_path / "existing-output" + unsafe_target.mkdir(mode=0o755) + save_path.symlink_to(unsafe_target, target_is_directory=True) - assert exit_code == 0 - assert stdout.getvalue() == ( - "\n" - " Welcome to\n" - "\n" - "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" - "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" - "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" - "\n" - f" AgentSeek v{__version__}\n" - "\n" - ) + stdout = io.StringIO() + stderr = io.StringIO() + hostile_ambient = { + "OPENAI_API_KEY": "boundary-canary", + "DOCKER_HOST": "unix:///hostile/docker.sock", + } + with patch.dict(os.environ, hostile_ambient, clear=False): + exit_code = main( + arguments, + process_transport=runner, + cwd=tmp_path, + stdout=stdout, + stderr=stderr, + ) + return exit_code, stdout.getvalue(), stderr.getvalue() -def test_onboard_banner_uses_one_write_and_flush_for_utf8_stream( +@pytest.mark.parametrize( + ("command_path", "case"), + [ + ("build", "missing_dotenv"), + ("build", "invalid_utf8_dotenv"), + ("build", "malformed_dotenv"), + ("dockerfile", "missing_dotenv"), + ("dockerfile", "invalid_utf8_dotenv"), + ("dockerfile", "malformed_dotenv"), + ("up", "malformed_dotenv"), + ("up", "unresolved_selected_reference"), + ("up", "invalid_pass_env"), + ("up", "missing_compose_selection"), + ("up-compose", "unsupported_compose_version"), + ("up", "docker_control_collision"), + ("up-compose", "compose_control_collision"), + ("up", "nul_value"), + ("build", "escaping_build_include"), + ("dockerfile", "symlink_build_include"), + ("up", "special_file_build_include"), + ("build", "credential_dependency"), + ("build", "unavailable_buildkit"), + ("dockerfile", "unsafe_temp"), + ("up-image", "missing_contract_label"), + ("up-image", "missing_manifest_label"), + ("up-image", "incompatible_entrypoint"), + ], +) +def test_container_plan_failure_starts_no_workload( + command_path: str, + case: str, tmp_path: Path, ) -> None: + runner = BoundaryRunner() + exit_code, stdout, stderr = invoke_failure_case( + command_path, + case, + tmp_path=tmp_path, + runner=runner, + ) + assert exit_code == 2 + assert all(not call.is_side_effecting for call in runner.calls) + if case in { + "missing_dotenv", + "invalid_utf8_dotenv", + "malformed_dotenv", + "unresolved_selected_reference", + "invalid_pass_env", + "missing_compose_selection", + "docker_control_collision", + "compose_control_collision", + "nul_value", + "escaping_build_include", + "symlink_build_include", + "special_file_build_include", + "credential_dependency", + "unsafe_temp", + }: + assert runner.calls == [] + expected_control_calls = { + "unsupported_compose_version": [ + (DOCKER_EXECUTABLE, "compose", "version", "--short") + ], + "unavailable_buildkit": [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ], + "missing_contract_label": [ + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + ], + "missing_manifest_label": [ + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + ], + "incompatible_entrypoint": [ + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + ], + } + if case in expected_control_calls: + assert len(runner.calls) == len(expected_control_calls[case]) + assert all( + getattr(call, "invocation_type", None) is ControlQueryInvocation + for call in runner.calls + ) + assert [call.argv for call in runner.calls] == expected_control_calls[case] + assert "boundary-canary" not in repr(runner.calls) + assert "boundary-canary" not in stdout + assert "boundary-canary" not in stderr + + +def test_generated_up_builds_then_inspects_before_any_workload(tmp_path: Path) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - stdout = _EncodingTextStream("utf-8") + runner = BoundaryRunner() exit_code = main( - ["serve"], - runner=_RunCapture(), - stdout=stdout, + ["up", "--recreate"], + process_transport=runner, cwd=tmp_path, ) assert exit_code == 0 - assert stdout.writes == [ - "\n" - " Welcome to\n" - "\n" - "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" - "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" - "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" - "\n" - f" AgentSeek v{__version__}\n" - "\n" + argv = [call.argv for call in runner.calls] + assert argv[:5] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ( + DOCKER_EXECUTABLE, + "buildx", + "build", + "--load", + "--file", + "Dockerfile", + "--pull", + "--tag", + "agentseek-up:8123", + "-", + ), + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek-up:8123", + ), + (DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123"), ] - assert stdout.flush_count == 1 + assert len(argv) == 6 + assert argv[5][:9] == ( + DOCKER_EXECUTABLE, + "run", + "--detach", + "--name", + "agentseek-up-8123", + "--add-host", + "host.docker.internal:host-gateway", + "-p", + "8123:2024", + ) + assert argv[5][-8:] == ( + "agentseek-api", + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ) -@pytest.mark.parametrize("role", ["dev", "serve"]) -def test_onboard_banner_falls_back_before_writing_to_cp1252_stream( +def test_up_resolves_docker_once_from_selected_path_and_freezes_absolute_argv( tmp_path: Path, - role: str, + monkeypatch: pytest.MonkeyPatch, ) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - stdout = _EncodingTextStream("cp1252") - arguments = [role] - if role == "dev": - arguments.append("--no-reload") + selected_path = ( + r"C:\selected\docker\bin" if os.name == "nt" else "/selected/docker/bin" + ) + monkeypatch.setenv("PATH", selected_path) + calls: list[tuple[str, str | None]] = [] + + def fake_which(executable: str, *, path: str | None = None) -> str: + calls.append((executable, path)) + return DOCKER_EXECUTABLE + + monkeypatch.setattr(shutil, "which", fake_which) + runner = BoundaryRunner() exit_code = main( - arguments, - runner=_RunCapture(), - stdout=stdout, + ["up", "--image", "agentseek:test", "--recreate"], + process_transport=runner, cwd=tmp_path, ) assert exit_code == 0 - assert stdout.writes == [ - "\n" - " Welcome to\n" - "\n" - "========================\n" - f" AgentSeek v{__version__}\n" - "========================\n" - "\n" - ] - assert stdout.flush_count == 1 + assert calls == [("docker", selected_path)] + assert runner.calls + assert {invocation.argv[0] for invocation in runner.calls} == {DOCKER_EXECUTABLE} -def test_onboard_banner_uses_ascii_fallback_for_unknown_named_encoding( +def test_generated_up_materializes_compose_artifact_before_build_and_cleans_it( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: + from agentseek_api import secure_temp from agentseek_api.cli import main - _write_basic_langgraph_config(tmp_path) - stdout = _RecordingTextStream("unknown-codec-canary") + private_root = tmp_path / "private-root" + private_root.mkdir(mode=0o700) + monkeypatch.setattr(secure_temp.tempfile, "gettempdir", lambda: str(private_root)) + config_path = _write_basic_langgraph_config(tmp_path) + payload = json.loads(config_path.read_text(encoding="utf-8")) + payload["env"] = {"TOKEN": "literal"} + payload["compose_env"] = ["TOKEN", "AGENTSEEK_GRAPHS"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services: {}\n", encoding="utf-8") + class ArtifactBoundaryRunner(BoundaryRunner): + artifact_exists_during_build = False + artifact_contents_during_build: bytes | None = None + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if isinstance(invocation, BuildImageInvocation): + artifacts = [ + child + for child in private_root.iterdir() + if child.name.startswith("agentseek-compose-") + ] + self.artifact_exists_during_build = bool(artifacts) + artifact = artifacts[0] + content_files = ( + [artifact] + if artifact.is_file() + else [child for child in artifact.rglob("*") if child.is_file()] + ) + assert len(content_files) == 1 + self.artifact_contents_during_build = content_files[0].read_bytes() + return super().__call__(invocation) + + runner = ArtifactBoundaryRunner() exit_code = main( - ["serve"], - runner=_RunCapture(), - stdout=stdout, + [ + "up", + "--docker-compose", + str(compose_path), + "--recreate", + ], + process_transport=runner, cwd=tmp_path, ) assert exit_code == 0 - assert stdout.writes == [ + assert runner.artifact_exists_during_build is True + assert runner.artifact_contents_during_build == ( + b'AGENTSEEK_GRAPHS="/opt/agentseek/manifest.v1.json"\nTOKEN="literal"\n' + ) + assert list(private_root.iterdir()) == [] + + +@pytest.mark.parametrize("outcome", ["success", "nonzero", "exception", "interrupt"]) +def test_generated_up_exit_stack_cleans_all_artifacts_on_every_exit( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + outcome: str, +) -> None: + from agentseek_api import secure_temp + from agentseek_api.cli import main + + private_root = tmp_path / "private-root" + private_root.mkdir(mode=0o700) + monkeypatch.setattr(secure_temp.tempfile, "gettempdir", lambda: str(private_root)) + config_path = _write_basic_langgraph_config(tmp_path) + payload = json.loads(config_path.read_text(encoding="utf-8")) + payload["env"] = {"TOKEN": "literal"} + payload["compose_env"] = ["TOKEN"] + config_path.write_text(json.dumps(payload), encoding="utf-8") + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services: {}\n", encoding="utf-8") + + class CleanupBoundaryRunner(BoundaryRunner): + artifacts_at_run: tuple[str, ...] = () + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + result = super().__call__(invocation) + if isinstance(invocation, DockerRunInvocation): + self.artifacts_at_run = tuple( + sorted(child.name for child in private_root.iterdir()) + ) + if outcome == "nonzero": + return ProcessResult(returncode=23) + if outcome == "exception": + raise RuntimeError("transport failed") + if outcome == "interrupt": + raise KeyboardInterrupt + return result + + runner = CleanupBoundaryRunner() + arguments = [ + "up", + "--docker-compose", + str(compose_path), + "--recreate", + ] + if outcome == "exception": + with pytest.raises(RuntimeError, match="transport failed"): + main(arguments, process_transport=runner, cwd=tmp_path) + elif outcome == "interrupt": + with pytest.raises(KeyboardInterrupt): + main(arguments, process_transport=runner, cwd=tmp_path) + else: + assert main(arguments, process_transport=runner, cwd=tmp_path) == ( + 23 if outcome == "nonzero" else 0 + ) + + assert len(runner.artifacts_at_run) == 2 + assert any(name.startswith("agentseek-build-") for name in runner.artifacts_at_run) + assert any( + name.startswith("agentseek-compose-") for name in runner.artifacts_at_run + ) + assert list(private_root.iterdir()) == [] + + +def test_up_image_branch_never_plans_a_build_bundle(tmp_path: Path) -> None: + from agentseek_api.cli import main + + config_path = _write_basic_langgraph_config(tmp_path) + payload = json.loads(config_path.read_text(encoding="utf-8")) + payload["dependencies"] = [ + "fixture @ https://user:credential@packages.example/fixture.whl" + ] + config_path.write_text(json.dumps(payload), encoding="utf-8") + runner = BoundaryRunner() + + exit_code = main( + ["up", "--image", "agentseek:test", "--recreate"], + process_transport=runner, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert all("buildx" not in call.argv for call in runner.calls) + assert not any(call.stdin_size for call in runner.calls) + + +def test_dockerfile_branch_never_invokes_docker_or_compose(tmp_path: Path) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + runner = BoundaryRunner() + + exit_code = main( + ["dockerfile", str(tmp_path / "standalone")], + process_transport=runner, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert runner.calls == [] + + +def test_onboard_banner_preserves_unicode_for_stringio(tmp_path: Path) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = io.StringIO() + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.getvalue() == ( + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ) + + +def test_onboard_banner_uses_one_write_and_flush_for_utf8_stream( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("utf-8") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ] + assert stdout.flush_count == 1 + + +@pytest.mark.parametrize("role", ["dev", "serve"]) +def test_onboard_banner_falls_back_before_writing_to_cp1252_stream( + tmp_path: Path, + role: str, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("cp1252") + arguments = [role] + if role == "dev": + arguments.append("--no-reload") + + exit_code = main( + arguments, + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "========================\n" + f" AgentSeek v{__version__}\n" + "========================\n" + "\n" + ] + assert stdout.flush_count == 1 + + +def test_onboard_banner_uses_ascii_fallback_for_unknown_named_encoding( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _RecordingTextStream("unknown-codec-canary") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ "\n" " Welcome to\n" "\n" @@ -510,7 +1460,9 @@ def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) from agentseek_api.cli import main config_path = tmp_path / "agentseek.json" - config_path.write_text('{"graphs":{"agentseek":"chat.graph:graph"}}', encoding="utf-8") + config_path.write_text( + '{"graphs":{"agentseek":"chat.graph:graph"}}', encoding="utf-8" + ) _write_basic_langgraph_config(tmp_path) capture = _RunCapture() @@ -532,13 +1484,17 @@ def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_serve_command_falls_back_to_langgraph_json_and_runs_graph(tmp_path: Path) -> None: +def test_serve_command_falls_back_to_langgraph_json_and_runs_graph( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = _write_basic_langgraph_config(tmp_path) capture = _RunCapture() - exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) + exit_code = main( + ["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path + ) assert exit_code == 0 assert capture.command[1:] == [ @@ -570,7 +1526,9 @@ def test_serve_command_uses_agentseek_graphs_env_for_manifest_named_config( monkeypatch.setenv("AGENTSEEK_GRAPHS", str(config_path.resolve())) capture = _RunCapture() - exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) + exit_code = main( + ["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path + ) assert exit_code == 0 assert capture.command[1:] == [ @@ -594,7 +1552,9 @@ def test_worker_command_uses_runtime_env_and_worker_module(tmp_path: Path) -> No config_path = _write_basic_langgraph_config(tmp_path) capture = _RunCapture() - exit_code = main(["worker", "--config", str(config_path)], runner=capture, cwd=tmp_path) + exit_code = main( + ["worker", "--config", str(config_path)], runner=capture, cwd=tmp_path + ) assert exit_code == 0 assert capture.command is not None @@ -607,13 +1567,17 @@ def test_worker_command_uses_runtime_env_and_worker_module(tmp_path: Path) -> No assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_scheduler_command_uses_runtime_env_and_scheduler_module(tmp_path: Path) -> None: +def test_scheduler_command_uses_runtime_env_and_scheduler_module( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = _write_basic_langgraph_config(tmp_path) capture = _RunCapture() - exit_code = main(["scheduler", "--config", str(config_path)], runner=capture, cwd=tmp_path) + exit_code = main( + ["scheduler", "--config", str(config_path)], runner=capture, cwd=tmp_path + ) assert exit_code == 0 assert capture.command is not None @@ -641,6 +1605,138 @@ def test_settings_validation_formatter_omits_input_values() -> None: assert "invalid-port-canary" not in message +def test_preloaded_runtime_rejects_loaded_agentseek_module_outside_distribution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.runtime_entrypoint import ( + RuntimeBootstrapError, + _require_distribution_owned_runtime, + ) + + hostile = types.ModuleType("agentseek_api.hostile_canary") + hostile.__file__ = str(tmp_path / "agentseek_api" / "hostile_canary.py") + monkeypatch.setitem(sys.modules, hostile.__name__, hostile) + + with pytest.raises(RuntimeBootstrapError): + _require_distribution_owned_runtime() + + +def test_preloaded_runtime_identity_error_is_fixed_and_value_free( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +) -> None: + from agentseek_api import runtime_entrypoint + + def reject_runtime() -> None: + raise runtime_entrypoint.RuntimeBootstrapError("ownership-secret-canary") + + activate = Mock(side_effect=AssertionError("manifest activated after failed trust")) + monkeypatch.setattr( + runtime_entrypoint, "_require_distribution_owned_runtime", reject_runtime + ) + monkeypatch.setattr(runtime_entrypoint, "_activate_preloaded_runtime", activate) + + assert runtime_entrypoint.main(["--preloaded-v1", "worker"]) == 2 + assert capsys.readouterr().err == ( + "The preloaded runtime identity is incompatible.\n" + ) + activate.assert_not_called() + + +def test_preloaded_runtime_bootstrap_orders_trust_manifest_and_settings( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + events: list[str] = [] + monkeypatch.setattr( + runtime_entrypoint, + "_require_distribution_owned_runtime", + lambda: events.append("trust"), + ) + monkeypatch.setattr( + runtime_entrypoint, + "_activate_preloaded_runtime", + lambda: events.append("manifest"), + ) + monkeypatch.setattr( + runtime_entrypoint, + "_require_trusted_target_module", + lambda name: events.append(f"target:{name}"), + raising=False, + ) + monkeypatch.setattr( + runtime_entrypoint.importlib, + "import_module", + lambda name: events.append(f"import:{name}"), + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda name, **_kwargs: events.append(f"run:{name}"), + ) + + assert runtime_entrypoint.main(["--preloaded-v1", "worker"]) == 0 + assert events == [ + "trust", + "target:worker", + "manifest", + "import:agentseek_api.settings", + "trust", + "run:agentseek_api.worker", + ] + + +def test_preloaded_runtime_activates_only_canonical_manifest_roots( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.runtime_entrypoint import _activate_preloaded_runtime + + manifest = write_sanitized_manifest(tmp_path) + document = json.loads(manifest.read_text(encoding="utf-8")) + document["dependencies"] = ["/deps/agent/application"] + manifest.write_text(json.dumps(document), encoding="utf-8") + monkeypatch.setenv("AGENTSEEK_GRAPHS", str(manifest)) + monkeypatch.setattr(sys, "path", ["trusted-site-packages"]) + + _activate_preloaded_runtime() + + assert sys.path == ["trusted-site-packages", "/deps/agent/application"] + + +def test_preloaded_runtime_dependency_cannot_shadow_trusted_uvicorn_target( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api import container_build, runtime_entrypoint + + dependency = tmp_path / "dependency" + package = dependency / "uvicorn" + package.mkdir(parents=True) + marker = tmp_path / "hostile-uvicorn-imported" + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "__main__.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('canary')\nraise SystemExit(29)\n", + encoding="utf-8", + ) + monkeypatch.setenv("AGENTSEEK_GRAPHS", str(tmp_path / "manifest.v1.json")) + monkeypatch.setattr( + container_build, + "load_container_runtime_manifest_v1", + lambda _path: types.SimpleNamespace(dependencies=(str(dependency),)), + ) + monkeypatch.setattr( + runtime_entrypoint, "_require_distribution_owned_runtime", lambda: None + ) + monkeypatch.setattr(sys, "path", list(sys.path)) + for module_name in tuple(sys.modules): + if module_name == "uvicorn" or module_name.startswith("uvicorn."): + monkeypatch.delitem(sys.modules, module_name) + + exit_code = runtime_entrypoint.main(["--preloaded-v1", "uvicorn", "--", "--help"]) + + assert exit_code == 0 + assert marker.exists() is False + + def test_dev_command_accepts_langgraph_cli_flags_and_env_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -718,7 +1814,11 @@ def test_dev_command_loads_config_env_mapping_and_auth_path( ) capture = _RunCapture() - exit_code = main(["dev", "--config", str(config_path), "--no-reload"], runner=capture, cwd=tmp_path) + exit_code = main( + ["dev", "--config", str(config_path), "--no-reload"], + runner=capture, + cwd=tmp_path, + ) assert exit_code == 0 assert capture.env is not None @@ -755,7 +1855,14 @@ def test_dev_command_merges_config_env_file_before_cli_env_file( capture = _RunCapture() exit_code = main( - ["dev", "--config", str(config_path), "--env-file", str(cli_env), "--no-reload"], + [ + "dev", + "--config", + str(config_path), + "--env-file", + str(cli_env), + "--no-reload", + ], runner=capture, cwd=tmp_path, ) @@ -782,7 +1889,14 @@ def test_dev_command_preserves_dotenv_default_and_bare_variable_syntax( capture = _RunCapture() exit_code = main( - ["dev", "--config", str(config_path), "--env-file", str(env_file), "--no-reload"], + [ + "dev", + "--config", + str(config_path), + "--env-file", + str(env_file), + "--no-reload", + ], runner=capture, cwd=tmp_path, ) @@ -802,8 +1916,13 @@ def test_dev_command_rejects_unsupported_langgraph_flags(tmp_path: Path) -> None exit_code = main(["dev", "--tunnel"], cwd=tmp_path, stderr=stderr) assert exit_code == 2 - assert "Unsupported option(s) for 'agentseek-api dev': --tunnel" in stderr.getvalue() - assert "Use 'langgraph dev' for mocked or tunneled local workflows." in stderr.getvalue() + assert ( + "Unsupported option(s) for 'agentseek-api dev': --tunnel" in stderr.getvalue() + ) + assert ( + "Use 'langgraph dev' for mocked or tunneled local workflows." + in stderr.getvalue() + ) def test_dev_command_forces_local_studio_auth_after_inherited_env( @@ -853,17 +1972,25 @@ def test_resolve_dev_urls_use_localhost_display_and_loopback_base_url() -> None: assert urls.api_url == "http://localhost:2024" assert urls.docs_url == "http://localhost:2024/docs" - assert urls.studio_url == "https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024" + assert ( + urls.studio_url + == "https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024" + ) def test_resolve_dev_urls_preserve_explicit_host_and_override_studio_origin() -> None: from agentseek_api.cli import _resolve_dev_urls - urls = _resolve_dev_urls(host="devbox.local", port=3030, studio_url="https://smith.example.com") + urls = _resolve_dev_urls( + host="devbox.local", port=3030, studio_url="https://smith.example.com" + ) assert urls.api_url == "http://devbox.local:3030" assert urls.docs_url == "http://devbox.local:3030/docs" - assert urls.studio_url == "https://smith.example.com/studio/?baseUrl=http://devbox.local:3030" + assert ( + urls.studio_url + == "https://smith.example.com/studio/?baseUrl=http://devbox.local:3030" + ) def test_run_managed_dev_server_prints_banner_and_opens_browser(tmp_path: Path) -> None: @@ -890,7 +2017,14 @@ def terminate(self) -> None: stdout = io.StringIO() exit_code = cli_module._run_managed_dev_server( - command=["uvicorn", "agentseek_api.main:app", "--host", "127.0.0.1", "--port", "2024"], + command=[ + "uvicorn", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ], env={"A": "B"}, cwd=tmp_path, urls=cli_module._resolve_dev_urls(host="127.0.0.1", port=2024, studio_url=None), @@ -906,7 +2040,9 @@ def terminate(self) -> None: assert "API: http://localhost:2024" in output assert "Docs: http://localhost:2024/docs" in output assert "https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024" in output - assert opened == ["https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024"] + assert opened == [ + "https://smith.langchain.com/studio/?baseUrl=http://127.0.0.1:2024" + ] def test_managed_dev_ascii_fallback_normalizes_non_ascii_urls_before_write( @@ -983,7 +2119,14 @@ def terminate(self) -> None: opened: list[str] = [] exit_code = cli_module._run_managed_dev_server( - command=["uvicorn", "agentseek_api.main:app", "--host", "127.0.0.1", "--port", "2024"], + command=[ + "uvicorn", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ], env={}, cwd=tmp_path, urls=cli_module._resolve_dev_urls(host="127.0.0.1", port=2024, studio_url=None), @@ -1004,7 +2147,9 @@ def test_dev_command_rejects_missing_explicit_config(tmp_path: Path) -> None: stderr = io.StringIO() - exit_code = main(["dev", "--config", str(tmp_path / "missing.json")], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dev", "--config", str(tmp_path / "missing.json")], cwd=tmp_path, stderr=stderr + ) assert exit_code == 2 assert "does not exist" in stderr.getvalue() @@ -1025,28 +2170,105 @@ def test_version_reports_cli_and_package_versions() -> None: def test_release_versions_are_consistent() -> None: from agentseek_api import __version__ - project_config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] - lock_packages = tomllib.loads(Path("uv.lock").read_text(encoding="utf-8"))["package"] + project_config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + lock_packages = tomllib.loads(Path("uv.lock").read_text(encoding="utf-8"))[ + "package" + ] root_package = next( package for package in lock_packages - if package["name"] == "agentseek-api" and package.get("source") == {"editable": "."} + if package["name"] == "agentseek-api" + and package.get("source") == {"editable": "."} ) + assert __version__ == "0.3.0" assert project_config["version"] == __version__ assert root_package["version"] == __version__ -def test_package_exposes_library_and_cli_entrypoints() -> None: - project_config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] +def test_container_planning_uses_published_runtime_artifact_by_default( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import agentseek_api.cli as cli + from agentseek_api.container_build import PUBLISHED_RUNTIME_ARTIFACT - assert project_config["name"] == "agentseek-api" - assert project_config["scripts"]["agentseek-api"] == "agentseek_api.cli:main" - assert project_config["optional-dependencies"]["embedded"] - assert any("langchain-oceanbase" in dep for dep in project_config["optional-dependencies"]["embedded"]) + config_path = _write_basic_langgraph_config(tmp_path) + captured: list[object] = [] + original = cli.plan_container_image + def capture(**kwargs: object) -> object: + captured.append(kwargs["runtime_artifact"]) + return original(**kwargs) -def test_cli_module_is_importable_with_embeddable_entrypoints() -> None: + monkeypatch.setattr(cli, "plan_container_image", capture) + output = io.StringIO() + + exit_code = cli.main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stdout=output, + ) + + assert exit_code == 0 + assert captured == [PUBLISHED_RUNTIME_ARTIFACT] + + +def test_internal_candidate_runtime_artifact_is_forwarded_unchanged( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + import agentseek_api.cli as cli + from agentseek_api.container_build import RuntimeArtifactSource, RuntimeArtifactV1 + + config_path = _write_basic_langgraph_config(tmp_path) + wheel = tmp_path / "candidate.whl" + artifact = RuntimeArtifactV1( + distribution="agentseek-api", + extra="embedded", + version="0.3.0", + source=RuntimeArtifactSource.CANDIDATE_WHEEL, + candidate_wheel=wheel, + candidate_sha256="0" * 64, + candidate_identity=(0, 0, 0, 0), + ) + captured: list[object] = [] + + def capture(**kwargs: object) -> object: + captured.append(kwargs["runtime_artifact"]) + raise cli.ContainerBuildError("candidate boundary reached") + + monkeypatch.setattr(cli, "plan_container_image", capture) + error = io.StringIO() + + exit_code = cli.main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=error, + runtime_artifact=artifact, + ) + + assert exit_code == 2 + assert captured == [artifact] + assert error.getvalue() == "candidate boundary reached\n" + + +def test_package_exposes_library_and_cli_entrypoints() -> None: + project_config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))[ + "project" + ] + + assert project_config["name"] == "agentseek-api" + assert project_config["scripts"]["agentseek-api"] == "agentseek_api.cli:main" + assert "packaging>=24.0" in project_config["dependencies"] + assert project_config["optional-dependencies"]["embedded"] + assert any( + "langchain-oceanbase" in dep + for dep in project_config["optional-dependencies"]["embedded"] + ) + + +def test_cli_module_is_importable_with_embeddable_entrypoints() -> None: cli_module = importlib.import_module("agentseek_api.cli") assert cli_module.main is not None @@ -1088,7 +2310,9 @@ def test_embedded_subcommand_errors_use_registered_command_name(tmp_path: Path) exit_code = cli_module.run_namespace(parsed, cwd=tmp_path, stderr=stderr) assert exit_code == 2 - assert "Unsupported option(s) for 'agentseek-api dev': --tunnel" in stderr.getvalue() + assert ( + "Unsupported option(s) for 'agentseek-api dev': --tunnel" in stderr.getvalue() + ) def test_run_namespace_allows_parent_cli_dispatch(tmp_path: Path) -> None: @@ -1097,7 +2321,9 @@ def test_run_namespace_allows_parent_cli_dispatch(tmp_path: Path) -> None: parser = argparse.ArgumentParser(prog="parent") subparsers = parser.add_subparsers(dest="tool", required=True) cli_module.register_subcommands(subparsers, command_name="agentseek") - parsed = parser.parse_args(["agentseek", "serve", "--host", "0.0.0.0", "--port", "3030"]) + parsed = parser.parse_args( + ["agentseek", "serve", "--host", "0.0.0.0", "--port", "3030"] + ) capture = _RunCapture() exit_code = cli_module.run_namespace(parsed, runner=capture, cwd=tmp_path) @@ -1116,26 +2342,35 @@ def test_run_namespace_allows_parent_cli_dispatch(tmp_path: Path) -> None: ] -def test_dockerfile_command_writes_langgraph_compatible_runtime_file(tmp_path: Path) -> None: +def test_dockerfile_command_writes_langgraph_compatible_runtime_file( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - dockerfile_path = tmp_path / "Dockerfile.agentseek" + output_root = tmp_path / "docker-build-bundle" - exit_code = main(["dockerfile", str(dockerfile_path)], cwd=tmp_path) + exit_code = main(["dockerfile", str(output_root)], cwd=tmp_path) assert exit_code == 0 + dockerfile_path = output_root / "context" / "Dockerfile" content = dockerfile_path.read_text(encoding="utf-8") - assert 'FROM python:3.12-slim' in content - assert 'RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*' in content - assert 'WORKDIR /deps/agent' in content - assert 'COPY . /deps/agent' in content - assert 'ENV PYTHONPATH=/deps/agent' in content - assert 'ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json' in content - assert 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' in content + assert (output_root / "inventory.json").is_file() + assert (output_root / "context" / "manifest.v1.json").is_file() + assert (output_root / "context" / "app" / "chat" / "graph.py").is_file() + assert "FROM python:3.12-slim" in content + assert "WORKDIR /deps/agent" in content + assert "COPY app /deps/agent" in content + assert "COPY manifest.v1.json /opt/agentseek/manifest.v1.json" in content + assert "LABEL org.agentseek.environment-contract=preloaded-v1" in content + assert ( + '"serve", "--environment-mode", "preloaded-v1", "--host", "0.0.0.0"' in content + ) -def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: +def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag( + tmp_path: Path, +) -> None: from agentseek_api.cli import main (tmp_path / "agentseek.json").write_text( @@ -1154,12 +2389,15 @@ def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag(tmp_pat exit_code = main(["dockerfile", str(dockerfile_path)], cwd=tmp_path) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") - assert "ENV AGENTSEEK_GRAPHS=/deps/agent/agentseek.json" in content - assert "ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json" not in content + manifest = json.loads( + (dockerfile_path / "context" / "manifest.v1.json").read_text(encoding="utf-8") + ) + assert manifest["graphs"] == {"chat": "chat.graph:graph"} -def test_dockerfile_command_honors_base_image_python_and_custom_lines(tmp_path: Path) -> None: +def test_dockerfile_command_honors_base_image_python_and_custom_lines( + tmp_path: Path, +) -> None: from agentseek_api.cli import main package_dir = tmp_path / "chat" @@ -1175,7 +2413,9 @@ def test_dockerfile_command_honors_base_image_python_and_custom_lines(tmp_path: encoding="utf-8", ) pip_conf = tmp_path / "pip.conf" - pip_conf.write_text("[global]\nindex-url = https://pypi.org/simple\n", encoding="utf-8") + pip_conf.write_text( + "[global]\nindex-url = https://pypi.org/simple\n", encoding="utf-8" + ) config_path = tmp_path / "langgraph.json" config_path.write_text( """ @@ -1196,13 +2436,19 @@ def test_dockerfile_command_honors_base_image_python_and_custom_lines(tmp_path: ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") assert "FROM python:3.13-slim-bookworm" in content assert "RUN echo custom-step" in content - assert "RUN PIP_CONFIG_FILE=/deps/agent/pip.conf pip install --no-cache-dir /deps/agent" in content + assert "--mount=type=secret,id=pip_config,target=/etc/pip.conf" in content + assert '"/deps/agent"' in content + assert "pip.conf" not in "\n".join( + line for line in content.splitlines() if line.startswith("COPY") + ) def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> None: @@ -1222,7 +2468,9 @@ def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> ) requirements_dir = project_dir / "reqs" requirements_dir.mkdir() - (requirements_dir / "requirements.txt").write_text("httpx==0.28.1\n", encoding="utf-8") + (requirements_dir / "requirements.txt").write_text( + "httpx==0.28.1\n", encoding="utf-8" + ) config_path = project_dir / "langgraph.json" config_path.write_text( """ @@ -1237,18 +2485,23 @@ def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") - assert "ENV PYTHONPATH=/deps/agent:/deps/agent/sample_project:/deps/agent/sample_project/local_pkg:/deps/agent/sample_project/reqs" in content - assert "RUN pip install --no-cache-dir /deps/agent/sample_project/local_pkg" in content - assert "RUN pip install --no-cache-dir -r /deps/agent/sample_project/reqs/requirements.txt" in content - assert "RUN pip install --no-cache-dir httpx" in content - assert "RUN pip install --no-cache-dir ." not in content + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert '"/deps/agent/sample_project/local_pkg"' in content + assert ( + '"--requirement", "/deps/agent/sample_project/reqs/requirements.txt"' in content + ) + assert '"httpx"' in content + assert '"."' not in content -def test_dockerfile_command_skips_root_install_when_root_is_not_installable(tmp_path: Path) -> None: +def test_dockerfile_command_skips_root_install_when_root_is_not_installable( + tmp_path: Path, +) -> None: from agentseek_api.cli import main src_dir = tmp_path / "src" @@ -1268,15 +2521,19 @@ def test_dockerfile_command_skips_root_install_when_root_is_not_installable(tmp_ ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") - assert "ENV PYTHONPATH=/deps/agent:/deps/agent/src" in content - assert "RUN pip install --no-cache-dir ." not in content + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert "COPY app /deps/agent" in content + assert '"/deps/agent/src"' not in content -def test_dockerfile_command_uses_manifest_project_root_not_invocation_root(tmp_path: Path) -> None: +def test_dockerfile_command_uses_manifest_project_root_not_invocation_root( + tmp_path: Path, +) -> None: from agentseek_api.cli import main (tmp_path / "pyproject.toml").write_text( @@ -1311,15 +2568,19 @@ def test_dockerfile_command_uses_manifest_project_root_not_invocation_root(tmp_p ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") - assert "RUN pip install --no-cache-dir /deps/agent/apps/agent" in content - assert "RUN pip install --no-cache-dir /deps/agent\n" not in content + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert "COPY app /deps/agent" in content + assert '"/deps/agent/apps/agent"' not in content -def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifest(tmp_path: Path) -> None: +def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifest( + tmp_path: Path, +) -> None: from agentseek_api.cli import main (tmp_path / "pyproject.toml").write_text( @@ -1332,6 +2593,9 @@ def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifes ) manifest_dir = tmp_path / "examples" / "docker_ci_auth" manifest_dir.mkdir(parents=True) + graph_dir = tmp_path / "examples" / "graphs" + graph_dir.mkdir() + (graph_dir / "chat.py").write_text("graph = object()\n", encoding="utf-8") config_path = manifest_dir / "manifest.json" config_path.write_text( """ @@ -1346,19 +2610,26 @@ def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifes ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") - assert "RUN pip install --no-cache-dir /deps/agent" in content - assert "RUN pip install --no-cache-dir /deps/agent/examples/docker_ci_auth" not in content + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert '"/deps/agent"' not in content + manifest = json.loads( + (dockerfile_path / "context" / "manifest.v1.json").read_text(encoding="utf-8") + ) + assert manifest["dependencies"] == ["/deps/agent/examples"] -def test_build_command_plans_docker_build_from_generated_dockerfile(tmp_path: Path) -> None: +def test_build_command_plans_docker_build_from_generated_dockerfile( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1369,28 +2640,568 @@ def test_build_command_plans_docker_build_from_generated_dockerfile(tmp_path: Pa "linux/amd64,linux/arm64", "--no-pull", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 - assert capture.command is not None - assert capture.command[:8] == [ - "docker", + assert capture.calls is not None + assert [call.argv for call in capture.calls[:2]] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ] + invocation = capture.calls[2] + assert isinstance(invocation, BuildImageInvocation) + assert "AGENTSEEK_GRAPHS" not in invocation.environment + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "buildx", "build", + "--load", + "--file", + "Dockerfile", "--platform", "linux/amd64,linux/arm64", - "-t", + "--tag", "agentseek:test", - "-f", - str((tmp_path / ".agentseek" / "Dockerfile").resolve()), + "-", + ) + assert invocation.stdin_bytes is not None + assert str(tmp_path) not in " ".join(invocation.argv) + with tarfile.open(fileobj=io.BytesIO(invocation.stdin_bytes), mode="r:") as archive: + members = set(archive.getnames()) + generated = archive.extractfile("Dockerfile").read().decode() # type: ignore[union-attr] + assert { + "Dockerfile", + "manifest.v1.json", + "runtime-constraints.txt", + "app/chat/__init__.py", + "app/chat/graph.py", + } <= members + assert "COPY app /deps/agent" in generated + assert "agentseek-api[embedded]==0.3.0" in generated + assert "org.agentseek.environment-contract=preloaded-v1" in generated + assert ( + 'CMD ["python", "-I", "-m", "agentseek_api.cli", "serve", "--environment-mode", ' + '"preloaded-v1", "--host", "0.0.0.0", "--port", "2024"]' in generated + ) + assert not (tmp_path / ".agentseek").exists() + + +def test_build_command_rejects_unavailable_buildx_before_build(tmp_path: Path) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + capture = _ProcessCapture( + return_codes={(DOCKER_EXECUTABLE, "buildx", "inspect"): 1} + ) + stderr = io.StringIO() + + exit_code = main( + ["build", "-t", "agentseek:test"], + process_transport=capture, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert capture.calls is not None + assert [call.argv for call in capture.calls] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ] - assert capture.command[-1] == "." - generated = (tmp_path / ".agentseek" / "Dockerfile").read_text(encoding="utf-8") - assert 'RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*' in generated - assert 'ENV PYTHONPATH=/deps/agent' in generated - assert 'ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json' in generated - assert 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' in generated + assert "builder is unavailable" in stderr.getvalue() + + +def test_build_command_carries_pip_config_only_as_buildkit_secret( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + pip_config = tmp_path / "pip.conf" + pip_config.write_text("password=cli-pip-canary\n", encoding="utf-8") + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = "./pip.conf" + config.write_text(json.dumps(payload), encoding="utf-8") + capture = _ProcessCapture() + + exit_code = main( + ["build", "-t", "agentseek:test"], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + build = _captured_image_build(capture) + assert build.argv[-3:] == ( + "--secret", + f"id=pip_config,src={pip_config}", + "-", + ) + assert "cli-pip-canary" not in " ".join(build.argv) + assert b"cli-pip-canary" not in build.stdin_bytes + + +def test_build_excludes_cli_dotenv_even_through_local_dependency_tree( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "build.env" + env_file.write_text("TOKEN=cli-dotenv-canary\n", encoding="utf-8") + capture = _ProcessCapture() + + exit_code = main( + ["build", "-t", "agentseek:test", "--env-file", str(env_file)], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + archive_bytes = _captured_image_build(capture).stdin_bytes + assert archive_bytes is not None + assert b"cli-dotenv-canary" not in archive_bytes + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:") as archive: + assert "app/build.env" not in archive.getnames() + + +def test_candidate_runtime_injection_changes_copied_build_artifact( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + from agentseek_api.container_build import candidate_runtime_artifact + + _write_basic_langgraph_config(tmp_path) + wheel = tmp_path / "agentseek_api-0.3.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "agentseek_api-0.3.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: agentseek-api\nVersion: 0.3.0\n", + ) + artifact = candidate_runtime_artifact( + wheel, hashlib.sha256(wheel.read_bytes()).hexdigest() + ) + capture = _ProcessCapture() + + exit_code = main( + ["build", "-t", "agentseek:candidate"], + process_transport=capture, + cwd=tmp_path, + runtime_artifact=artifact, + ) + + assert exit_code == 0 + assert capture.calls is not None + archive_bytes = _captured_image_build(capture).stdin_bytes + assert archive_bytes is not None + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:") as archive: + copied = archive.extractfile(f"runtime/{wheel.name}") + assert copied is not None + assert copied.read() == wheel.read_bytes() + + +def test_generated_up_uses_final_auth_selection_and_sanitized_build_stdin( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + (tmp_path / "pyproject.toml").write_text( + '[project]\nname="fixture"\nversion="1.0"\n', encoding="utf-8" + ) + lower_auth = tmp_path / "lower_auth.py" + lower_auth.write_text("auth = 'lower'\n", encoding="utf-8") + winning_auth = tmp_path / "winning_auth.py" + winning_auth.write_text("auth = 'winner'\n", encoding="utf-8") + config = tmp_path / "agentseek.json" + config.write_text( + '{"graphs":{"chat":"installed.graph:graph"},' + '"auth":{"path":"./lower_auth.py:auth"}}', + encoding="utf-8", + ) + env_file = tmp_path / "up.env" + env_file.write_text( + "AUTH_MODULE_PATH=winning_auth.py:auth\n" + "OPENAI_API_KEY=provider-secret-canary\n", + encoding="utf-8", + ) + capture = _ProcessCapture() + + exit_code = main( + ["up", "--env-file", str(env_file), "--no-pull"], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + build = _captured_image_build(capture) + assert build.argv == ( + DOCKER_EXECUTABLE, + "buildx", + "build", + "--load", + "--file", + "Dockerfile", + "--tag", + "agentseek-up:8123", + "-", + ) + assert build.stdin_bytes is not None + assert b"provider-secret-canary" not in build.stdin_bytes + assert "provider-secret-canary" not in " ".join(build.argv) + assert "provider-secret-canary" not in repr(build) + with tarfile.open(fileobj=io.BytesIO(build.stdin_bytes), mode="r:") as archive: + members = set(archive.getnames()) + assert "app/winning_auth.py" in members + assert "app/lower_auth.py" not in members + assert "app/up.env" not in members + container_env = _application_environment(capture) + assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/winning_auth.py:auth" + assert container_env["OPENAI_API_KEY"] == "provider-secret-canary" + + +@pytest.mark.parametrize( + "reference", + [ + "auth.py:auth", + "/host/auth.py:auth", + "installed.auth", + "installed.auth:", + ":auth", + r"installed\auth:auth", + r"C:\host\auth.py:auth", + ], +) +def test_up_with_custom_image_rejects_host_file_auth_references( + tmp_path: Path, reference: str +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "up.env" + env_file.write_text(f"AUTH_MODULE_PATH={reference}\n", encoding="utf-8") + capture = _ProcessCapture() + stderr = io.StringIO() + + exit_code = main( + [ + "up", + "--config", + str(config), + "--image", + "agentseek:test", + "--env-file", + str(env_file), + ], + process_transport=capture, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert capture.calls is None + assert "package" in stderr.getvalue() + assert "host" in stderr.getvalue().lower() + + +@pytest.mark.parametrize("coincident_host_file", [False, True]) +def test_up_custom_image_never_guesses_absolute_auth_file_origin( + tmp_path: Path, coincident_host_file: bool +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + reference = tmp_path / "coincident.py" + if coincident_host_file: + reference.write_text("auth = object()\n", encoding="utf-8") + env_file = tmp_path / "up.env" + env_file.write_text(f"AUTH_MODULE_PATH={reference}:auth\n", encoding="utf-8") + stderr = io.StringIO() + capture = _ProcessCapture() + + assert ( + main( + [ + "up", + "--config", + str(config), + "--image", + "agentseek:test", + "--env-file", + str(env_file), + ], + process_transport=capture, + cwd=tmp_path, + stderr=stderr, + ) + == 2 + ) + assert capture.calls is None + assert stderr.getvalue() == ( + "Custom-image auth cannot reference a host file; bake the module into the image " + "and use an importable package reference.\n" + ) + + +@pytest.mark.parametrize("reference", ["", "installed.auth:auth"]) +def test_up_with_custom_image_preserves_empty_or_package_auth( + tmp_path: Path, reference: str +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "up.env" + env_file.write_text(f"AUTH_MODULE_PATH={reference}\n", encoding="utf-8") + capture = _ProcessCapture() + + exit_code = main( + [ + "up", + "--config", + str(config), + "--image", + "agentseek:test", + "--env-file", + str(env_file), + ], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert _application_environment(capture)["AUTH_MODULE_PATH"] == reference + + +@pytest.mark.parametrize( + ("entrypoint", "expected_tail"), + [ + ( + [], + ( + "agentseek-api", + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ( + ["agentseek-api"], + ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ( + ["python", "-m", "agentseek_api.cli"], + ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ], +) +def test_up_custom_image_inspects_contract_and_runs_explicit_preloaded_mode( + tmp_path: Path, + entrypoint: list[str], + expected_tail: tuple[str, ...], +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + capture = _ProcessCapture( + image_config=( + { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", + }, + entrypoint, + ["hostile-default"], + ) + ) + + assert ( + main( + ["up", "--config", str(config), "--image", "agentseek:test"], + process_transport=capture, + cwd=tmp_path, + ) + == 0 + ) + + assert capture.calls is not None + inspect = capture.calls[0] + assert inspect.argv == ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + assert ".Config.Env" not in " ".join(inspect.argv) + run = next(call for call in capture.calls if isinstance(call, DockerRunInvocation)) + assert run.environment["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" + assert "--entrypoint" not in run.argv + assert run.argv[-len(expected_tail) :] == expected_tail + + +@pytest.mark.parametrize("custom_image", [False, True]) +def test_up_preloaded_launch_environment_overrides_or_removes_python_controls( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + custom_image: bool, +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + canaries = { + "PYTHONSAFEPATH": "unsafe-safepath-canary", + "PYTHONNOUSERSITE": "unsafe-usersite-canary", + "PYTHONPATH": "unsafe-pythonpath-canary", + "PYTHONHOME": "unsafe-pythonhome-canary", + } + for name, value in canaries.items(): + monkeypatch.setenv(name, value) + capture = _ProcessCapture() + arguments = ["up", "--config", str(config), "--recreate"] + if custom_image: + arguments.extend(("--image", "agentseek:test")) + for name in canaries: + arguments.extend(("--pass-env", name)) + + assert main(arguments, process_transport=capture, cwd=tmp_path) == 0 + + payload = _application_environment(capture) + assert payload["PYTHONSAFEPATH"] == "1" + assert payload["PYTHONNOUSERSITE"] == "1" + assert "PYTHONPATH" not in payload + assert "PYTHONHOME" not in payload + run = next( + call for call in capture.calls or () if isinstance(call, DockerRunInvocation) + ) + rendered = repr(run) + assert all(value not in rendered for value in canaries.values()) + + +def test_custom_python_entrypoint_uses_safe_carrier_from_hostile_cwd( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + hostile = tmp_path / "hostile" + package = hostile / "agentseek_api" + package.mkdir(parents=True) + marker = tmp_path / "hostile-custom-image-bootstrap" + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "cli.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('canary')\nraise SystemExit(23)\n", + encoding="utf-8", + ) + monkeypatch.setenv("PYTHONPATH", str(hostile)) + capture = _ProcessCapture( + image_config=( + { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", + }, + ["python", "-m", "agentseek_api.cli"], + ["hostile-default"], + ) + ) + + assert ( + main( + [ + "up", + "--config", + str(config), + "--image", + "agentseek:test", + "--pass-env", + "PYTHONPATH", + ], + process_transport=capture, + cwd=tmp_path, + ) + == 0 + ) + + run = next( + call for call in capture.calls or () if isinstance(call, DockerRunInvocation) + ) + payload = {name: run.environment[name] for name in run.application_names} + environment = dict(os.environ) + environment.pop("PYTHONPATH", None) + environment.pop("PYTHONHOME", None) + environment.update(payload) + completed = subprocess.run( + [sys.executable, "-m", "agentseek_api.cli", "version"], + cwd=hostile, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + assert run.argv[-7:] == ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ) + assert completed.returncode == 0 + assert completed.stdout == "agentseek-api 0.3.0\n" + assert marker.exists() is False + + +def test_up_custom_image_contract_failure_stops_after_one_read_only_query( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config = _write_basic_langgraph_config(tmp_path) + capture = _ProcessCapture(image_config=({}, [], [])) + stderr = io.StringIO() + + assert ( + main( + ["up", "--config", str(config), "--image", "private-image-canary"], + process_transport=capture, + cwd=tmp_path, + stderr=stderr, + ) + == 2 + ) + assert capture.calls is not None + assert len(capture.calls) == 1 + assert isinstance(capture.calls[0], ControlQueryInvocation) + assert "private-image-canary" not in stderr.getvalue() def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: @@ -1403,27 +3214,35 @@ def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: encoding="utf-8", ) - env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) + env = build_runtime_env( + config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={} + ) assert env["TOKEN"] == "quoted # value\nnext" assert env["PLAIN"] == "value" assert env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_build_runtime_env_ignores_dotenv_entries_without_values(tmp_path: Path) -> None: +def test_build_runtime_env_ignores_dotenv_entries_without_values( + tmp_path: Path, +) -> None: from agentseek_api.cli import build_runtime_env config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / ".env" env_file.write_text("MALFORMED_LINE\nTOKEN=present\n", encoding="utf-8") - env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) + env = build_runtime_env( + config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={} + ) assert "MALFORMED_LINE" not in env assert env["TOKEN"] == "present" -def test_build_runtime_env_shell_values_override_config_and_cli_dotenv(tmp_path: Path) -> None: +def test_build_runtime_env_shell_values_override_config_and_cli_dotenv( + tmp_path: Path, +) -> None: from agentseek_api.cli import build_runtime_env config_path = _write_basic_langgraph_config(tmp_path) @@ -1464,7 +3283,9 @@ def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) cli_env = tmp_path / "cli.env" cli_env.write_text("TOKEN\nRESULT=${TOKEN:-fallback}\n", encoding="utf-8") - env = build_runtime_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={}) + env = build_runtime_env( + config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={} + ) assert env["TOKEN"] == "from-config" assert env["RESULT"] == "" @@ -1474,20 +3295,30 @@ def test_build_runtime_env_rejects_invalid_config_env_shape(tmp_path: Path) -> N from agentseek_api.cli import build_runtime_env config_path = tmp_path / "langgraph.json" - config_path.write_text('{"graphs":{"chat":"chat.graph:graph"},"env":["bad"]}', encoding="utf-8") + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":["bad"]}', encoding="utf-8" + ) - with pytest.raises(RuntimeError, match="must set 'env' to a path string or key/value object"): - build_runtime_env(config_path=config_path, env_file=None, cwd=tmp_path, base_env={}) + with pytest.raises( + RuntimeError, match="must set 'env' to a path string or key/value object" + ): + build_runtime_env( + config_path=config_path, env_file=None, cwd=tmp_path, base_env={} + ) def test_build_runtime_env_rejects_non_scalar_config_env_value(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env config_path = tmp_path / "langgraph.json" - config_path.write_text('{"graphs":{"chat":"chat.graph:graph"},"env":{"BAD":[]}}', encoding="utf-8") + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":{"BAD":[]}}', encoding="utf-8" + ) with pytest.raises(RuntimeError, match="env mapping values must be scalar"): - build_runtime_env(config_path=config_path, env_file=None, cwd=tmp_path, base_env={}) + build_runtime_env( + config_path=config_path, env_file=None, cwd=tmp_path, base_env={} + ) def test_containerize_symbol_reference_supports_windows_drive_paths( @@ -1496,14 +3327,18 @@ def test_containerize_symbol_reference_supports_windows_drive_paths( from agentseek_api import cli as cli_module expected_path = tmp_path / "auth.py" - monkeypatch.setattr(cli_module, "_resolve_path", lambda path_text, *, cwd: expected_path) + monkeypatch.setattr( + cli_module, "_resolve_path", lambda path_text, *, cwd: expected_path + ) monkeypatch.setattr( cli_module, "_container_config_path", lambda *, config_path, cwd: "/deps/agent/auth.py", ) - result = cli_module._containerize_symbol_reference(r"C:\workspace\auth.py:backend", cwd=tmp_path) + result = cli_module._containerize_symbol_reference( + r"C:\workspace\auth.py:backend", cwd=tmp_path + ) assert result == "/deps/agent/auth.py:backend" @@ -1515,13 +3350,19 @@ def test_dockerfile_command_requires_valid_config_object(tmp_path: Path) -> None config_path.write_text("[]", encoding="utf-8") stderr = io.StringIO() - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=stderr, + ) assert exit_code == 2 assert "must contain a top-level JSON object" in stderr.getvalue() -def test_dockerfile_command_rejects_invalid_auth_and_missing_pip_config(tmp_path: Path) -> None: +def test_dockerfile_command_rejects_invalid_auth_and_missing_pip_config( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = tmp_path / "langgraph.json" @@ -1539,7 +3380,11 @@ def test_dockerfile_command_rejects_invalid_auth_and_missing_pip_config(tmp_path ) stderr = io.StringIO() - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=stderr, + ) assert exit_code == 2 assert "field 'auth' must be an object" in stderr.getvalue() @@ -1562,7 +3407,11 @@ def test_dockerfile_command_rejects_missing_pip_config_file(tmp_path: Path) -> N ) stderr = io.StringIO() - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=stderr, + ) assert exit_code == 2 assert "Pip config file" in stderr.getvalue() @@ -1585,13 +3434,19 @@ def test_dockerfile_command_rejects_unsupported_image_distro(tmp_path: Path) -> ) stderr = io.StringIO() - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=stderr, + ) assert exit_code == 2 assert "not supported without an explicit base_image" in stderr.getvalue() -def test_dockerfile_command_rejects_non_apt_base_image(tmp_path: Path) -> None: +def test_dockerfile_command_allows_python_alpine_base_without_apt( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = tmp_path / "langgraph.json" @@ -1606,15 +3461,22 @@ def test_dockerfile_command_rejects_non_apt_base_image(tmp_path: Path) -> None: """.strip(), encoding="utf-8", ) - stderr = io.StringIO() + dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], + cwd=tmp_path, + ) - assert exit_code == 2 - assert "require apt-get" in stderr.getvalue() + assert exit_code == 0 + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert "FROM python:3.12-alpine" in content + assert "apt-get" not in content -def test_dockerfile_command_rejects_unknown_non_debian_base_image(tmp_path: Path) -> None: +def test_dockerfile_command_allows_explicit_python_runtime_base_image( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = tmp_path / "langgraph.json" @@ -1629,15 +3491,48 @@ def test_dockerfile_command_rejects_unknown_non_debian_base_image(tmp_path: Path """.strip(), encoding="utf-8", ) + dockerfile_path = tmp_path / "Dockerfile.agentseek" + + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], + cwd=tmp_path, + ) + + assert exit_code == 0 + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert "FROM registry.access.redhat.com/ubi9/python-312" in content + + +def test_dockerfile_command_rejects_syntactically_invalid_base_image( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config_path = tmp_path / "langgraph.json" + config_path.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "base_image": "python:3.12 slim", + } + ), + encoding="utf-8", + ) stderr = io.StringIO() - exit_code = main(["dockerfile", "--config", str(config_path), "Dockerfile"], cwd=tmp_path, stderr=stderr) + exit_code = main( + ["dockerfile", "--config", str(config_path), "Dockerfile"], + cwd=tmp_path, + stderr=stderr, + ) assert exit_code == 2 - assert "Debian/Ubuntu-compatible" in stderr.getvalue() + assert "base image is invalid" in stderr.getvalue() -def test_dockerfile_command_allows_supported_explicit_langgraph_base_image(tmp_path: Path) -> None: +def test_dockerfile_command_allows_supported_explicit_langgraph_base_image( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = tmp_path / "langgraph.json" @@ -1654,10 +3549,12 @@ def test_dockerfile_command_allows_supported_explicit_langgraph_base_image(tmp_p ) dockerfile_path = tmp_path / "Dockerfile.agentseek" - exit_code = main(["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path) + exit_code = main( + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path + ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") assert "FROM langchain/langgraph-api:0.2" in content @@ -1684,7 +3581,7 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) "OPENAI_BASE_URL=${API_ORIGIN}/v1\n", encoding="utf-8", ) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1699,15 +3596,20 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) str(env_file), "--recreate", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0] == ["docker", "rm", "-f", "agentseek-up-8123"] - assert capture.calls[1][:9] == [ - "docker", + assert capture.calls[1].argv == ( + DOCKER_EXECUTABLE, + "rm", + "-f", + "agentseek-up-8123", + ) + assert capture.calls[2].argv[:9] == ( + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -1716,22 +3618,104 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) "host.docker.internal:host-gateway", "-p", "8123:2024", - ] - assert capture.calls[1][-1] == "agentseek:test" - container_env = _docker_env_from_run_command(capture.calls[1]) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" + ) + assert "agentseek:test" in capture.calls[2].argv + container_env = _application_environment(capture) + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" assert container_env["METADATA_DB_URL"] == "sqlite+aiosqlite:////tmp/agentseek.db" assert container_env["OCEANBASE_HOST"] == "host.docker.internal" assert container_env["OPENAI_BASE_URL"] == "https://api.example.test/v1" +def test_up_keeps_application_values_only_in_final_run_carrier( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "docker.env" + env_file.write_text( + "OPENAI_API_KEY=sk-$#=雪\nEMPTY=\n", + encoding="utf-8", + ) + monkeypatch.setenv("DOCKER_HOST", "unix:///private/docker.sock") + monkeypatch.setenv("UNSELECTED_CANARY", "must-not-cross") + capture = _ProcessCapture() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--env-file", + str(env_file), + "--pass-env", + "EMPTY", + "--recreate", + ], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + assert len(capture.calls) == 3 + inspect, remove, run = capture.calls + assert isinstance(inspect, ControlQueryInvocation) + assert type(remove) is ProcessInvocation + assert dict(remove.environment)["DOCKER_HOST"] == "unix:///private/docker.sock" + assert "OPENAI_API_KEY" not in remove.environment + assert isinstance(run, DockerRunInvocation) + assert run.environment["OPENAI_API_KEY"] == "sk-$#=雪" + assert run.environment["EMPTY"] == "" + assert "UNSELECTED_CANARY" not in run.environment + joined_argv = " ".join(run.argv) + assert "sk-$#=雪" not in joined_argv + assert "OPENAI_API_KEY=sk-$#=雪" not in joined_argv + assert ("-e", "OPENAI_API_KEY") in tuple(zip(run.argv, run.argv[1:], strict=False)) + + +def test_up_container_existence_probe_is_bounded_and_control_only( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + config_path = _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("DOCKER_HOST", "unix:///private/docker.sock") + monkeypatch.setenv("OPENAI_API_KEY", "application-canary") + capture = _ProcessCapture() + + exit_code = main( + ["up", "--config", str(config_path), "--image", "agentseek:test"], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + probe = capture.calls[1] + assert isinstance(probe, ControlQueryInvocation) + assert probe.timeout_seconds > 0 + assert probe.argv == ( + DOCKER_EXECUTABLE, + "container", + "inspect", + "agentseek-up-8123", + ) + assert probe.environment["DOCKER_HOST"] == "unix:///private/docker.sock" + assert "OPENAI_API_KEY" not in probe.environment + assert capture.calls[2].environment["OPENAI_API_KEY"] == "application-canary" + + def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: from agentseek_api.cli import main config_path = _write_basic_langgraph_config(tmp_path) compose_path = tmp_path / "docker-compose.yml" compose_path.write_text("services: {}\n", encoding="utf-8") - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1744,15 +3728,179 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: str(compose_path), "--recreate", ], - runner=capture, + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + assert capture.calls[1].argv == ( + DOCKER_EXECUTABLE, + "compose", + "version", + "--short", + ) + assert capture.calls[2].argv == ( + DOCKER_EXECUTABLE, + "rm", + "-f", + "agentseek-up-8123", + ) + compose_invocation = capture.calls[3] + assert compose_invocation.argv[:3] == ( + DOCKER_EXECUTABLE, + "compose", + "--env-file", + ) + assert compose_invocation.argv[4:] == ( + "-f", + str(compose_path.resolve()), + "up", + "-d", + "--force-recreate", + ) + assert "agentseek:test" in capture.calls[4].argv + for invocation in capture.calls: + assert "AGENTSEEK_GRAPHS" not in invocation.environment or isinstance( + invocation, DockerRunInvocation + ) + + +def test_up_compose_uses_selected_literal_artifact_and_ignores_ambient_controls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},' + '"env":{"TOKEN":"${HOSTILE} # literal"},' + '"compose_env":["TOKEN"]}', + encoding="utf-8", + ) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text( + 'services:\n db:\n image: busybox\n environment:\n TOKEN: "${TOKEN}"\n', + encoding="utf-8", + ) + (tmp_path / ".env").write_text("TOKEN=project-dotenv-canary\n", encoding="utf-8") + monkeypatch.setenv("COMPOSE_FILE", "hostile.yaml") + monkeypatch.setenv("COMPOSE_ENV_FILES", "hostile.env") + monkeypatch.setenv("HOSTILE", "ambient-canary") + + class ContentCapture(_ProcessCapture): + compose_contents: bytes | None = None + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if invocation.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file"): + self.compose_contents = Path(invocation.argv[3]).read_bytes() + return super().__call__(invocation) + + capture = ContentCapture() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--docker-compose", + str(compose_path), + "--recreate", + ], + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0] == ["docker", "rm", "-f", "agentseek-up-8123"] - assert capture.calls[1] == ["docker", "compose", "-f", str(compose_path.resolve()), "up", "-d", "--force-recreate"] - assert capture.calls[2][-1] == "agentseek:test" + compose = next( + call + for call in capture.calls + if call.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file") + ) + artifact = Path(compose.argv[3]) + assert not artifact.exists() + assert "COMPOSE_FILE" not in compose.environment + assert "COMPOSE_ENV_FILES" not in compose.environment + assert "HOSTILE" not in compose.environment + assert "project-dotenv-canary" not in " ".join(compose.argv) + assert capture.compose_contents == b'TOKEN="$${HOSTILE} # literal"\n' + + +def test_up_compose_artifact_is_removed_after_compose_failure(tmp_path: Path) -> None: + from agentseek_api.cli import main + + config_path = _write_basic_langgraph_config(tmp_path) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services: {}\n", encoding="utf-8") + artifact_paths: list[Path] = [] + + class FailureCapture(_ProcessCapture): + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + result = super().__call__(invocation) + if invocation.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file"): + artifact = Path(invocation.argv[3]) + assert artifact.exists() + artifact_paths.append(artifact) + return ProcessResult(returncode=19) + return result + + capture = FailureCapture() + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--docker-compose", + str(compose_path), + "--recreate", + ], + process_transport=capture, + cwd=tmp_path, + ) + + assert exit_code == 19 + assert len(artifact_paths) == 1 + assert not artifact_paths[0].exists() + assert capture.calls is not None + assert not any(isinstance(call, DockerRunInvocation) for call in capture.calls) + + +def test_up_rejects_missing_compose_selection_before_build_or_artifact( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"compose_env":["MISSING"]}', + encoding="utf-8", + ) + compose_path = tmp_path / "compose.yaml" + compose_path.write_text("services: {}\n", encoding="utf-8") + capture = _ProcessCapture() + stderr = io.StringIO() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--docker-compose", + str(compose_path), + ], + process_transport=capture, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert capture.calls is None + assert "not present in application payload" in stderr.getvalue() def test_up_command_rejects_missing_docker_compose_file(tmp_path: Path) -> None: @@ -1779,20 +3927,16 @@ def test_up_command_rejects_missing_docker_compose_file(tmp_path: Path) -> None: assert "Docker compose file" in stderr.getvalue() -def test_up_command_rejects_existing_container_before_starting_compose_sidecars(tmp_path: Path) -> None: +def test_up_command_rejects_existing_container_before_starting_compose_sidecars( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = _write_basic_langgraph_config(tmp_path) compose_path = tmp_path / "docker-compose.yml" compose_path.write_text("services: {}\n", encoding="utf-8") stderr = io.StringIO() - capture = _RunCapture() - - def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: - capture(command, env=env, cwd=cwd) - if command[:3] == ["docker", "container", "inspect"]: - return 0 - return 0 + capture = _ProcessCapture(container_exists=True) exit_code = main( [ @@ -1804,22 +3948,36 @@ def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: s "--docker-compose", str(compose_path), ], - runner=existing_container_runner, + process_transport=capture, cwd=tmp_path, stderr=stderr, ) assert exit_code == 2 - assert capture.calls == [["docker", "container", "inspect", "agentseek-up-8123"]] + assert capture.calls is not None + assert [call.argv for call in capture.calls] == [ + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ), + (DOCKER_EXECUTABLE, "compose", "version", "--short"), + (DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), + ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() -def test_up_command_builds_image_when_missing_and_passes_postgres_uri(tmp_path: Path) -> None: +def test_up_command_builds_image_when_missing_and_passes_postgres_uri( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1830,24 +3988,37 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri(tmp_path: "--postgres-uri", "postgresql://postgres:postgres@db/agentseek", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0] == [ - "docker", + assert [call.argv for call in capture.calls[:2]] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ] + assert capture.calls[2].argv == ( + DOCKER_EXECUTABLE, + "buildx", "build", - "-t", + "--load", + "--file", + "Dockerfile", + "--tag", "agentseek-up:8124", - "-f", - str((tmp_path / ".agentseek" / "Dockerfile").resolve()), - ".", - ] - assert capture.calls[1] == ["docker", "container", "inspect", "agentseek-up-8124"] - assert capture.calls[2][:9] == [ - "docker", + "-", + ) + assert capture.calls[2].stdin_bytes is not None + assert capture.calls[3].argv[:3] == (DOCKER_EXECUTABLE, "image", "inspect") + assert capture.calls[4].argv == ( + DOCKER_EXECUTABLE, + "container", + "inspect", + "agentseek-up-8124", + ) + assert capture.calls[5].argv[:9] == ( + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -1856,15 +4027,25 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri(tmp_path: "host.docker.internal:host-gateway", "-p", "8124:2024", - ] - assert capture.calls[2][-1] == "agentseek-up:8124" - container_env = _docker_env_from_run_command(capture.calls[2]) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" - assert container_env["METADATA_DB_URL"] == "postgresql://postgres:postgres@db/agentseek" + ) + assert "agentseek-up:8124" in capture.calls[5].argv + for invocation in capture.calls[:5]: + assert "METADATA_DB_URL" not in invocation.environment + assert "postgresql://postgres:postgres@db/agentseek" not in " ".join( + invocation.argv + ) + container_env = _application_environment(capture) + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" + assert ( + container_env["METADATA_DB_URL"] + == "postgresql://postgres:postgres@db/agentseek" + ) assert container_env["METADATA_DB_BACKEND"] == "postgresql" -def test_up_command_passes_config_auth_env_and_containerizes_file_paths(tmp_path: Path) -> None: +def test_up_command_passes_config_auth_env_and_containerizes_file_paths( + tmp_path: Path, +) -> None: from agentseek_api.cli import main package_dir = tmp_path / "chat" @@ -1889,25 +4070,31 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths(tmp_path """.strip(), encoding="utf-8", ) - capture = _RunCapture() + (tmp_path / "auth.py").write_text("backend = object()\n", encoding="utf-8") + capture = _ProcessCapture() exit_code = main( [ "up", "--config", str(config_path), - "--image", - "agentseek:test", + "--no-pull", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0] == ["docker", "container", "inspect", "agentseek-up-8123"] - assert capture.calls[1][:9] == [ - "docker", + assert capture.calls[3].argv[:3] == (DOCKER_EXECUTABLE, "image", "inspect") + assert capture.calls[4].argv == ( + DOCKER_EXECUTABLE, + "container", + "inspect", + "agentseek-up-8123", + ) + assert capture.calls[5].argv[:9] == ( + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -1916,20 +4103,22 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths(tmp_path "host.docker.internal:host-gateway", "-p", "8123:2024", - ] - assert capture.calls[1][-1] == "agentseek:test" - container_env = _docker_env_from_run_command(capture.calls[1]) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" + ) + assert "agentseek-up:8123" in capture.calls[5].argv + container_env = _application_environment(capture) + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/auth.py:backend" assert container_env["FEATURE_FLAG"] == "True" -def test_up_command_passes_ambient_env_into_container(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_up_command_passes_ambient_env_into_container( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main config_path = _write_basic_langgraph_config(tmp_path) monkeypatch.setenv("OPENAI_API_KEY", "ambient-key") - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1939,17 +4128,19 @@ def test_up_command_passes_ambient_env_into_container(tmp_path: Path, monkeypatc "--image", "agentseek:test", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - container_env = _docker_env_from_run_command(capture.calls[1]) + container_env = _application_environment(capture) assert container_env["OPENAI_API_KEY"] == "ambient-key" -def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: +def test_up_command_prefers_agentseek_json_without_explicit_flag( + tmp_path: Path, +) -> None: from agentseek_api.cli import main (tmp_path / "agentseek.json").write_text( @@ -1963,7 +4154,7 @@ def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) encoding="utf-8", ) _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1971,14 +4162,14 @@ def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) "--image", "agentseek:test", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - container_env = _docker_env_from_run_command(capture.calls[1]) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/agentseek.json" + container_env = _application_environment(capture) + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" def test_up_command_does_not_pass_shell_runtime_env_into_container( @@ -1990,7 +4181,7 @@ def test_up_command_does_not_pass_shell_runtime_env_into_container( config_path = _write_basic_langgraph_config(tmp_path) monkeypatch.setenv("PATH", "/tmp/bad-path") monkeypatch.setenv("PWD", "/tmp/host-pwd") - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2000,13 +4191,13 @@ def test_up_command_does_not_pass_shell_runtime_env_into_container( "--image", "agentseek:test", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - container_env = _docker_env_from_run_command(capture.calls[1]) + container_env = _application_environment(capture) assert "PATH" not in container_env assert "PWD" not in container_env @@ -2015,7 +4206,7 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2026,21 +4217,28 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No "python:3.13-slim-bookworm", "--no-pull", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 - dockerfile = (tmp_path / ".agentseek" / "Dockerfile").read_text(encoding="utf-8") + assert capture.calls is not None + archive_bytes = _captured_image_build(capture).stdin_bytes + assert archive_bytes is not None + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:") as archive: + dockerfile_file = archive.extractfile("Dockerfile") + assert dockerfile_file is not None + dockerfile = dockerfile_file.read().decode() assert "FROM python:3.13-slim-bookworm" in dockerfile -def test_up_command_rejects_non_apt_base_image_before_docker_build(tmp_path: Path) -> None: +def test_up_command_allows_python_alpine_base_without_apt( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() - stderr = io.StringIO() + capture = _ProcessCapture() exit_code = main( [ @@ -2048,30 +4246,53 @@ def test_up_command_rejects_non_apt_base_image_before_docker_build(tmp_path: Pat "--base-image", "python:3.12-alpine", ], - runner=capture, + process_transport=capture, cwd=tmp_path, - stderr=stderr, ) - assert exit_code == 2 - assert capture.calls is None - assert "require apt-get" in stderr.getvalue() + assert exit_code == 0 + archive_bytes = _captured_image_build(capture).stdin_bytes + with tarfile.open(fileobj=io.BytesIO(archive_bytes), mode="r:") as archive: + dockerfile_file = archive.extractfile("Dockerfile") + assert dockerfile_file is not None + dockerfile = dockerfile_file.read().decode() + assert "FROM python:3.12-alpine" in dockerfile + assert "apt-get" not in dockerfile -def test_up_command_returns_build_failure_without_running_container(tmp_path: Path) -> None: +def test_up_command_returns_build_failure_without_running_container( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() - - def fail_build(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: - capture(command, env=env, cwd=cwd) - return 9 if command[:2] == ["docker", "build"] else 0 + build_argv = ( + DOCKER_EXECUTABLE, + "buildx", + "build", + "--load", + "--file", + "Dockerfile", + "--pull", + "--tag", + "agentseek-up:8125", + "-", + ) + capture = _ProcessCapture(return_codes={build_argv: 9}) - exit_code = main(["up", "--port", "8125"], runner=fail_build, cwd=tmp_path) + exit_code = main( + ["up", "--port", "8125"], + process_transport=capture, + cwd=tmp_path, + ) assert exit_code == 9 - assert capture.calls == [["docker", "build", "--pull", "-t", "agentseek-up:8125", "-f", str((tmp_path / ".agentseek" / "Dockerfile").resolve()), "."]] + assert capture.calls is not None + assert [call.argv for call in capture.calls] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + build_argv, + ] def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) -> None: @@ -2079,13 +4300,7 @@ def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) config_path = _write_basic_langgraph_config(tmp_path) stderr = io.StringIO() - capture = _RunCapture() - - def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: - capture(command, env=env, cwd=cwd) - if command[:3] == ["docker", "container", "inspect"]: - return 0 - return 0 + capture = _ProcessCapture(container_exists=True) exit_code = main( [ @@ -2095,55 +4310,61 @@ def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: s "--image", "agentseek:test", ], - runner=existing_container_runner, + process_transport=capture, cwd=tmp_path, stderr=stderr, ) assert exit_code == 2 - assert capture.calls == [["docker", "container", "inspect", "agentseek-up-8123"]] + assert capture.calls is not None + assert [call.argv for call in capture.calls] == [ + ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ), + (DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), + ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() -def test_container_exists_uses_quiet_probe_for_default_runner( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: +def test_container_exists_uses_a_private_bounded_query(tmp_path: Path) -> None: from agentseek_api import cli as cli_module - observed: dict[str, object] = {} - - class _Completed: - returncode = 0 - - def fake_run(command: list[str], **kwargs: object) -> _Completed: - observed["command"] = command - observed["kwargs"] = kwargs - return _Completed() - - monkeypatch.setattr(cli_module.subprocess, "run", fake_run) + capture = _ProcessCapture(container_exists=True) exists = cli_module._container_exists( "agentseek-up-8123", - runner=cli_module._default_runner, - env={}, + docker_executable=DOCKER_EXECUTABLE, + process_transport=capture, + docker_control={}, cwd=tmp_path, ) assert exists is True - assert observed["command"] == ["docker", "container", "inspect", "agentseek-up-8123"] - kwargs = observed["kwargs"] - assert kwargs["stdout"] is cli_module.subprocess.DEVNULL - assert kwargs["stderr"] is cli_module.subprocess.DEVNULL - assert kwargs["check"] is False + assert capture.calls is not None + invocation = capture.calls[0] + assert isinstance(invocation, ControlQueryInvocation) + assert invocation.timeout_seconds > 0 + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "container", + "inspect", + "agentseek-up-8123", + ) -def test_up_command_waits_for_http_health_when_requested(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_up_command_waits_for_http_health_when_requested( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api import cli as cli_module config_path = _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() waited: list[tuple[str, float]] = [] def fake_wait(url: str, *, timeout_seconds: float) -> None: @@ -2162,7 +4383,7 @@ def fake_wait(url: str, *, timeout_seconds: float) -> None: "8123", "--wait", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py new file mode 100644 index 0000000..40558a0 --- /dev/null +++ b/tests/unit/test_container_boundary_acceptance.py @@ -0,0 +1,606 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from agentseek_api.docker_runtime import ProcessInvocation, ProcessResult + + +SCRIPT = Path("scripts/test_container_env_boundary.py") +AUTODISCOVERY_SCRIPT = Path("scripts/test_cli_config_autodiscovery.py") +CLI_DOCKER_SCRIPT = Path("scripts/test-cli-docker.sh") +VALUE_FREE_LOG_SCRIPT = Path("scripts/value_free_log_tail.py") +DOCKER_EXECUTABLE = str( + Path(sys.executable).parent / ("docker.exe" if os.name == "nt" else "docker") +) + + +def _load_script(): + if not SCRIPT.is_file(): + pytest.fail("the executable container-boundary acceptance module is missing") + spec = importlib.util.spec_from_file_location( + "container_boundary_acceptance", SCRIPT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_autodiscovery_script(): + spec = importlib.util.spec_from_file_location( + "cli_config_autodiscovery", AUTODISCOVERY_SCRIPT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _load_value_free_log_script(): + spec = importlib.util.spec_from_file_location( + "value_free_log_tail", VALUE_FREE_LOG_SCRIPT + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_evidence_objects_hide_all_values_from_repr() -> None: + module = _load_script() + value = "private-boundary-value" + + invocation = module.CapturedInvocation( + kind="docker-run", + argv=("docker", value), + environment={"VALUE": value}, + stdin_sha256=value, + ) + evidence = module.BoundaryEvidence( + disallowed_value=value, + allowed_value=value, + build_environment={"VALUE": value}, + compose_environment={"VALUE": value}, + build_context_archive=value.encode(), + image_archive_and_history=value.encode(), + application_environment={"VALUE": value}, + invocations=(invocation,), + ) + + assert value not in repr(invocation) + assert value not in repr(evidence) + + +def test_success_output_is_one_value_free_line() -> None: + module = _load_script() + evidence = module.BoundaryEvidence( + disallowed_value="disallowed-private-value", + allowed_value="allowed-private-value", + build_environment={}, + compose_environment={}, + build_context_archive=b"", + image_archive_and_history=b"", + application_environment={"ALLOWED_SENTINEL": "allowed-private-value"}, + invocations=( + module.CapturedInvocation( + kind="docker-run", + argv=("docker", "run", "-e", "ALLOWED_SENTINEL"), + environment={"ALLOWED_SENTINEL": "allowed-private-value"}, + stdin_sha256=None, + ), + ), + ) + output = io.StringIO() + + with contextlib.redirect_stdout(output): + result = module.main(evidence_collector=lambda **_: evidence) + + assert result == 0 + assert output.getvalue() == "container boundary verification passed\n" + + +def test_boundary_failure_emits_value_free_github_annotation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script() + stderr = io.StringIO() + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + def fail(**_kwargs): + raise module.BoundaryFailure("synthetic Docker carrier probe failed") + + with contextlib.redirect_stderr(stderr): + result = module.main(evidence_collector=fail) + + assert result == 1 + lines = stderr.getvalue().splitlines() + assert lines == [ + "::error title=AgentSeek container boundary::synthetic Docker carrier probe failed", + "container boundary verification failed: synthetic Docker carrier probe failed", + ] + + +def test_unexpected_failure_emits_only_a_value_free_code_fingerprint( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script() + stderr = io.StringIO() + secret = "unexpected-private-value" + monkeypatch.setenv("GITHUB_ACTIONS", "true") + + def fail(**_kwargs): + raise RuntimeError(secret) + + with contextlib.redirect_stderr(stderr): + result = module.main(evidence_collector=fail) + + diagnostic = stderr.getvalue() + assert result == 1 + assert "RuntimeError" in diagnostic + assert "test_container_boundary_acceptance.py" in diagnostic + assert secret not in diagnostic + + +def test_generated_compose_flow_uses_build_controls_for_direct_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script() + observed_controls: list[dict[str, str]] = [] + observed_cli_argv: list[tuple[str, ...]] = [] + + monkeypatch.setattr(module, "_build_candidate", lambda _project: object()) + monkeypatch.setattr(module, "_remove_owned_image", lambda **_kwargs: None) + + def fake_cli(argv, *, process_transport, **_kwargs): + observed_cli_argv.append(tuple(argv)) + process_transport.image = "agentseek:test" + process_transport.docker_executable = DOCKER_EXECUTABLE + process_transport.build_environment = {"PATH": "docker-control-only"} + process_transport.build_context_archive = b"context" + process_transport.compose_environment = {} + process_transport.application_environment = {"ALLOWED_SENTINEL": "allowed"} + process_transport.image_archive_and_history = b"image" + process_transport.invocations.append( + module.CapturedInvocation( + kind="compose", + argv=("docker", "compose", "up"), + environment={"PATH": "docker-control-only"}, + stdin_sha256=None, + ) + ) + return 0 + + def fake_probe(*, docker_environment, **_kwargs): + observed_controls.append(dict(docker_environment)) + + monkeypatch.setattr(module, "cli_main", fake_cli) + monkeypatch.setattr(module, "_prove_value_domain_carrier", fake_probe) + + evidence = module.collect_boundary_evidence( + disallowed_name="DISALLOWED_CANARY", + disallowed_value="disallowed", + allowed_name="ALLOWED_SENTINEL", + allowed_value="allowed", + ) + + assert evidence.invocations[0].kind == "compose" + assert observed_controls == [{"PATH": "docker-control-only"}] + assert observed_cli_argv and "--wait" in observed_cli_argv[0] + + +def test_generated_run_transport_executes_the_real_preloaded_container( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script() + result_path = tmp_path / "result.json" + result_path.write_text("{}", encoding="utf-8") + transport = module._EvidenceTransport( + result_path=result_path, + compose_dotenv_canary="canary", + forbidden_values=(), + image="agentseek:test", + ) + calls: list[tuple[str, ...]] = [] + probes: list[dict[str, str]] = [] + + monkeypatch.setattr( + module, + "_run_probe", + lambda **kwargs: ( + probes.append(dict(kwargs["application"])) or kwargs["application"] + ), + ) + + def fake_process(argv, **_kwargs): + calls.append(tuple(argv)) + return ProcessResult(returncode=0) + + monkeypatch.setattr(module, "_safe_process", fake_process) + invocation = module.DockerRunInvocation( + argv=( + DOCKER_EXECUTABLE, + "run", + "--detach", + "--name", + "agentseek-up-48123", + "-e", + "ALLOWED_SENTINEL", + "agentseek:test", + ), + environment={"PATH": "control", "ALLOWED_SENTINEL": "allowed"}, + cwd=tmp_path, + application_names=frozenset({"ALLOWED_SENTINEL"}), + ) + + assert transport(invocation).returncode == 0 + assert probes == [{"ALLOWED_SENTINEL": "allowed"}] + assert calls == [invocation.argv] + assert transport.container_name == "agentseek-up-48123" + + +def test_owned_generated_container_cleanup_requires_absence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script() + calls: list[tuple[str, ...]] = [] + + def fake_process(argv, **_kwargs): + calls.append(tuple(argv)) + return ProcessResult(returncode=1 if "inspect" in argv else 0) + + monkeypatch.setattr(module, "_safe_process", fake_process) + + module._remove_owned_container( + docker_executable=DOCKER_EXECUTABLE, + container_name="agentseek-up-48123", + cwd=tmp_path, + environment={"PATH": "control"}, + ) + + assert calls == [ + (DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-48123"), + (DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-48123"), + ] + + +def test_compose_evidence_uses_floor_compatible_rendered_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script() + transport = module._EvidenceTransport( + result_path=tmp_path / "result.json", + compose_dotenv_canary="must-not-load", + forbidden_values=(), + ) + calls: list[tuple[str, ...]] = [] + + def fake_process(argv, **_kwargs): + calls.append(argv) + if "--environment" in argv: + return ProcessResult(returncode=2) + return ProcessResult( + returncode=0, + stdout=json.dumps( + { + "services": { + "probe": { + "environment": { + "PROJECT_DOTENV_CANARY": "unset", + "SAFE_VALUE": "observed", + } + } + } + } + ).encode(), + ) + + monkeypatch.setattr(module, "_safe_process", fake_process) + invocation = ProcessInvocation( + argv=( + DOCKER_EXECUTABLE, + "compose", + "--env-file", + "private.env", + "-f", + "compose.json", + "up", + ), + environment={"PATH": "docker-control-only"}, + cwd=tmp_path, + ) + + assert transport._render_compose(invocation).returncode == 0 + assert calls == [ + ( + DOCKER_EXECUTABLE, + "compose", + "--env-file", + "private.env", + "-f", + "compose.json", + "config", + "--format", + "json", + ) + ] + assert dict(transport.compose_environment) == { + "PROJECT_DOTENV_CANARY": "unset", + "SAFE_VALUE": "observed", + } + + +def test_process_failure_tail_is_bounded_and_value_free() -> None: + module = _load_script() + forbidden = "abc" + credential = "tiny-password" + result = ProcessResult( + returncode=1, + stdout=("x" * 2_000).encode(), + stderr=( + f"prefix{forbidden}suffix " + f"https://alice:{credential}@registry.invalid real build error" + ).encode(), + ) + + diagnostic = module._value_free_process_tail( + result, + redactions=(forbidden.encode(), credential.encode()), + ) + + assert len(diagnostic) <= 800 + assert "real build error" in diagnostic + assert forbidden not in diagnostic + assert credential not in diagnostic + assert "alice" not in diagnostic + + +@pytest.mark.parametrize( + "application", + [ + {"ALLOWED_SENTINEL": "selected-value"}, + { + "VALUE_EMPTY": "", + "VALUE_NEWLINE": "physical\nnewline", + "VALUE_UNICODE": "海洋数据库", + }, + ], +) +def test_probe_writes_and_reads_the_exact_private_result_basename( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + application: dict[str, str], +) -> None: + module = _load_script() + result_path = tmp_path / f"environment-{os.urandom(8).hex()}" + result_path.write_bytes(b"") + result_path.chmod(0o600) + calls: list[tuple[str, ...]] = [] + + def fake_process(argv, *, cwd, environment, stdin=None, timeout=None): + del cwd, stdin, timeout + calls.append(argv) + if argv[:3] == (DOCKER_EXECUTABLE, "container", "inspect"): + return ProcessResult(returncode=1) + script = argv[-1].replace("/result/", f"{result_path.parent}/") + completed = subprocess.run( + [sys.executable, "-c", script], + env=dict(environment), + capture_output=True, + check=False, + ) + return ProcessResult( + returncode=completed.returncode, + stdout=completed.stdout, + stderr=completed.stderr, + ) + + monkeypatch.setattr(module, "_safe_process", fake_process) + + observed = module._run_probe( + docker_executable=DOCKER_EXECUTABLE, + image="synthetic:test", + application=application, + docker_environment={"PATH": os.environ["PATH"]}, + cwd=tmp_path, + result_path=result_path, + ) + + assert dict(observed) == application + assert json.loads(result_path.read_text(encoding="utf-8")) == application + assert tuple(path.name for path in tmp_path.iterdir()) == (result_path.name,) + run_argv = calls[0] + entrypoint_index = run_argv.index("--entrypoint") + assert run_argv[entrypoint_index : entrypoint_index + 3] == ( + "--entrypoint", + "python", + "synthetic:test", + ) + assert run_argv[-3:-1] == ("-I", "-c") + assert calls[1][:3] == (DOCKER_EXECUTABLE, "container", "inspect") + + +def test_probe_fails_if_auto_remove_leaves_the_owned_container( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script() + result_path = tmp_path / "environment-random" + result_path.write_text("{}", encoding="utf-8") + result_path.chmod(0o600) + + monkeypatch.setattr( + module, + "_safe_process", + lambda *args, **kwargs: ProcessResult(returncode=0), + ) + + with pytest.raises(module.BoundaryFailure, match="cleanup"): + module._run_probe( + docker_executable=DOCKER_EXECUTABLE, + image="synthetic:test", + application={}, + docker_environment={"PATH": os.environ["PATH"]}, + cwd=tmp_path, + result_path=result_path, + ) + + +def test_owned_image_cleanup_requires_removal_and_absence( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + module = _load_script() + results = iter((ProcessResult(returncode=0), ProcessResult(returncode=0))) + monkeypatch.setattr(module, "_safe_process", lambda *args, **kwargs: next(results)) + + with pytest.raises(module.BoundaryFailure, match="cleanup"): + module._remove_owned_image( + docker_executable=DOCKER_EXECUTABLE, + image="synthetic:test", + cwd=tmp_path, + environment={"PATH": os.environ["PATH"]}, + ) + + +@pytest.mark.parametrize("name", ["README.md", "README.zh-CN.md", "CHANGELOG.md"]) +def test_container_migration_docs_cover_the_public_boundary(name: str) -> None: + text = Path(name).read_text(encoding="utf-8") + required_literals = { + "build_include", + "compose_env", + "--pass-env", + "--compose-pass-env", + "preloaded-v1", + "org.agentseek.environment-contract", + "org.agentseek.runtime-manifest", + "org.agentseek.runtime-distribution", + "org.agentseek.runtime-version", + } + + assert required_literals <= set(re.findall(r"[\w.-]+|--[\w-]+", text)) + + +def test_release_docs_name_template_catalog_and_agentseek_as_separate_releases() -> ( + None +): + for name in ("README.md", "README.zh-CN.md", "CHANGELOG.md"): + text = Path(name).read_text(encoding="utf-8") + assert re.search(r"template/catalog.{0,80}0\.1\.4", text, re.DOTALL) + assert re.search(r"AgentSeek.{0,80}0\.1\.4", text, re.DOTALL) + + +def test_cli_config_autodiscovery_executes_the_bundle_contract() -> None: + completed = subprocess.run( + [sys.executable, "scripts/test_cli_config_autodiscovery.py"], + cwd=Path.cwd(), + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + + +def test_cli_docker_smoke_asserts_the_canonical_candidate_filename() -> None: + text = CLI_DOCKER_SCRIPT.read_text(encoding="utf-8") + + assert 'dockerfile.index("agentseek_api-0.3.0-py3-none-any.whl[embedded]")' in text + assert 'dockerfile.index("agentseek-api-0.3.0.whl[embedded]")' not in text + + +def test_cli_docker_smoke_launches_from_the_generated_project() -> None: + text = CLI_DOCKER_SCRIPT.read_text(encoding="utf-8") + + assert 'cd "$PROJECT_DIR"' in text + assert 'uv run --project "$ROOT_DIR" agentseek-api up' in text + + +def test_cli_docker_smoke_selects_the_baked_custom_auth_module() -> None: + text = CLI_DOCKER_SCRIPT.read_text(encoding="utf-8") + + assert '"auth": {"path": "auth_backend:HeaderAuthBackend"}' in text + + +def test_cli_docker_failure_tail_is_bounded_and_redacts_application_values( + tmp_path: Path, +) -> None: + module = _load_value_free_log_script() + private_value = "private-boundary-value" + credential = "registry-password" + log_path = tmp_path / "up.log" + env_path = tmp_path / "application.env" + log_path.write_text( + "x" * 20_000 + + f"\nprefix{private_value}suffix\n" + + f"https://alice:{credential}@registry.invalid startup failed\n", + encoding="utf-8", + ) + env_path.write_text(f"PRIVATE_VALUE={private_value}\n", encoding="utf-8") + + diagnostic = module.value_free_log_tail(log_path, env_path) + + assert len(diagnostic.encode()) <= module.MAXIMUM_TAIL_BYTES + assert "startup failed" in diagnostic + assert private_value not in diagnostic + assert credential not in diagnostic + assert "alice" not in diagnostic + + +def test_cli_config_autodiscovery_emits_value_free_ci_failure_annotation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_autodiscovery_script() + credential = "tiny-password" + stderr = io.StringIO() + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=2, + stdout="", + stderr=( + "The build output root could not be verified private at " + f"https://alice:{credential}@registry.invalid\n" + ), + ), + ) + + with contextlib.redirect_stderr(stderr), pytest.raises(SystemExit): + module.main([]) + + diagnostic = stderr.getvalue() + assert diagnostic.startswith("::error title=AgentSeek dockerfile smoke::") + assert "build output root could not be verified private" in diagnostic + assert credential not in diagnostic + assert "alice" not in diagnostic + + +def test_cli_config_autodiscovery_reports_post_generation_contract_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_autodiscovery_script() + stderr = io.StringIO() + monkeypatch.setenv("GITHUB_ACTIONS", "true") + monkeypatch.setattr( + module.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace(returncode=0, stdout="", stderr=""), + ) + + with contextlib.redirect_stderr(stderr), pytest.raises(SystemExit): + module.main([]) + + assert "dockerfile bundle contract was incomplete" in stderr.getvalue() diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py new file mode 100644 index 0000000..8a2e2f8 --- /dev/null +++ b/tests/unit/test_container_build.py @@ -0,0 +1,2332 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import subprocess +import sys +import tarfile +import tempfile +import zipfile +from dataclasses import replace +from pathlib import Path + +import pytest + +import agentseek_api.container_build as container_build +from agentseek_api.container_build import ( + PUBLISHED_RUNTIME_ARTIFACT, + AuthPayloadPatch, + ContainerBuildError, + FinalAuthSelection, + InstallActionKind, + RuntimeArtifactSource, + RuntimeArtifactV1, + SourceReason, + candidate_runtime_artifact, + interpret_host_runtime_policy, + interpret_manifest_runtime_policy, + load_container_runtime_manifest_v1, + materialize_build_bundle, + plan_container_image, + plan_generated_up_auth, + render_build_dockerfile, + validate_dependency_specification, +) +from agentseek_api.environment import EnvironmentOrigin +from tests.container_plan_helpers import ( + build_plan_fixture, + make_graph_project, + package_only_build_plan_fixture, + read_archive_member, + write_sanitized_manifest, +) + + +def test_canonical_manifest_loader_preserves_explicit_values_and_container_roots( + tmp_path: Path, +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document.update( + dependencies=["/deps/agent", "/deps/agent/source"], + store={"ttl": {"refresh_on_read": False, "default_ttl": 0}}, + http={ + "app": "/deps/agent/web.py:app", + "disable_mcp": False, + "disable_a2a": True, + "cors": {"allow_origins": [], "allow_credentials": False, "max_age": 0}, + }, + auth={ + "openapi": {"securitySchemes": {}, "security": []}, + "disable_studio_auth": False, + }, + ) + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + manifest = load_container_runtime_manifest_v1(manifest_path) + + assert manifest.to_json_object() == document + + +@pytest.mark.parametrize( + "root", + [ + ".", + "source", + "../escape", + "/deps/agent/../escape", + "/other/root", + "/deps/agent/", + ], +) +def test_canonical_manifest_loader_rejects_noncanonical_container_roots( + tmp_path: Path, root: str +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["dependencies"] = [root] + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="container"): + load_container_runtime_manifest_v1(manifest_path) + + +@pytest.mark.parametrize( + ("mutation", "match"), + [ + (("runtime", "distribution", "other-runtime"), "identity"), + (("runtime", "version", "9.9.9"), "identity"), + (("runtime", "contract", "resolve"), "identity"), + (("http", "unknown", True), "http"), + (("http", "cors", {"unknown": True}), "cors"), + (("auth", "path", "secret.py:auth"), "auth"), + (("unknown", "field", True), "manifest"), + (("unknown", "env", {"TOKEN": "secret.py"}), "manifest"), + (("unknown", "pip_config_file", "secret.py"), "manifest"), + (("unknown", "dockerfile_lines", ["secret.py"]), "manifest"), + ], +) +def test_canonical_manifest_loader_rejects_unknown_or_forbidden_fields_value_free( + tmp_path: Path, mutation: tuple[str, str, object], match: str +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + parent, field, value = mutation + if parent == "unknown": + document[field] = value + else: + nested = document.setdefault(parent, {}) + assert isinstance(nested, dict) + nested[field] = value + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match=match) as caught: + load_container_runtime_manifest_v1(manifest_path) + assert "secret.py" not in str(caught.value) + + +def test_canonical_manifest_loader_rejects_boolean_schema_version( + tmp_path: Path, +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["schema_version"] = True + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="identity"): + load_container_runtime_manifest_v1(manifest_path) + + +@pytest.mark.parametrize( + ("section", "reference"), + [ + ("graphs", "/deps/agent/../escape.py:graph"), + ("store", "/deps/agent/source/../../escape.py:embed"), + ("http", "/deps/agent/../escape.py:app"), + ], +) +def test_canonical_manifest_loader_rejects_absolute_copied_module_escapes( + tmp_path: Path, section: str, reference: str +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + if section == "graphs": + document["graphs"] = {"chat": reference} + elif section == "store": + document["store"] = {"index": {"embed": reference}} + else: + document["http"] = {"app": reference} + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="container"): + load_container_runtime_manifest_v1(manifest_path) + + +@pytest.mark.parametrize("reference", ["missing-symbol", ":graph", "bad-module!:graph"]) +def test_canonical_manifest_loader_rejects_nonimportable_package_references( + tmp_path: Path, reference: str +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["graphs"] = {"chat": reference} + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="importable package"): + load_container_runtime_manifest_v1(manifest_path) + + +def test_canonical_manifest_loader_rejects_present_null_auth_path( + tmp_path: Path, +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["auth"] = {"path": None} + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="auth.path"): + load_container_runtime_manifest_v1(manifest_path) + + +@pytest.mark.parametrize( + "reference", + ["", "agentseek_api.services.sample_graphs:_build_echo_graph.extra"], +) +def test_canonical_loader_and_runtime_consumer_reject_same_graph_reference( + tmp_path: Path, reference: str +) -> None: + from agentseek_api.services.langgraph_service import ( + GraphManifestError, + _load_module_symbol, + ) + + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["graphs"] = {"chat": reference} + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError): + load_container_runtime_manifest_v1(manifest_path) + with pytest.raises(GraphManifestError): + _load_module_symbol( + dotted_path=reference, + graph_id="chat", + field_name="graph", + manifest_path=manifest_path, + ) + + +@pytest.mark.parametrize( + "http", + [ + {"disable_mcp": "bad"}, + {"cors": {"max_age": "bad"}}, + {"app": "../host.py:app"}, + ], +) +def test_build_planning_and_preloaded_loading_share_http_rejection( + tmp_path: Path, http: dict[str, object] +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + host_document = json.loads(config.read_text(encoding="utf-8")) + host_document["http"] = http + config.write_text(json.dumps(host_document), encoding="utf-8") + + with pytest.raises(ContainerBuildError): + plan_container_image(config_path=config) + + manifest_path = write_sanitized_manifest(tmp_path) + manifest_document = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest_document["http"] = http + manifest_path.write_text(json.dumps(manifest_document), encoding="utf-8") + with pytest.raises(ContainerBuildError): + load_container_runtime_manifest_v1(manifest_path) + + +@pytest.mark.parametrize( + "patch", + [ + {"graphs": {"chat": {"graph": "chat.graph:graph", "unknown": True}}}, + {"store": {"index": {"unknown": True}}}, + {"auth": {"openapi": {"unknown": True}}}, + { + "auth": { + "openapi": { + "securitySchemes": { + "oidc": { + "type": "openIdConnect", + "openIdConnectUrl": "https://user:manifest-canary@id.example/config", + } + }, + "security": [{"oidc": []}], + } + } + }, + ], +) +def test_preloaded_loader_rejects_unknown_nested_or_credential_metadata_value_free( + tmp_path: Path, patch: dict[str, object] +) -> None: + manifest_path = write_sanitized_manifest(tmp_path) + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document.update(patch) + manifest_path.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(ContainerBuildError) as caught: + load_container_runtime_manifest_v1(manifest_path) + assert "manifest-canary" not in str(caught.value) + + +@pytest.mark.parametrize("flag", [False, True]) +def test_loaded_manifest_runtime_policy_matches_host_for_boolean_matrix( + tmp_path: Path, flag: bool +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + host = { + "graphs": {"chat": "chat.graph:graph"}, + "http": { + "app": "installed.web:app", + "disable_mcp": flag, + "disable_a2a": not flag, + "cors": { + "allow_origins": [], + "allow_methods": [], + "allow_headers": [], + "allow_credentials": False, + "expose_headers": [], + "max_age": 0, + }, + }, + "auth": { + "openapi": { + "securitySchemes": { + "key": {"type": "apiKey", "name": "x-key", "in": "header"} + }, + "security": [{"key": []}], + }, + "disable_studio_auth": flag, + }, + } + config.write_text(json.dumps(host), encoding="utf-8") + planned = plan_container_image(config_path=config).manifest + manifest_path = project / "manifest.v1.json" + manifest_path.write_bytes(planned.to_json_bytes()) + loaded = load_container_runtime_manifest_v1(manifest_path) + + assert interpret_host_runtime_policy( + host, config_path=config + ) == interpret_manifest_runtime_policy(loaded) + + +def _dockerfile_run_argv(text: str) -> list[list[str]]: + return [ + json.loads(line[line.index("[") :]) + for line in text.splitlines() + if line.startswith("RUN ") + ] + + +def _generated_python_check(text: str, needle: str) -> str: + return next( + argv[2] + for argv in _dockerfile_run_argv(text) + if argv[:2] == ["python", "-c"] and needle in argv[2] + ) + + +def _candidate_build_plan(root: Path): + project = make_graph_project(root) + wheel = project / "candidate.whl" + digest = _write_candidate_wheel(wheel) + return plan_container_image( + config_path=project / "agentseek.json", + runtime_artifact=candidate_runtime_artifact(wheel, digest), + ) + + +def test_candidate_renderer_stages_a_pep427_valid_wheel_filename( + tmp_path: Path, +) -> None: + text = render_build_dockerfile(_candidate_build_plan(tmp_path)).decode("utf-8") + canonical = "agentseek_api-0.3.0-py3-none-any.whl" + + assert f'"/opt/agentseek/runtime/{canonical}"' in text + assert f'"/opt/agentseek/runtime/{canonical}[embedded]"' in text + assert "/opt/agentseek/runtime/agentseek-api-0.3.0.whl" not in text + + +def test_windows_binary_flag_is_used_for_bundle_file_io( + monkeypatch: pytest.MonkeyPatch, +) -> None: + binary_flag = 0x8000 + monkeypatch.setattr(container_build.os, "O_BINARY", binary_flag, raising=False) + + assert container_build._output_file_flags() & binary_flag + assert container_build._source_file_flags() & binary_flag + + +def test_dockerfile_uses_manifest_labels_and_buildkit_pip_secret( + tmp_path: Path, +) -> None: + plan = build_plan_fixture(tmp_path) + dockerfile = render_build_dockerfile(plan) + text = dockerfile.decode("utf-8") + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=tmp_path / "bundle", + ) + assert "COPY app /deps/agent" in text + assert "COPY manifest.v1.json /opt/agentseek/manifest.v1.json" in text + assert "COPY runtime-constraints.txt /opt/agentseek/runtime-constraints.txt" in text + assert "org.agentseek.environment-contract=preloaded-v1" in text + assert "org.agentseek.runtime-manifest=/opt/agentseek/manifest.v1.json" in text + assert "org.agentseek.runtime-distribution=agentseek-api" in text + assert "org.agentseek.runtime-version=0.3.0" in text + assert "agentseek-api[embedded]==0.3.0" in text + assert 'python", "-m", "pip", "check' in text + assert "importlib.metadata" in text + assert "sysconfig.get_paths" in text + assert "--mount=type=secret,id=pip_config,target=/etc/pip.conf" in text + assert "pip.conf" not in "\n".join( + line for line in text.splitlines() if line.startswith("COPY") + ) + assert "ENTRYPOINT []" in text + assert ( + '"serve", "--environment-mode", "preloaded-v1", "--host", ' + '"0.0.0.0", "--port", "2024"' in text + ) + assert bundle.dockerfile.read_bytes() == dockerfile + assert ( + next( + item.sha256 + for item in bundle.inventory + if item.relative_path == "Dockerfile" + ) + == hashlib.sha256(dockerfile).hexdigest() + ) + assert read_archive_member(bundle.archive_bytes(), "Dockerfile") == dockerfile + + +def test_generated_image_command_ignores_hostile_workdir_and_pythonpath( + tmp_path: Path, +) -> None: + text = render_build_dockerfile(build_plan_fixture(tmp_path)).decode("utf-8") + command_line = next(line for line in text.splitlines() if line.startswith("CMD ")) + command = json.loads(command_line.removeprefix("CMD ")) + module_index = command.index("agentseek_api.cli") + probe_command = [*command[: module_index + 1], "version"] + hostile = tmp_path / "hostile" + package = hostile / "agentseek_api" + package.mkdir(parents=True) + marker = tmp_path / "hostile-image-bootstrap" + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "cli.py").write_text( + f"from pathlib import Path\nPath({str(marker)!r}).write_text('canary')\nraise SystemExit(23)\n", + encoding="utf-8", + ) + environment = dict(os.environ) + environment["PYTHONPATH"] = str(hostile) + + completed = subprocess.run( + probe_command, + cwd=hostile, + env=environment, + check=False, + capture_output=True, + text=True, + timeout=20, + ) + + assert completed.returncode == 0 + assert completed.stdout == "agentseek-api 0.3.0\n" + assert marker.exists() is False + + +def test_package_only_plan_does_not_copy_missing_app_directory( + tmp_path: Path, +) -> None: + plan = package_only_build_plan_fixture(tmp_path) + dockerfile = render_build_dockerfile(plan) + text = dockerfile.decode("utf-8") + + assert "COPY app /deps/agent" not in text + assert "WORKDIR /deps/agent" in text + materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=tmp_path / "package-only-bundle", + ) + + +@pytest.mark.parametrize( + "requirement", + [ + "https://user:password@example.invalid/pkg.whl", + "package @ https://user:password@example.invalid/pkg.whl", + "git+https://token@example.invalid/repo.git", + "package @ git+https://user%40name:secret@example.invalid/repo.git", + ], +) +def test_dependency_url_credentials_are_rejected(requirement: str) -> None: + with pytest.raises(ContainerBuildError, match="pip_config_file"): + validate_dependency_specification(requirement) + + +@pytest.mark.parametrize( + "requirement", + [ + "https://example.invalid/pkg.whl", + "package @ https://example.invalid/pkg.whl", + "package @ https://example.invalid/pkg.whl#sha256=" + "a" * 64, + "package @ https://example.invalid/pkg.whl#sha256=" + + "B" * 64 + + "&subdirectory=python/pkg", + "ordinary-package[extra]>=1; python_version >= '3.12'", + "./local dependency", + "../local", + "C:\\workspace\\local", + ], +) +def test_dependency_specification_accepts_v1_inputs(requirement: str) -> None: + validate_dependency_specification(requirement) + + +@pytest.mark.parametrize( + "requirement", + [ + "http://example.invalid/pkg.whl", + "file:///tmp/pkg.whl", + "git+file:///tmp/repo", + "git+https://example.invalid/repo.git", + "ftp://example.invalid/pkg.whl", + "ssh://example.invalid/repo", + "hg+ssh://example.invalid/repo", + "git@example.invalid:repo.git", + "custom+scheme://example.invalid/pkg", + "https://example.invalid/pkg.whl?token=secret", + "https://example.invalid/pkg.whl?%74oken=secret", + "https://example.invalid/pkg.whl%3Fauth=secret", + "https://example.invalid/pkg.whl#token=secret", + "https://example.invalid/pkg.whl#sha256=bad", + "https://example.invalid/pkg.whl#sha256=" + "a" * 64 + "&sha256=" + "b" * 64, + "https://example.invalid/pkg.whl#subdirectory=../escape", + "https://example.invalid/pkg.whl#subdirectory=not/./normalized", + "https://example.invalid/pkg.whl#subdirectory=wheel%26token=secret", + "https://example.invalid/pkg.whl%23token=secret", + ], +) +def test_dependency_specification_rejects_non_v1_urls(requirement: str) -> None: + with pytest.raises(ContainerBuildError, match="pip_config_file"): + validate_dependency_specification(requirement) + + +def test_external_pip_config_is_identity_only_secret_source(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + external = tmp_path / "private-pip.conf" + external.write_text("password=external-canary\n", encoding="utf-8") + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = str(external) + config.write_text(json.dumps(payload), encoding="utf-8") + + plan = plan_container_image(config_path=config) + + assert plan.pip_config_file == external + assert plan.pip_config_identity is not None + assert external not in ( + source.source_path for source in plan.selected_sources.values() + ) + assert "external-canary" not in repr(plan) + + +@pytest.mark.parametrize("kind", ["missing", "symlink", "directory", "unreadable"]) +def test_pip_config_requires_readable_regular_nonsymlink_file( + tmp_path: Path, kind: str +) -> None: + project = make_graph_project(tmp_path) + pip_config = tmp_path / "pip.conf" + if kind == "symlink": + target = tmp_path / "target.conf" + target.write_text("[global]\n", encoding="utf-8") + pip_config.symlink_to(target) + elif kind == "directory": + pip_config.mkdir() + elif kind == "unreadable": + pip_config.write_text("[global]\n", encoding="utf-8") + pip_config.chmod(0) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = str(pip_config) + config.write_text(json.dumps(payload), encoding="utf-8") + + try: + with pytest.raises(ContainerBuildError, match="readable regular file"): + plan_container_image(config_path=config) + finally: + if kind == "unreadable": + pip_config.chmod(0o600) + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFO is POSIX-only") +def test_pip_config_rejects_special_file_without_opening_it(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + pip_config = tmp_path / "pip.conf" + os.mkfifo(pip_config) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = str(pip_config) + config.write_text(json.dumps(payload), encoding="utf-8") + + with pytest.raises(ContainerBuildError, match="readable regular file"): + plan_container_image(config_path=config) + + +def test_renderer_preserves_plan_fields_and_authoritative_order(tmp_path: Path) -> None: + plan = build_plan_fixture(tmp_path) + plan = replace( + plan, + base_image="python:3.13-slim-bookworm", + python_version="3.13", + image_distro="bookworm", + dockerfile_lines=('RUN ["python", "-c", "print(\'trusted\')"]',), + ) + + text = render_build_dockerfile(plan).decode("utf-8") + + assert "FROM python:3.13-slim-bookworm" in text + assert '# agentseek-python-version="3.13"' in text + assert '# agentseek-image-distro="bookworm"' in text + user_install = text.index("/deps/agent") + custom = text.index("print('trusted')") + runtime_install = text.rindex("agentseek-api[embedded]==0.3.0") + manifest = text.index("COPY manifest.v1.json") + labels = text.index("LABEL org.agentseek.environment-contract") + assert user_install < custom < runtime_install < manifest < labels + + +@pytest.mark.parametrize("candidate", [False, True]) +def test_exact_runtime_install_forces_selected_artifact_replacement( + tmp_path: Path, candidate: bool +) -> None: + plan = ( + _candidate_build_plan(tmp_path) if candidate else build_plan_fixture(tmp_path) + ) + + commands = _dockerfile_run_argv(render_build_dockerfile(plan).decode()) + runtime_install = next( + argv + for argv in commands + if argv[:4] == ["python", "-m", "pip", "install"] + and any("agentseek-api" in operand.replace("_", "-") for operand in argv) + ) + + assert "--force-reinstall" in runtime_install + + +def _run_generated_check(script: str, setup: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-O", "-c", setup + "\n" + f"exec({script!r})"], + capture_output=True, + check=False, + text=True, + ) + + +@pytest.mark.parametrize("valid", [True, False]) +def test_candidate_hash_verifier_has_success_and_failure_paths_under_optimize( + tmp_path: Path, valid: bool +) -> None: + plan = _candidate_build_plan(tmp_path) + script = _generated_python_check( + render_build_dockerfile(plan).decode(), + "agentseek_api-0.3.0-py3-none-any.whl", + ) + assert plan.runtime_artifact.candidate_wheel is not None + candidate = tmp_path / "candidate-check.whl" + candidate.write_bytes( + plan.runtime_artifact.candidate_wheel.read_bytes() + if valid + else b"independently-invalid-candidate" + ) + setup = ( + "import pathlib\n" + "OriginalPath=pathlib.Path\n" + f"actual=OriginalPath({str(candidate)!r})\n" + "pathlib.Path=lambda value: actual if value==" + "'/opt/agentseek/runtime/agentseek_api-0.3.0-py3-none-any.whl' " + "else OriginalPath(value)" + ) + + completed = _run_generated_check(script, setup) + + assert (completed.returncode == 0) is valid, completed.stderr + + +_VALID_MANIFEST = ( + b'{"dependencies":["/deps/agent"],"graphs":{"chat":"chat.graph:graph"},' + b'"runtime":{"contract":"preloaded-v1","distribution":"agentseek-api",' + b'"version":"0.3.0"},"schema_version":1}\n' +) + + +@pytest.mark.parametrize("check", ["hash", "canonical", "parser"]) +@pytest.mark.parametrize("valid", [True, False]) +def test_manifest_verifier_has_success_and_failure_paths_under_optimize( + tmp_path: Path, check: str, valid: bool +) -> None: + script = _generated_python_check( + render_build_dockerfile(build_plan_fixture(tmp_path)).decode(), + "manifest.v1.json", + ) + manifest = tmp_path / f"manifest-{check}.json" + invalid = { + "hash": b'{"schema_version":1}\n', + "canonical": json.dumps( + json.loads(_VALID_MANIFEST), indent=2, sort_keys=False + ).encode() + + b"\n", + "parser": b"not-json\n", + } + manifest.write_bytes(_VALID_MANIFEST if valid else invalid[check]) + setup = ( + "import pathlib\n" + "OriginalPath=pathlib.Path\n" + f"actual=OriginalPath({str(manifest)!r})\n" + "pathlib.Path=lambda value: actual if value==" + "'/opt/agentseek/manifest.v1.json' else OriginalPath(value)" + ) + + completed = _run_generated_check(script, setup) + + assert (completed.returncode == 0) is valid, completed.stderr + + +@pytest.mark.parametrize("check", ["ownership", "version", "site-packages", "python"]) +@pytest.mark.parametrize("valid", [True, False]) +def test_runtime_verifier_has_success_and_failure_paths_under_optimize( + tmp_path: Path, check: str, valid: bool +) -> None: + script = _generated_python_check( + render_build_dockerfile(build_plan_fixture(tmp_path)).decode(), + "importlib.metadata", + ) + site_packages = tmp_path / "site-packages" + distribution_root = ( + tmp_path / "outside" + if check == "site-packages" and not valid + else site_packages + ) + module = distribution_root / "agentseek_api" / "cli.py" + module.parent.mkdir(parents=True) + module.write_text("", encoding="utf-8") + owned_file = ( + "agentseek_api/not-cli.py" + if check == "ownership" and not valid + else "agentseek_api/cli.py" + ) + version = "9.9.9" if check == "version" and not valid else "0.3.0" + python_version = "(3,11,0)" if check == "python" and not valid else "(3,12,0)" + setup = "\n".join( + ( + "import agentseek_api.cli,importlib.metadata,pathlib,sys,sysconfig", + f"site=pathlib.Path({str(site_packages)!r})", + f"distribution_root=pathlib.Path({str(distribution_root)!r})", + f"agentseek_api.cli.__file__={str(module)!r}", + "class Distribution:", + f" version={version!r}", + f" files=(importlib.metadata.PackagePath({owned_file!r}),)", + " def locate_file(self,item): return distribution_root/item", + "importlib.metadata.distribution=lambda name:Distribution()", + "sysconfig.get_paths=lambda:{'purelib':str(site),'platlib':str(site)}", + f"sys.version_info={python_version}", + ) + ) + + completed = _run_generated_check(script, setup) + + assert (completed.returncode == 0) is valid, completed.stderr + + +def test_renderer_json_escapes_install_operands_and_candidate_source( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + local = project / "local dependency" + local.mkdir() + (local / "requirements.txt").write_text("httpx>=0.27\n", encoding="utf-8") + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["dependencies"] = [ + "./local dependency", + "package @ https://example.invalid/pkg.whl#sha256=" + "a" * 64, + ] + config.write_text(json.dumps(payload), encoding="utf-8") + wheel = project / "candidate runtime.whl" + digest = _write_candidate_wheel(wheel) + plan = plan_container_image( + config_path=config, + runtime_artifact=candidate_runtime_artifact(wheel, digest), + ) + + text = render_build_dockerfile(plan).decode("utf-8") + + assert '"--requirement", "/deps/agent/local dependency/requirements.txt"' in text + assert '"package @ https://example.invalid/pkg.whl#sha256=' in text + assert 'COPY ["runtime/candidate runtime.whl", "/opt/agentseek/runtime/' in text + assert text.index("candidate runtime.whl") < text.index( + "agentseek_api-0.3.0-py3-none-any.whl[embedded]" + ) + + +def test_bundle_excludes_env_and_records_only_selected_regular_files( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + (project / ".env").write_text("TOKEN=build-canary", encoding="utf-8") + (project / "asset.txt").write_text("allowed", encoding="utf-8") + + plan = plan_container_image( + config_path=project / "agentseek.json", + dotenv_paths=(project / ".env",), + build_include=("asset.txt",), + ) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert not (bundle.context / ".env").exists() + assert b"build-canary" not in bundle.archive_bytes() + assert {entry.relative_path for entry in bundle.inventory} == { + "app/asset.txt", + "app/chat/__init__.py", + "app/chat/graph.py", + "manifest.v1.json", + "runtime-constraints.txt", + "Dockerfile", + } + + +def test_published_runtime_artifact_is_exact_and_path_free() -> None: + artifact = PUBLISHED_RUNTIME_ARTIFACT + assert artifact.source is RuntimeArtifactSource.PUBLISHED_INDEX + assert artifact.requirement == "agentseek-api[embedded]==0.3.0" + assert artifact.candidate_wheel is None + assert artifact.candidate_sha256 is None + + +def test_install_actions_are_separate_from_runtime_import_roots(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + source_only = project / "extras" + source_only.mkdir() + (source_only / "helper.py").write_text("VALUE = 1\n", encoding="utf-8") + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["dependencies"] = [".", "./extras", "httpx>=0.27"] + config.write_text(json.dumps(payload), encoding="utf-8") + + plan = plan_container_image(config_path=config) + + assert [action.kind for action in plan.install_actions] == [ + InstallActionKind.SOURCE_ONLY, + InstallActionKind.SOURCE_ONLY, + InstallActionKind.PEP508, + ] + assert plan.manifest.dependencies == ("/deps/agent", "/deps/agent/extras") + + +def test_manifest_serialization_omits_absent_and_preserves_explicit_values( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": { + "chat": { + "graph": "chat.graph:graph", + "name": "", + "input_schema": {"nullable": None}, + } + }, + "store": {"ttl": {"refresh_on_read": False, "default_ttl": 0}}, + "http": { + "disable_mcp": False, + "disable_a2a": True, + "cors": {"allow_origins": [], "max_age": 0}, + }, + "auth": {"disable_studio_auth": False}, + } + ), + encoding="utf-8", + ) + + document = json.loads( + plan_container_image(config_path=config).manifest.to_json_bytes() + ) + + graph = document["graphs"]["chat"] + assert "prepare_input" not in graph + assert "extract_output" not in graph + assert graph["name"] == "" + assert graph["input_schema"]["nullable"] is None + assert document["store"]["ttl"] == { + "refresh_on_read": False, + "default_ttl": 0, + } + assert document["http"]["cors"] == {"allow_origins": [], "max_age": 0} + assert document["auth"]["disable_studio_auth"] is False + + +@pytest.mark.parametrize( + "payload,path", + [ + ({"store": {"index": {"api_key": "manifest-canary"}}}, "store.index"), + ({"http": {"unknown": True}}, "http"), + ({"auth": {"unknown": "manifest-canary"}}, "auth"), + ], +) +def test_unknown_or_launch_only_manifest_fields_fail_value_free( + tmp_path: Path, payload: dict[str, object], path: str +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + document = {"graphs": {"chat": "chat.graph:graph"}, **payload} + config.write_text(json.dumps(document), encoding="utf-8") + + with pytest.raises(Exception, match=path) as caught: + plan_container_image(config_path=config) + + assert "manifest-canary" not in str(caught.value) + assert "manifest-canary" not in repr(caught.value) + + +def test_selected_source_reasons_merge_for_same_source(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + plan = plan_container_image( + config_path=project / "agentseek.json", + build_include=("chat/graph.py",), + ) + assert plan.selected_sources["app/chat/graph.py"].reasons == frozenset( + {SourceReason.GRAPH, SourceReason.BUILD_INCLUDE, SourceReason.DEPENDENCY} + ) + + +def test_deterministic_archive_rejects_changed_context(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + bundle = materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + first = bundle.archive_bytes() + assert first == bundle.archive_bytes() + with tarfile.open(fileobj=io.BytesIO(first), mode="r:") as archive: + assert archive.getnames() == sorted( + entry.relative_path for entry in bundle.inventory + ) + assert all(member.uid == member.gid == member.mtime == 0 for member in archive) + + (bundle.context / "app/chat/graph.py").write_text("changed\n", encoding="utf-8") + with pytest.raises(Exception, match="changed"): + bundle.archive_bytes() + + +def test_static_incompatible_runtime_pin_fails_with_migration_guidance( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + (project / "requirements.txt").write_text( + "agentseek-api==0.2.2\n", encoding="utf-8" + ) + with pytest.raises(Exception, match=r"0\.3\.0.*migrat"): + plan_container_image(config_path=project / "agentseek.json") + + +def test_direct_https_is_install_action_not_manifest_path(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + url = "https://packages.example.invalid/fixture.whl" + payload["dependencies"] = [url] + config.write_text(json.dumps(payload), encoding="utf-8") + plan = plan_container_image(config_path=config) + assert [(item.kind, item.operand) for item in plan.install_actions] == [ + (InstallActionKind.PEP508, url) + ] + assert plan.manifest.dependencies == () + assert url not in plan.manifest.to_json_bytes().decode() + + +def test_generated_up_auth_preserves_empty_and_rewrites_local_file( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + auth = project / "auth.py" + auth.write_text("auth = object()\n", encoding="utf-8") + plan = plan_container_image(config_path=project / "agentseek.json") + origin = EnvironmentOrigin("launch", "AUTH_MODULE_PATH") + + absent_plan, absent_patch = plan_generated_up_auth(plan, None) + assert absent_patch is None + assert all( + SourceReason.AUTH not in source.reasons + for source in absent_plan.selected_sources.values() + ) + + empty_plan, empty_patch = plan_generated_up_auth( + plan, FinalAuthSelection(value="", origin=origin) + ) + assert empty_patch == AuthPayloadPatch("") + assert empty_plan.selected_sources == plan.selected_sources + + local_plan, local_patch = plan_generated_up_auth( + plan, FinalAuthSelection(value="auth.py:auth", origin=origin) + ) + assert local_patch == AuthPayloadPatch("/deps/agent/auth.py:auth") + assert local_plan.selected_sources["app/auth.py"].reasons == frozenset( + {SourceReason.AUTH, SourceReason.DEPENDENCY} + ) + + +def test_candidate_wheel_hash_and_metadata_are_verified(tmp_path: Path) -> None: + wheel = tmp_path / "agentseek_api-0.3.0-py3-none-any.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "agentseek_api-0.3.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: agentseek-api\nVersion: 0.3.0\n", + ) + digest = hashlib.sha256(wheel.read_bytes()).hexdigest() + artifact = candidate_runtime_artifact(wheel, digest) + assert artifact.source is RuntimeArtifactSource.CANDIDATE_WHEEL + assert artifact.candidate_sha256 == digest + + +@pytest.mark.parametrize("value", ["../escape", "/outside"]) +def test_build_include_rejects_escape(tmp_path: Path, value: str) -> None: + project = make_graph_project(tmp_path) + with pytest.raises(Exception, match="project"): + plan_container_image( + config_path=project / "agentseek.json", build_include=(value,) + ) + + +@pytest.mark.parametrize( + ("marker", "expected_kind", "expected_operand_suffix", "runtime_root"), + [ + ("pyproject.toml", InstallActionKind.PROJECT, "/dep", None), + ("setup.py", InstallActionKind.PROJECT, "/dep", None), + ( + "requirements.txt", + InstallActionKind.REQUIREMENTS, + "/dep/requirements.txt", + None, + ), + (None, InstallActionKind.SOURCE_ONLY, "/dep", "/deps/agent/dep"), + ], +) +def test_local_dependency_classification_is_exact( + tmp_path: Path, + marker: str | None, + expected_kind: InstallActionKind, + expected_operand_suffix: str, + runtime_root: str | None, +) -> None: + project = make_graph_project(tmp_path) + dependency = project / "dep" + dependency.mkdir() + (dependency / "module.py").write_text("VALUE = 1\n", encoding="utf-8") + if marker == "pyproject.toml": + (dependency / marker).write_text( + '[project]\nname="fixture"\nversion="1.0.0"\n', encoding="utf-8" + ) + elif marker == "setup.py": + (dependency / marker).write_text( + "raise RuntimeError('must never execute on host')\n", encoding="utf-8" + ) + elif marker == "requirements.txt": + (dependency / marker).write_text("httpx>=0.27\n", encoding="utf-8") + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["dependencies"] = ["./dep"] + config.write_text(json.dumps(payload), encoding="utf-8") + + plan = plan_container_image(config_path=config) + + assert len(plan.install_actions) == 1 + assert plan.install_actions[0].kind is expected_kind + assert plan.install_actions[0].operand.endswith(expected_operand_suffix) + assert plan.manifest.dependencies == ( + () if runtime_root is None else (runtime_root,) + ) + + +def test_structured_hooks_http_store_and_auth_are_selected_and_normalized( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + for filename in ("prepare.py", "extract.py", "embed.py", "web.py", "auth.py"): + (project / filename).write_text("value = object()\n", encoding="utf-8") + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": { + "chat": { + "graph": "chat.graph:graph", + "prepare_input": "./prepare.py:value", + "extract_output": "./extract.py:value", + } + }, + "store": {"index": {"embed": "./embed.py:value", "dims": 0}}, + "http": {"app": "./web.py:value"}, + "auth": {"path": "./auth.py:value"}, + } + ), + encoding="utf-8", + ) + + plan = plan_container_image(config_path=config) + document = plan.manifest.to_json_object() + + assert plan.selected_sources["app/prepare.py"].reasons == frozenset( + {SourceReason.GRAPH_HOOK} + ) + assert plan.selected_sources["app/extract.py"].reasons == frozenset( + {SourceReason.GRAPH_HOOK} + ) + assert plan.selected_sources["app/embed.py"].reasons == frozenset( + {SourceReason.STORE_HOOK} + ) + assert plan.selected_sources["app/web.py"].reasons == frozenset( + {SourceReason.HTTP_APP} + ) + assert plan.selected_sources["app/auth.py"].reasons == frozenset( + {SourceReason.AUTH} + ) + assert document["graphs"]["chat"]["prepare_input"] == "/deps/agent/prepare.py:value" + assert document["store"]["index"]["embed"] == "/deps/agent/embed.py:value" + assert document["http"]["app"] == "/deps/agent/web.py:value" + assert "path" not in document["auth"] + + +def test_package_only_project_materializes_without_app_sources(tmp_path: Path) -> None: + project = tmp_path / "project" + project.mkdir() + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "installed.graph:graph"}, + "dependencies": ["installed-package>=1"], + } + ), + encoding="utf-8", + ) + plan = plan_container_image(config_path=config) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + assert not any(entry.relative_path.startswith("app/") for entry in bundle.inventory) + assert bundle.dockerfile.read_bytes() == b"FROM scratch\n" + + +@pytest.mark.skipif(not hasattr(os, "mkfifo"), reason="FIFO is POSIX-only") +def test_build_include_rejects_special_file(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + fifo = project / "pipe" + os.mkfifo(fifo) + with pytest.raises(Exception, match="regular file"): + plan_container_image( + config_path=project / "agentseek.json", build_include=("pipe",) + ) + + +def test_selected_tree_rejects_escaping_symlink(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + outside = tmp_path / "outside.txt" + outside.write_text("outside", encoding="utf-8") + (project / "escape").symlink_to(outside) + with pytest.raises(Exception, match="symlink"): + plan_container_image(config_path=project / "agentseek.json") + + +def test_explicit_config_dotenv_and_vcs_includes_are_rejected(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + dotenv = project / ".env" + dotenv.write_text("TOKEN=canary\n", encoding="utf-8") + vcs = project / ".git" + vcs.mkdir() + (vcs / "config").write_text("canary", encoding="utf-8") + for include, match in [ + ("agentseek.json", "excluded"), + (".env", "excluded"), + (".git", "VCS"), + ]: + with pytest.raises(Exception, match=match): + plan_container_image( + config_path=project / "agentseek.json", + dotenv_paths=(dotenv,), + build_include=(include,), + ) + + +def test_invalid_dotenv_fails_before_bundle_creation(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + dotenv = project / ".env" + dotenv.write_bytes(b"GOOD=1\n\xff") + output = tmp_path / "bundle" + with pytest.raises(Exception, match="UTF-8"): + plan = plan_container_image( + config_path=project / "agentseek.json", dotenv_paths=(dotenv,) + ) + materialize_build_bundle( + plan, dockerfile_bytes=b"FROM scratch\n", output_root=output + ) + assert not output.exists() + + +@pytest.mark.parametrize("contents", [None, b"VALID=1\nthis is not an assignment\n"]) +def test_missing_or_malformed_dotenv_fails_closed( + tmp_path: Path, contents: bytes | None +) -> None: + project = make_graph_project(tmp_path) + dotenv = project / "selected.env" + if contents is not None: + dotenv.write_bytes(contents) + with pytest.raises(Exception, match="Env file|dotenv"): + plan_container_image( + config_path=project / "agentseek.json", dotenv_paths=(dotenv,) + ) + + +def test_duplicate_content_is_retained_at_distinct_destinations(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + (project / "first.txt").write_text("same", encoding="utf-8") + (project / "second.txt").write_text("same", encoding="utf-8") + plan = plan_container_image( + config_path=project / "agentseek.json", + build_include=("first.txt", "second.txt"), + ) + assert "app/first.txt" in plan.selected_sources + assert "app/second.txt" in plan.selected_sources + + +def test_credential_dependency_url_fails_without_value_disclosure( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["dependencies"] = [ + "fixture @ https://user:dependency-canary@packages.example/fixture.whl" + ] + config.write_text(json.dumps(payload), encoding="utf-8") + with pytest.raises(Exception, match="credentials") as caught: + plan_container_image(config_path=config) + assert "dependency-canary" not in str(caught.value) + + +def test_compatible_static_runtime_pin_is_accepted_and_constrained( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + (project / "requirements.txt").write_text( + "agentseek-api>=0.3,<0.4\n", encoding="utf-8" + ) + bundle = materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + assert (bundle.context / "runtime-constraints.txt").read_text() == ( + "agentseek-api==0.3.0\n" + ) + + +def test_provider_payload_is_structurally_absent_from_plan_and_bundle( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["env"] = {"OPENAI_API_KEY": "provider-structural-canary"} + config.write_text(json.dumps(payload), encoding="utf-8") + plan = plan_container_image(config_path=config) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + assert "provider-structural-canary" not in repr(plan) + assert b"provider-structural-canary" not in bundle.archive_bytes() + + +def test_pip_config_is_retained_only_as_secret_source_not_copied( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + pip_config = project / "pip.conf" + pip_config.write_text("password=pip-canary\n", encoding="utf-8") + config = project / "agentseek.json" + payload = json.loads(config.read_text(encoding="utf-8")) + payload["pip_config_file"] = "./pip.conf" + config.write_text(json.dumps(payload), encoding="utf-8") + plan = plan_container_image(config_path=config) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + assert plan.pip_config_file == pip_config + assert b"pip-canary" not in bundle.archive_bytes() + + +def _write_candidate_wheel(path: Path, *, version: str = "0.3.0") -> str: + with zipfile.ZipFile(path, "w") as archive: + archive.writestr( + f"agentseek_api-{version}.dist-info/METADATA", + f"Metadata-Version: 2.1\nName: agentseek-api\nVersion: {version}\n", + ) + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def test_candidate_wrong_metadata_and_symlink_are_rejected(tmp_path: Path) -> None: + wrong = tmp_path / "wrong.whl" + wrong_digest = _write_candidate_wheel(wrong, version="0.2.2") + with pytest.raises(Exception, match="identity"): + candidate_runtime_artifact(wrong, wrong_digest) + link = tmp_path / "link.whl" + link.symlink_to(wrong) + with pytest.raises(Exception, match="regular"): + candidate_runtime_artifact(link, wrong_digest) + + +def test_candidate_inconsistent_states_and_swaps_fail_closed(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + wheel = project / "candidate.whl" + digest = _write_candidate_wheel(wheel) + with pytest.raises(Exception, match="published"): + RuntimeArtifactV1( + distribution="agentseek-api", + extra="embedded", + version="0.3.0", + source=RuntimeArtifactSource.PUBLISHED_INDEX, + candidate_wheel=wheel, + candidate_sha256=digest, + ) + with pytest.raises(Exception, match="SHA-256"): + candidate_runtime_artifact(wheel, "0" * 64) + + artifact = candidate_runtime_artifact(wheel, digest) + plan = plan_container_image( + config_path=project / "agentseek.json", runtime_artifact=artifact + ) + assert [ + destination + for destination, selected in plan.selected_sources.items() + if selected.source_path == wheel + ] == ["runtime/candidate.whl"] + _write_candidate_wheel(wheel, version="0.2.2") + output = tmp_path / "candidate-bundle" + with pytest.raises(Exception, match="candidate wheel .*changed"): + materialize_build_bundle( + plan, dockerfile_bytes=b"FROM scratch\n", output_root=output + ) + assert not output.exists() + + +def test_candidate_same_bytes_inode_replacement_is_rejected(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + wheel = project / "candidate.whl" + digest = _write_candidate_wheel(wheel) + plan = plan_container_image( + config_path=project / "agentseek.json", + runtime_artifact=candidate_runtime_artifact(wheel, digest), + ) + replacement = tmp_path / "replacement.whl" + replacement.write_bytes(wheel.read_bytes()) + replacement.replace(wheel) + output = tmp_path / "same-bytes-bundle" + + with pytest.raises(Exception, match="identity"): + materialize_build_bundle( + plan, dockerfile_bytes=b"FROM scratch\n", output_root=output + ) + + assert not output.exists() + + +def test_candidate_wheel_must_be_confined_to_project(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + wheel = tmp_path / "outside.whl" + digest = _write_candidate_wheel(wheel) + with pytest.raises(Exception, match="project root"): + plan_container_image( + config_path=project / "agentseek.json", + runtime_artifact=candidate_runtime_artifact(wheel, digest), + ) + + +def test_auth_precedence_and_package_override_remove_only_auth_reason( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + nested = project / "config" + nested.mkdir(parents=True) + (project / "pyproject.toml").write_text( + '[project]\nname="fixture"\nversion="1.0"\n', encoding="utf-8" + ) + local_a = project / "mapping.py" + local_b = nested / "dedicated.py" + local_a.write_text("auth = object()\n", encoding="utf-8") + local_b.write_text("auth = object()\n", encoding="utf-8") + config = nested / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "installed.graph:graph"}, + "env": {"AUTH_MODULE_PATH": "mapping.py:auth"}, + "auth": {"path": "./dedicated.py:auth"}, + } + ), + encoding="utf-8", + ) + plan = plan_container_image(config_path=config) + assert "app/config/dedicated.py" in plan.selected_sources + assert "app/mapping.py" not in plan.selected_sources + origin = EnvironmentOrigin("launch", "AUTH_MODULE_PATH") + overridden, patch = plan_generated_up_auth( + plan, FinalAuthSelection("package.auth:auth", origin) + ) + assert patch == AuthPayloadPatch("package.auth:auth") + assert "app/config/dedicated.py" not in overridden.selected_sources + + +def test_openapi_security_metadata_round_trips_and_rejects_credentials( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + base = { + "graphs": {"chat": "chat.graph:graph"}, + "auth": { + "openapi": { + "securitySchemes": { + "oauth": { + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://id.example/authorize", + "tokenUrl": "https://id.example/token", + "scopes": {"read": "Read"}, + } + }, + } + }, + "security": [{"oauth": ["read"]}], + } + }, + } + config.write_text(json.dumps(base), encoding="utf-8") + document = plan_container_image(config_path=config).manifest.to_json_object() + assert document["auth"]["openapi"] == base["auth"]["openapi"] + + base["auth"]["openapi"]["securitySchemes"]["oauth"]["flows"]["authorizationCode"][ + "tokenUrl" + ] = "https://user:manifest-canary@id.example/token" + config.write_text(json.dumps(base), encoding="utf-8") + with pytest.raises(Exception, match="credential-bearing") as caught: + plan_container_image(config_path=config) + assert "manifest-canary" not in str(caught.value) + + +@pytest.mark.parametrize("app_ref", ["installed.web:app", "./web.py:app"]) +def test_http_and_auth_policy_effect_matches_host_config( + tmp_path: Path, app_ref: str +) -> None: + project = make_graph_project(tmp_path) + (project / "web.py").write_text("app = object()\n", encoding="utf-8") + config = project / "agentseek.json" + http = { + "disable_mcp": True, + "disable_a2a": False, + "app": app_ref, + "cors": { + "allow_origins": ["https://example.test"], + "allow_origin_regex": "https://.*", + "allow_methods": ["GET"], + "allow_headers": ["x-test"], + "allow_credentials": False, + "expose_headers": [], + "max_age": 0, + }, + } + auth = { + "openapi": { + "securitySchemes": { + "apiKey": {"type": "apiKey", "name": "x-api-key", "in": "header"} + }, + "security": [{"apiKey": []}], + }, + "disable_studio_auth": True, + } + host_payload = { + "graphs": {"chat": "chat.graph:graph"}, + "http": http, + "auth": auth, + } + config.write_text(json.dumps(host_payload), encoding="utf-8") + + manifest = plan_container_image(config_path=config).manifest + manifest_path = project / "manifest.v1.json" + manifest_path.write_bytes(manifest.to_json_bytes()) + loaded = load_container_runtime_manifest_v1(manifest_path) + + assert interpret_host_runtime_policy( + host_payload, config_path=config + ) == interpret_manifest_runtime_policy(loaded) + + +def test_selected_source_collision_rules_are_fail_closed(tmp_path: Path) -> None: + first = tmp_path / "first.py" + second = tmp_path / "second.py" + first.write_text("FIRST = 1\n", encoding="utf-8") + second.write_text("SECOND = 1\n", encoding="utf-8") + selected: dict[str, object] = {} + container_build._add_selected( # noqa: SLF001 - invariant-level regression + selected, + destination="app/first.py", + source=first, + reason=SourceReason.GRAPH, + ) + container_build._add_selected( # noqa: SLF001 - explicit second destination + selected, + destination="copy/first.py", + source=first, + reason=SourceReason.BUILD_INCLUDE, + ) + assert len(selected) == 2 + with pytest.raises(Exception, match="same destination"): + container_build._add_selected( # noqa: SLF001 - collision regression + selected, + destination="app/first.py", + source=second, + reason=SourceReason.AUTH, + ) + + +def test_shared_auth_source_loses_only_auth_reason_on_package_override( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + project.mkdir() + shared = project / "shared.py" + shared.write_text("graph = auth = object()\n", encoding="utf-8") + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "./shared.py:graph"}, + "auth": {"path": "./shared.py:auth"}, + "build_include": ["shared.py"], + } + ), + encoding="utf-8", + ) + plan = plan_container_image(config_path=config) + origin = EnvironmentOrigin("launch", "AUTH_MODULE_PATH") + overridden, _ = plan_generated_up_auth( + plan, FinalAuthSelection("installed.auth:auth", origin) + ) + assert overridden.selected_sources["app/shared.py"].reasons == frozenset( + {SourceReason.GRAPH, SourceReason.BUILD_INCLUDE} + ) + + +def test_generated_up_rejects_outside_local_auth_override(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + outside = tmp_path / "outside.py" + outside.write_text("auth = object()\n", encoding="utf-8") + plan = plan_container_image(config_path=project / "agentseek.json") + origin = EnvironmentOrigin("launch", "AUTH_MODULE_PATH") + with pytest.raises(Exception, match="project root"): + plan_generated_up_auth(plan, FinalAuthSelection(f"{outside}:auth", origin)) + + +def test_top_level_runtime_shadow_source_is_rejected(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + shadow = project / "agentseek_api.py" + shadow.write_text("SHADOW = True\n", encoding="utf-8") + with pytest.raises(Exception, match="shadow"): + plan_container_image( + config_path=project / "agentseek.json", + build_include=("agentseek_api.py",), + ) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX mode assertion") +def test_existing_wrong_mode_output_root_is_rejected(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + output.mkdir(mode=0o755) + with pytest.raises(Exception, match="private"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + +def test_archive_rejects_added_symlink(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + bundle = materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + (bundle.context / "escape").symlink_to(project / "agentseek.json") + with pytest.raises(Exception, match="unsafe"): + bundle.archive_bytes() + + +# Task 4 Fix Round 1 regressions + + +def test_ordinary_selected_source_same_bytes_replacement_is_rejected( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + source = project / "asset.txt" + source.write_text("stable", encoding="utf-8") + plan = plan_container_image( + config_path=project / "agentseek.json", build_include=("asset.txt",) + ) + replacement = project / "replacement.txt" + replacement.write_bytes(source.read_bytes()) + replacement.replace(source) + + with pytest.raises(Exception, match="identity"): + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert not (tmp_path / "bundle").exists() + + +def test_ordinary_selected_source_hash_is_rechecked_when_metadata_is_restored( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + source = project / "asset.txt" + source.write_text("safe-content", encoding="utf-8") + plan = plan_container_image( + config_path=project / "agentseek.json", build_include=("asset.txt",) + ) + frozen = source.stat() + source.write_text("evil-content", encoding="utf-8") + os.utime(source, ns=(frozen.st_atime_ns, frozen.st_mtime_ns)) + + with pytest.raises(Exception, match="hash|changed"): + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert not (tmp_path / "bundle").exists() + + +def test_intermediate_directory_symlink_swap_is_rejected_without_reading_canary( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + selected_dir = project / "assets" + selected_dir.mkdir() + selected_file = selected_dir / "value.txt" + selected_file.write_text("safe", encoding="utf-8") + plan = plan_container_image( + config_path=project / "agentseek.json", build_include=("assets/value.txt",) + ) + moved = project / "assets-original" + selected_dir.rename(moved) + outside = tmp_path / "outside-assets" + outside.mkdir() + (outside / "value.txt").write_text("external-directory-canary", encoding="utf-8") + selected_dir.symlink_to(outside, target_is_directory=True) + + with pytest.raises(Exception, match="project root|symlink|identity") as caught: + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert "external-directory-canary" not in str(caught.value) + assert not (tmp_path / "bundle").exists() + + +@pytest.mark.parametrize("vcs_name", [".git", ".hg", ".svn", ".bzr"]) +def test_all_supported_vcs_metadata_is_recursively_excluded( + tmp_path: Path, vcs_name: str +) -> None: + project = make_graph_project(tmp_path) + vcs = project / vcs_name + vcs.mkdir() + (vcs / "secret").write_text("vcs-canary", encoding="utf-8") + + plan = plan_container_image(config_path=project / "agentseek.json") + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert b"vcs-canary" not in bundle.archive_bytes() + with pytest.raises(Exception, match="VCS"): + plan_container_image( + config_path=project / "agentseek.json", build_include=(vcs_name,) + ) + + +def test_public_manifest_mappings_are_deep_frozen(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + schemes = {"key": {"type": "apiKey", "name": "x-key", "in": "header"}} + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "auth": { + "openapi": { + "securitySchemes": schemes, + "security": [{"key": []}], + } + }, + } + ), + encoding="utf-8", + ) + manifest = plan_container_image(config_path=config).manifest + before = manifest.to_json_bytes() + schemes["key"]["name"] = "mutated" + with pytest.raises(TypeError): + manifest.auth.openapi.security_schemes["key"]["name"] = "mutated" # type: ignore[index] + assert manifest.to_json_bytes() == before + + +@pytest.mark.parametrize( + "payload", + [ + {"store": {"ttl": {"default_ttl": float("nan")}}}, + {"store": {"ttl": {"default_ttl": float("inf")}}}, + { + "graphs": { + "chat": { + "graph": "chat.graph:graph", + "input_schema": {"bad": float("nan")}, + } + } + }, + ], +) +def test_non_finite_manifest_numbers_are_rejected( + tmp_path: Path, payload: dict[str, object] +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + document = {"graphs": {"chat": "chat.graph:graph"}, **payload} + config.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(Exception, match="finite"): + plan_container_image(config_path=config) + + +@pytest.mark.parametrize( + "scheme", + [ + {"name": "x-key", "in": "header"}, + {"type": "apiKey", "in": "header"}, + {"type": "http"}, + {"type": "oauth2", "flows": {}}, + { + "type": "oauth2", + "flows": {"authorizationCode": {"scopes": {}}}, + }, + {"type": "openIdConnect"}, + ], +) +def test_openapi_scheme_type_specific_required_fields_fail_closed( + tmp_path: Path, scheme: dict[str, object] +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "auth": { + "openapi": {"securitySchemes": {"bad": scheme}, "security": []} + }, + } + ), + encoding="utf-8", + ) + with pytest.raises(Exception, match="required|type|flow"): + plan_container_image(config_path=config) + + +@pytest.mark.parametrize( + "scheme", + [ + {"type": "http", "scheme": "bearer", "bearerFormat": "JWT"}, + { + "type": "openIdConnect", + "openIdConnectUrl": "https://id.example/.well-known/openid-configuration", + }, + { + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://id.example/authorize", + "scopes": {}, + }, + "password": { + "tokenUrl": "https://id.example/token", + "scopes": {}, + }, + "clientCredentials": { + "tokenUrl": "https://id.example/token", + "refreshUrl": "https://id.example/refresh", + "scopes": {}, + }, + }, + }, + ], +) +def test_openapi_scheme_type_specific_valid_fields_round_trip( + tmp_path: Path, scheme: dict[str, object] +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "auth": { + "openapi": {"securitySchemes": {"valid": scheme}, "security": []} + }, + } + ), + encoding="utf-8", + ) + + document = plan_container_image(config_path=config).manifest.to_json_object() + + assert document["auth"]["openapi"]["securitySchemes"]["valid"] == scheme # type: ignore[index] + + +@pytest.mark.parametrize( + "url", + [ + "https://id.example/config?token=openapi-canary", + "https://id.example/config#access_token=openapi-canary", + ], +) +def test_openapi_query_and_unsafe_fragment_urls_are_value_free( + tmp_path: Path, url: str +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "auth": { + "openapi": { + "securitySchemes": { + "bad": { + "type": "openIdConnect", + "openIdConnectUrl": url, + } + }, + "security": [], + } + }, + } + ), + encoding="utf-8", + ) + + with pytest.raises(Exception, match="query|fragment") as caught: + plan_container_image(config_path=config) + assert "openapi-canary" not in str(caught.value) + + +@pytest.mark.parametrize( + "url", + [ + "https://packages.example/fixture.whl?token=query-canary", + "https://packages.example/fixture.whl#access_token=fragment-canary", + ], +) +def test_credential_query_and_unsafe_fragment_urls_are_rejected_value_free( + tmp_path: Path, url: str +) -> None: + project = make_graph_project(tmp_path) + config = project / "agentseek.json" + config.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "dependencies": [url]}), + encoding="utf-8", + ) + with pytest.raises(Exception, match="query|fragment|credential") as caught: + plan_container_image(config_path=config) + assert "canary" not in str(caught.value) + + +def test_config_dotenv_is_config_parent_relative_and_recursively_excluded( + tmp_path: Path, +) -> None: + project = tmp_path / "project" + config_dir = project / "config" + package = project / "chat" + config_dir.mkdir(parents=True) + package.mkdir() + (project / "pyproject.toml").write_text( + '[project]\nname="fixture"\nversion="1.0"\n', encoding="utf-8" + ) + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") + dotenv = config_dir / ".env" + dotenv.write_text("TOKEN=recursive-dotenv-canary\n", encoding="utf-8") + config = config_dir / "agentseek.json" + config.write_text( + json.dumps( + { + "graphs": {"chat": "../chat/graph.py:graph"}, + "dependencies": [".."], + "env": ".env", + } + ), + encoding="utf-8", + ) + + plan = plan_container_image(config_path=config, invocation_cwd=project) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert dotenv.resolve() in plan.excluded_paths + assert b"recursive-dotenv-canary" not in bundle.archive_bytes() + + +@pytest.mark.parametrize( + "source_kind", + ["config-mapping", "config-dotenv", "cli-dotenv", "launch"], +) +def test_relative_final_auth_uses_invocation_cwd_for_non_auth_origins( + tmp_path: Path, source_kind: str +) -> None: + project = tmp_path / "project" + config_dir = project / "config" + config_dir.mkdir(parents=True) + (project / "pyproject.toml").write_text( + '[project]\nname="fixture"\nversion="1.0"\n', encoding="utf-8" + ) + (project / "auth.py").write_text("auth = 'cwd'\n", encoding="utf-8") + (config_dir / "auth.py").write_text("auth = 'config'\n", encoding="utf-8") + config = config_dir / "agentseek.json" + config.write_text( + json.dumps({"graphs": {"chat": "installed.graph:graph"}}), + encoding="utf-8", + ) + plan = plan_container_image(config_path=config, invocation_cwd=project) + + updated, patch = plan_generated_up_auth( + plan, + FinalAuthSelection( + "auth.py:auth", EnvironmentOrigin(source_kind, "fixture.env") + ), + ) + + assert patch == AuthPayloadPatch("/deps/agent/auth.py:auth") + assert updated.selected_sources["app/auth.py"].source_path == project / "auth.py" + + +def test_relative_dedicated_auth_origin_uses_config_parent(tmp_path: Path) -> None: + project = tmp_path / "project" + config_dir = project / "config" + config_dir.mkdir(parents=True) + (project / "pyproject.toml").write_text( + '[project]\nname="fixture"\nversion="1.0"\n', encoding="utf-8" + ) + (project / "auth.py").write_text("auth = 'cwd'\n", encoding="utf-8") + selected = config_dir / "auth.py" + selected.write_text("auth = 'config'\n", encoding="utf-8") + config = config_dir / "agentseek.json" + config.write_text( + json.dumps({"graphs": {"chat": "installed.graph:graph"}}), + encoding="utf-8", + ) + plan = plan_container_image(config_path=config, invocation_cwd=project) + + updated, patch = plan_generated_up_auth( + plan, + FinalAuthSelection("auth.py:auth", EnvironmentOrigin("auth", str(config))), + ) + + assert patch == AuthPayloadPatch("/deps/agent/config/auth.py:auth") + assert updated.selected_sources["app/config/auth.py"].source_path == selected + + +@pytest.mark.parametrize("cwd_kind", ["missing", "regular-file"]) +def test_invocation_cwd_must_be_an_existing_directory( + tmp_path: Path, cwd_kind: str +) -> None: + project = make_graph_project(tmp_path) + invocation_cwd = tmp_path / cwd_kind + if cwd_kind == "regular-file": + invocation_cwd.write_text("not a directory", encoding="utf-8") + + with pytest.raises(Exception, match="invocation cwd"): + plan_container_image( + config_path=project / "agentseek.json", invocation_cwd=invocation_cwd + ) + + +@pytest.mark.skipif( + Path(tempfile.gettempdir()).absolute() == Path(tempfile.gettempdir()).resolve(), + reason="platform temporary directory has no lexical/canonical alias", +) +def test_planner_normalizes_macos_temporary_directory_alias() -> None: + with tempfile.TemporaryDirectory(prefix="agentseek-container-plan-") as directory: + lexical_root = Path(directory) + project = make_graph_project(lexical_root) + + plan = plan_container_image(config_path=project / "agentseek.json") + + assert plan.config_path == (project / "agentseek.json").resolve() + assert plan.project_root == project.resolve() + + +def test_planner_alias_normalization_does_not_accept_project_symlink( + tmp_path: Path, +) -> None: + project = make_graph_project(tmp_path) + alias = project / "config-alias.json" + alias.symlink_to(project / "agentseek.json") + + with pytest.raises(Exception, match="project root|unsafe intermediate"): + plan_container_image(config_path=alias) + + +def test_planner_rejects_missing_config(tmp_path: Path) -> None: + with pytest.raises(Exception, match="config source is missing"): + plan_container_image(config_path=tmp_path / "missing.json") + + +def test_planner_rejects_vcs_metadata_config(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + vcs_config = project / ".git" / "agentseek.json" + vcs_config.parent.mkdir() + vcs_config.write_text("{}", encoding="utf-8") + with pytest.raises(Exception, match="excluded VCS metadata"): + plan_container_image(config_path=vcs_config) + + +def test_output_writer_rejects_regular_file_ancestor(tmp_path: Path) -> None: + ancestor = tmp_path / "not-a-directory" + ancestor.write_text("must-survive", encoding="utf-8") + + with pytest.raises(Exception, match="output ancestor.*unsafe"): + container_build._write_file(ancestor / "artifact", b"blocked") # noqa: SLF001 + + assert ancestor.read_text(encoding="utf-8") == "must-survive" + + +def test_materialization_rejects_nested_output_ancestor_swap_without_external_write( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + captured = tmp_path / "captured-app" + outside = tmp_path / "outside" + outside.mkdir() + original_write = container_build._write_file # noqa: SLF001 + calls = 0 + + def swap_nested_parent_after_first_write(path: Path, data: bytes) -> None: + nonlocal calls + original_write(path, data) + calls += 1 + if calls == 1: + app = output / "context" / "app" + app.rename(captured) + app.symlink_to(outside, target_is_directory=True) + + monkeypatch.setattr( + container_build, "_write_file", swap_nested_parent_after_first_write + ) + + with pytest.raises(Exception, match="output.*(ancestor|directory|symlink|changed)"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert calls == 1 + assert not output.exists() + assert list(outside.iterdir()) == [] + assert (captured / "chat" / "__init__.py").is_file() + + +def test_materialization_rejects_nested_output_ancestor_inode_swap( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + captured = tmp_path / "captured-app" + original_write = container_build._write_file # noqa: SLF001 + calls = 0 + + def swap_nested_parent_after_first_write(path: Path, data: bytes) -> None: + nonlocal calls + original_write(path, data) + calls += 1 + if calls == 1: + app = output / "context" / "app" + app.rename(captured) + app.mkdir(mode=0o700) + (app / "chat").mkdir(mode=0o700) + + monkeypatch.setattr( + container_build, "_write_file", swap_nested_parent_after_first_write + ) + + with pytest.raises(Exception, match="output.*ancestor.*identity"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert calls == 1 + assert not output.exists() + assert (captured / "chat" / "__init__.py").is_file() + + +def test_materialization_rejects_output_context_swap_without_deleting_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + captured = tmp_path / "captured-context" + original_write = container_build._write_file # noqa: SLF001 + calls = 0 + + def swap_after_first_write(path: Path, data: bytes) -> None: + nonlocal calls + original_write(path, data) + calls += 1 + if calls == 1: + (output / "context").rename(captured) + (output / "context").mkdir(mode=0o700) + (output / "context" / "replacement-canary").write_text( + "must-survive", encoding="utf-8" + ) + + monkeypatch.setattr(container_build, "_write_file", swap_after_first_write) + + with pytest.raises(Exception, match="identity|changed"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert (output / "context" / "replacement-canary").read_text( + encoding="utf-8" + ) == "must-survive" + assert captured.is_dir() + + +def test_materialization_rejects_output_root_swap_without_deleting_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + captured = tmp_path / "captured-root" + original_write = container_build._write_file # noqa: SLF001 + calls = 0 + + def swap_after_first_write(path: Path, data: bytes) -> None: + nonlocal calls + original_write(path, data) + calls += 1 + if calls == 1: + output.rename(captured) + output.mkdir(mode=0o700) + (output / "replacement-canary").write_text("must-survive", encoding="utf-8") + + monkeypatch.setattr(container_build, "_write_file", swap_after_first_write) + + with pytest.raises(Exception, match="root identity"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert (output / "replacement-canary").read_text(encoding="utf-8") == ( + "must-survive" + ) + assert captured.is_dir() + + +def test_materialization_rejects_project_root_inode_swap(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + plan = plan_container_image(config_path=project / "agentseek.json") + captured = tmp_path / "captured-project" + project.rename(captured) + project.mkdir() + + with pytest.raises(Exception, match="project root identity"): + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert not (tmp_path / "bundle").exists() + + +def test_materialization_rejects_vanished_project_root(tmp_path: Path) -> None: + project = make_graph_project(tmp_path) + plan = plan_container_image(config_path=project / "agentseek.json") + project.rename(tmp_path / "captured-project") + + with pytest.raises(Exception, match="project root changed"): + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert not (tmp_path / "bundle").exists() + + +def test_materialization_fails_closed_when_private_output_creation_fails( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.secure_temp import SecureArtifactError + + project = make_graph_project(tmp_path) + + def reject_output(_path: Path) -> None: + raise SecureArtifactError("private output unavailable") + + monkeypatch.setattr(container_build, "create_private_directory", reject_output) + + with pytest.raises(Exception, match="could not be created"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + +def test_materialization_fails_closed_when_output_root_cannot_be_inspected( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + plan = plan_container_image(config_path=project / "agentseek.json") + output = tmp_path / "bundle" + original_lstat = Path.lstat + + def unreadable_output(path: Path) -> os.stat_result: + if path == output: + raise PermissionError("blocked") + return original_lstat(path) + + monkeypatch.setattr(Path, "lstat", unreadable_output) + + with pytest.raises(Exception, match="output root could not be verified"): + materialize_build_bundle( + plan, + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert not output.exists() + + +def test_materialization_fails_closed_when_os_write_makes_no_progress( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + monkeypatch.setattr(container_build.os, "write", lambda _fd, _data: 0) + + with pytest.raises(Exception, match="Could not write the build bundle"): + materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert not output.exists() + + +def test_materialization_writes_every_byte_when_os_write_is_partial( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + original_write = os.write + + def partial_write(fd: int, data: bytes | memoryview) -> int: + return original_write(fd, bytes(data[: max(1, len(data) // 2)])) + + monkeypatch.setattr(container_build.os, "write", partial_write) + bundle = materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=tmp_path / "bundle", + ) + + assert bundle.dockerfile.read_bytes() == b"FROM scratch\n" + assert bundle.manifest.read_bytes().endswith(b"\n") + assert bundle.archive_bytes() + + +def test_materialization_computes_each_inventory_digest_once( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + project = make_graph_project(tmp_path) + output = tmp_path / "bundle" + original_read_bytes = Path.read_bytes + + def reject_output_reopen(path: Path) -> bytes: + if output / "context" in (path, *path.parents): + raise AssertionError("frozen output bytes must drive inventory") + return original_read_bytes(path) + + monkeypatch.setattr(Path, "read_bytes", reject_output_reopen) + bundle = materialize_build_bundle( + plan_container_image(config_path=project / "agentseek.json"), + dockerfile_bytes=b"FROM scratch\n", + output_root=output, + ) + + assert bundle.inventory diff --git a/tests/unit/test_container_image_archive.py b/tests/unit/test_container_image_archive.py new file mode 100644 index 0000000..7dfbbae --- /dev/null +++ b/tests/unit/test_container_image_archive.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +import gzip +import hashlib +import importlib.util +import io +import json +import os +import subprocess +import sys +import tarfile +from pathlib import Path + +import pytest + + +SCANNER = Path("scripts/container_image_archive.py") + + +def _load_scanner(): + if not SCANNER.is_file(): + pytest.fail("the structural image archive scanner is missing") + spec = importlib.util.spec_from_file_location("container_image_archive", SCANNER) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _tar(entries: list[tuple[str, bytes]]) -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w") as archive: + for name, data in entries: + info = tarfile.TarInfo(name) + info.size = len(data) + archive.addfile(info, io.BytesIO(data)) + return output.getvalue() + + +def _tar_with_standard_directory_aliases() -> bytes: + output = io.BytesIO() + with tarfile.open(fileobj=output, mode="w") as archive: + for name in (".", "./usr/", "./usr/share/"): + info = tarfile.TarInfo(name) + info.type = tarfile.DIRTYPE + archive.addfile(info) + payload = b"safe" + info = tarfile.TarInfo("./usr/share/payload.txt") + info.size = len(payload) + archive.addfile(info, io.BytesIO(payload)) + return output.getvalue() + + +def _docker_save_archive( + *, + layer_payloads: list[bytes], + config: bytes = b'{"history":[]}', + config_reference: str = "config.json", + extra_entries: list[tuple[str, bytes]] | None = None, +) -> bytes: + layers = [f"layer-{index}/layer.tar" for index in range(len(layer_payloads))] + manifest = json.dumps( + [ + { + "Config": config_reference, + "RepoTags": ["synthetic:test"], + "Layers": layers, + } + ], + separators=(",", ":"), + ).encode() + entries = [("manifest.json", manifest), ("config.json", config)] + entries.extend(zip(layers, layer_payloads, strict=True)) + entries.extend(extra_entries or []) + return _tar(entries) + + +def _descriptor(payload: bytes, media_type: str) -> dict[str, object]: + return { + "digest": f"sha256:{hashlib.sha256(payload).hexdigest()}", + "mediaType": media_type, + "size": len(payload), + } + + +def _oci_archive( + *, + layer: bytes, + config: bytes = b'{"history":[]}', + repeat_layer: bool = False, +) -> bytes: + config_descriptor = _descriptor(config, "application/vnd.oci.image.config.v1+json") + layer_descriptor = _descriptor(layer, "application/vnd.oci.image.layer.v1.tar+gzip") + manifest = json.dumps( + { + "schemaVersion": 2, + "config": config_descriptor, + "layers": [layer_descriptor, layer_descriptor] + if repeat_layer + else [layer_descriptor], + }, + separators=(",", ":"), + ).encode() + manifest_descriptor = _descriptor( + manifest, "application/vnd.oci.image.manifest.v1+json" + ) + index = json.dumps( + {"schemaVersion": 2, "manifests": [manifest_descriptor]}, + separators=(",", ":"), + ).encode() + blobs = [(config_descriptor, config), (layer_descriptor, layer)] + blobs.append((manifest_descriptor, manifest)) + return _tar( + [ + ("oci-layout", b'{"imageLayoutVersion":"1.0.0"}'), + ("index.json", index), + *[ + (f"blobs/sha256/{descriptor['digest'][7:]}", payload) + for descriptor, payload in blobs + ], + ] + ) + + +def test_scanner_accepts_every_referenced_layer_and_no_trunc_history() -> None: + scanner = _load_scanner() + layers = [ + _tar([("first.txt", b"first-safe-payload")]), + gzip.compress(_tar([("second.txt", b"second-safe-payload")])), + ] + + scanner.scan_image_archive( + _docker_save_archive(layer_payloads=layers), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_scanner_accepts_standard_root_and_directory_tar_aliases() -> None: + scanner = _load_scanner() + + scanner.scan_image_archive( + _docker_save_archive(layer_payloads=[_tar_with_standard_directory_aliases()]), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_scanner_rejects_members_that_alias_the_same_canonical_path() -> None: + scanner = _load_scanner() + layer = _tar([("safe.txt", b"first"), ("./safe.txt", b"second")]) + + with pytest.raises(scanner.ImageArchiveError, match="duplicate"): + scanner.scan_image_archive( + _docker_save_archive(layer_payloads=[layer]), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def _docker_save_with_repeated_layer(layer: bytes) -> bytes: + reference = "shared/layer.tar" + manifest = json.dumps( + [ + { + "Config": "config.json", + "RepoTags": ["synthetic:test"], + "Layers": [reference, reference], + } + ], + separators=(",", ":"), + ).encode() + return _tar( + [ + ("manifest.json", manifest), + ("config.json", b'{"history":[]}'), + (reference, layer), + ] + ) + + +def test_scanner_accepts_a_reused_docker_save_layer_reference() -> None: + scanner = _load_scanner() + + scanner.scan_image_archive( + _docker_save_with_repeated_layer(_tar([("safe.txt", b"safe")])), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_reused_docker_save_layer_reference_cannot_hide_forbidden_bytes() -> None: + scanner = _load_scanner() + canary = b"high-entropy-canary" + + with pytest.raises(scanner.ImageArchiveError, match="forbidden bytes"): + scanner.scan_image_archive( + _docker_save_with_repeated_layer( + _tar([("payload.txt", b"prefix" + canary + b"suffix")]) + ), + forbidden=canary, + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_scanner_accepts_an_oci_index_and_verifies_referenced_blobs() -> None: + scanner = _load_scanner() + + scanner.scan_image_archive( + _oci_archive(layer=gzip.compress(_tar([("safe", b"safe")]))), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_scanner_accepts_an_identical_reused_oci_layer_descriptor() -> None: + scanner = _load_scanner() + + scanner.scan_image_archive( + _oci_archive( + layer=gzip.compress(_tar([("safe", b"safe")])), + repeat_layer=True, + ), + forbidden=b"high-entropy-canary", + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_reused_oci_layer_descriptor_cannot_hide_forbidden_bytes() -> None: + scanner = _load_scanner() + canary = b"high-entropy-canary" + + with pytest.raises(scanner.ImageArchiveError, match="forbidden bytes"): + scanner.scan_image_archive( + _oci_archive( + layer=gzip.compress(_tar([("safe", b"prefix" + canary)])), + repeat_layer=True, + ), + forbidden=canary, + history=b'{"CreatedBy":"safe"}\n', + ) + + +def test_scanner_rejects_an_oci_blob_digest_mismatch() -> None: + scanner = _load_scanner() + archive = _oci_archive(layer=gzip.compress(_tar([("safe", b"safe")]))) + + with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as source: + entries = [ + (member.name, source.extractfile(member).read()) + for member in source + if member.isfile() + ] + blob_index = next( + index for index, (name, _) in enumerate(entries) if name.startswith("blobs/") + ) + name, _ = entries[blob_index] + entries[blob_index] = (name, b"changed") + + with pytest.raises(scanner.ImageArchiveError, match="integrity"): + scanner.scan_image_archive( + _tar(entries), + forbidden=b"high-entropy-canary", + history=b"safe", + ) + + +@pytest.mark.parametrize("location", ["config", "layer", "history"]) +def test_scanner_finds_a_canary_in_each_decoded_image_surface(location: str) -> None: + scanner = _load_scanner() + canary = b"high-entropy-canary" + config = b'{"history":[]}' + layer = gzip.compress(_tar([("payload.txt", b"safe")])) + history = b'{"CreatedBy":"safe"}\n' + if location == "config": + config = b'{"history":["high-entropy-canary"]}' + elif location == "layer": + layer = gzip.compress(_tar([("payload.txt", canary)])) + else: + history = b'{"CreatedBy":"high-entropy-canary"}\n' + + with pytest.raises(scanner.ImageArchiveError, match="forbidden bytes") as captured: + scanner.scan_image_archive( + _docker_save_archive(layer_payloads=[layer], config=config), + forbidden=canary, + history=history, + ) + assert canary.decode() not in str(captured.value) + + +def test_scanner_cli_reads_the_canary_from_the_environment_without_disclosure( + tmp_path: Path, +) -> None: + canary = "high-entropy-canary" + archive = tmp_path / "image.tar" + history = tmp_path / "history.jsonl" + archive.write_bytes( + _docker_save_archive(layer_payloads=[_tar([("payload.txt", canary.encode())])]) + ) + history.write_bytes(b"safe") + completed = subprocess.run( + [ + sys.executable, + str(SCANNER), + "--archive", + str(archive), + "--history", + str(history), + ], + env={**os.environ, "AGENTSEEK_IMAGE_SCAN_SENTINEL": canary}, + capture_output=True, + text=True, + check=False, + ) + + assert completed.returncode == 1 + assert completed.stdout == "" + assert completed.stderr == "image archive boundary verification failed\n" + assert canary not in " ".join(completed.args) + assert canary not in completed.stderr + + +@pytest.mark.parametrize( + "archive", + [ + b"not a tar archive", + _docker_save_archive( + layer_payloads=[_tar([("safe", b"safe")])], + config_reference="missing.json", + ), + _docker_save_archive( + layer_payloads=[_tar([("safe", b"safe")])], + config_reference="../config.json", + ), + _docker_save_archive( + layer_payloads=[_tar([("safe", b"safe")])], + extra_entries=[("config.json", b"duplicate")], + ), + ], +) +def test_scanner_fails_closed_on_malformed_missing_duplicate_or_escape( + archive: bytes, +) -> None: + scanner = _load_scanner() + + with pytest.raises(scanner.ImageArchiveError): + scanner.scan_image_archive( + archive, + forbidden=hashlib.sha256(b"absent").hexdigest().encode(), + history=b"safe", + ) + + +def test_scanner_rejects_layer_member_path_escape() -> None: + scanner = _load_scanner() + layer = _tar([("../escape", b"safe")]) + + with pytest.raises(scanner.ImageArchiveError, match="path"): + scanner.scan_image_archive( + _docker_save_archive(layer_payloads=[layer]), + forbidden=b"absent-canary", + history=b"safe", + ) diff --git a/tests/unit/test_container_policy.py b/tests/unit/test_container_policy.py new file mode 100644 index 0000000..425060a --- /dev/null +++ b/tests/unit/test_container_policy.py @@ -0,0 +1,591 @@ +from __future__ import annotations + +from dataclasses import fields +from pathlib import Path +from types import MappingProxyType + +import pytest + +from agentseek_api.container_policy import ( + APPLICATION_COMPATIBILITY_KEYS, + APP_CONTAINER_POLICY, + ContainerPolicyError, + ContainerSelection, + HOST_RUNTIME_POLICY, + docker_control_environment, + select_application_payload, + select_compose_payload, +) +from agentseek_api.environment import ( + CommandDerivedAssignment, + EnvironmentPlan, + EnvironmentTarget, + resolve_environment, +) +from agentseek_api.settings import Settings +from tests.container_plan_helpers import resolved_fixture + + +def _plan( + tmp_path: Path, + *, + config_dotenv: str | None = None, + config_mapping: dict[str, str] | None = None, + launch: dict[str, str] | None = None, + cli_dotenv: str | None = None, + explicit_names: frozenset[str] = frozenset(), + assignments: tuple[CommandDerivedAssignment, ...] = (), +) -> EnvironmentPlan: + config_path = tmp_path / "langgraph.json" + config_path.write_text('{"graphs":{"chat":"chat.graph:graph"}}', encoding="utf-8") + config_env_path = None + if config_dotenv is not None: + config_env_path = tmp_path / "config.env" + config_env_path.write_text(config_dotenv, encoding="utf-8") + cli_env_path = None + if cli_dotenv is not None: + cli_env_path = tmp_path / "cli.env" + cli_env_path.write_text(cli_dotenv, encoding="utf-8") + return EnvironmentPlan( + config_path=config_path, + config_dotenv=config_env_path, + config_mapping=config_mapping or {}, + auth_path=None, + cli_dotenv=cli_env_path, + launch_environment=launch or {}, + command_assignments=assignments, + explicit_names=explicit_names, + ) + + +def test_application_payload_requires_declaration_but_preserves_empty() -> None: + resolved = resolved_fixture( + values={"DECLARED_EMPTY": "", "AMBIENT_ONLY": "secret"}, + declared_keys={"DECLARED_EMPTY"}, + ) + + payload = select_application_payload( + resolved, + ContainerSelection(pass_env=frozenset(), compose_env=frozenset()), + ) + + assert dict(payload) == {"DECLARED_EMPTY": ""} + + +def test_compose_selection_rejects_control_plane_collision() -> None: + with pytest.raises(ContainerPolicyError, match="DOCKER_HOST"): + select_compose_payload( + application_payload={"DOCKER_HOST": "application-value"}, + selected_names=frozenset({"DOCKER_HOST"}), + docker_control={"DOCKER_HOST": "unix:///var/run/docker.sock"}, + ) + + +def test_compose_selection_does_not_parse_or_resolve_dotenv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import agentseek_api.dotenv_adapter as dotenv_adapter + + def fail(*_args: object, **_kwargs: object) -> None: + raise AssertionError("Compose selection must not resolve dotenv sources") + + monkeypatch.setattr(dotenv_adapter, "parse_dotenv_document", fail) + monkeypatch.setattr(dotenv_adapter, "parse_dotenv_file", fail) + + payload = select_compose_payload( + application_payload=MappingProxyType({"TOKEN": "already-final"}), + selected_names=frozenset({"TOKEN"}), + docker_control=MappingProxyType({"PATH": "/safe/bin"}), + ) + + assert dict(payload) == {"TOKEN": "already-final"} + + +def test_typed_host_policy_matches_released_build_runtime_env_matrix( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + plan = _plan( + tmp_path, + config_dotenv="TOKEN=config\nRESULT=${TOKEN}/dotenv\n", + launch={"TOKEN": "shell", "INHERITED_EMPTY": ""}, + cli_dotenv="CLI_RESULT=${TOKEN}/cli\nVALUELESS\nEMPTY=\n", + ) + plan.config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":"./config.env"}', encoding="utf-8" + ) + plan = EnvironmentPlan( + config_path=plan.config_path, + config_dotenv=plan.config_dotenv, + config_mapping={}, + auth_path=None, + cli_dotenv=plan.cli_dotenv, + launch_environment=plan.launch_environment, + command_assignments=( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"AGENTSEEK_GRAPHS": str(plan.config_path)}, + reason="selected host config path", + ), + ), + explicit_names=frozenset(), + ) + expected = build_runtime_env( + config_path=plan.config_path, + env_file=str(plan.cli_dotenv), + cwd=tmp_path, + base_env=dict(plan.launch_environment), + ) + + resolved = resolve_environment(plan, HOST_RUNTIME_POLICY) + + assert dict(resolved.values) == expected + assert "VALUELESS" not in resolved.declared_keys + assert {"TOKEN", "RESULT", "CLI_RESULT", "EMPTY"} <= resolved.declared_keys + + +def test_container_document_collects_explicit_empty_but_not_bare_binding( + tmp_path: Path, +) -> None: + resolved = resolve_environment( + _plan(tmp_path, config_dotenv="EMPTY=\nBARE\n"), + APP_CONTAINER_POLICY, + ) + + assert "EMPTY" in resolved.declared_keys + assert "BARE" not in resolved.declared_keys + assert dict( + select_application_payload( + resolved, ContainerSelection(frozenset(), frozenset()) + ) + ) == {"EMPTY": ""} + + +def test_declared_key_uses_final_inherited_value_for_export(tmp_path: Path) -> None: + resolved = resolve_environment( + _plan(tmp_path, config_dotenv="TOKEN=dotenv\n", launch={"TOKEN": "shell"}), + APP_CONTAINER_POLICY, + ) + + assert dict( + select_application_payload( + resolved, ContainerSelection(frozenset(), frozenset()) + ) + ) == {"TOKEN": "shell"} + + +def test_unselected_ambient_reference_fails_before_payload_selection( + tmp_path: Path, +) -> None: + plan = _plan( + tmp_path, + config_dotenv="RESULT=${AMBIENT_ONLY}\n", + launch={"AMBIENT_ONLY": "secret"}, + ) + + with pytest.raises(ContainerPolicyError, match="AMBIENT_ONLY"): + resolve_environment(plan, APP_CONTAINER_POLICY) + + +def test_explicit_pass_env_allows_ambient_reference_and_export(tmp_path: Path) -> None: + resolved = resolve_environment( + _plan( + tmp_path, + config_dotenv="RESULT=${AMBIENT_ONLY}\n", + launch={"AMBIENT_ONLY": "secret"}, + explicit_names=frozenset({"AMBIENT_ONLY"}), + ), + APP_CONTAINER_POLICY, + ) + + payload = select_application_payload( + resolved, + ContainerSelection( + pass_env=frozenset({"AMBIENT_ONLY"}), compose_env=frozenset() + ), + ) + assert dict(payload) == {"AMBIENT_ONLY": "secret", "RESULT": "secret"} + + +def test_later_resolved_dotenv_binding_clears_earlier_unresolved_reference( + tmp_path: Path, +) -> None: + resolved = resolve_environment( + _plan(tmp_path, config_dotenv="VALUE=${MISSING}\nVALUE=ok\n"), + APP_CONTAINER_POLICY, + ) + + assert resolved.values["VALUE"] == "ok" + assert "VALUE" not in resolved.unresolved_references + + +def test_windows_container_interpolation_matches_explicit_name_case_insensitively( + tmp_path: Path, +) -> None: + resolved = resolve_environment( + _plan( + tmp_path, + config_dotenv="RESULT=${openai_api_key}\n", + launch={"OpenAI_Api_Key": "value"}, + ), + APP_CONTAINER_POLICY, + platform="win32", + ) + + assert resolved.values["RESULT"] == "value" + assert dict( + select_application_payload( + resolved, + ContainerSelection(pass_env=frozenset(), compose_env=frozenset()), + platform="win32", + ) + ) == {"OPENAI_API_KEY": "value", "RESULT": "value"} + + +def test_windows_rejects_two_spellings_of_a_launch_environment_name( + tmp_path: Path, +) -> None: + with pytest.raises(ContainerPolicyError, match="Path.*PATH"): + resolve_environment( + _plan(tmp_path, launch={"Path": "one", "PATH": "two"}), + APP_CONTAINER_POLICY, + platform="win32", + ) + + +def test_windows_launch_assignment_replaces_lower_source_case_insensitively( + tmp_path: Path, +) -> None: + resolved = resolve_environment( + _plan( + tmp_path, + config_dotenv="OPENAI_API_KEY=dotenv\n", + launch={"OpenAI_Api_Key": "launch"}, + ), + APP_CONTAINER_POLICY, + platform="win32", + ) + + assert dict( + select_application_payload( + resolved, + ContainerSelection(pass_env=frozenset(), compose_env=frozenset()), + platform="win32", + ) + ) == {"OPENAI_API_KEY": "launch"} + + +def test_windows_rejects_application_control_collision_across_spellings() -> None: + with pytest.raises(ContainerPolicyError, match="Path"): + select_compose_payload( + application_payload={"Path": "application-value"}, + selected_names=frozenset({"PATH"}), + docker_control={"PATH": "control-value"}, + platform="win32", + ) + + +def test_cli_builders_construct_target_scoped_command_assignments( + tmp_path: Path, +) -> None: + from agentseek_api.cli import ( + build_container_command_assignments, + build_host_environment_plan, + ) + + config_path = tmp_path / "langgraph.json" + config_path.write_text('{"graphs":{"chat":"chat.graph:graph"}}', encoding="utf-8") + host_plan = build_host_environment_plan( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={}, + role="dev", + ) + host = resolve_environment(host_plan, HOST_RUNTIME_POLICY) + omitted = build_container_command_assignments( + config_path=config_path, cwd=tmp_path, postgres_uri=None + ) + explicit = build_container_command_assignments( + config_path=config_path, cwd=tmp_path, postgres_uri="postgresql://db" + ) + dev_preloaded = build_container_command_assignments( + config_path=config_path, + cwd=tmp_path, + postgres_uri=None, + role="dev", + environment_mode="preloaded-v1", + ) + dev_without_mode = build_container_command_assignments( + config_path=config_path, + cwd=tmp_path, + postgres_uri=None, + role="dev", + environment_mode=None, + ) + non_dev_preloaded = build_container_command_assignments( + config_path=config_path, + cwd=tmp_path, + postgres_uri=None, + role="serve", + environment_mode="preloaded-v1", + ) + + assert host.values["AGENTSEEK_GRAPHS"] == str(config_path) + assert host.values["STUDIO_AUTH_LOCAL_DEV"] == "true" + assert [dict(item.values) for item in omitted] == [ + {"AGENTSEEK_GRAPHS": "/deps/agent/langgraph.json"} + ] + assert [dict(item.values) for item in explicit] == [ + {"AGENTSEEK_GRAPHS": "/deps/agent/langgraph.json"}, + { + "METADATA_DB_URL": "postgresql://db", + "METADATA_DB_BACKEND": "postgresql", + }, + ] + assert [dict(item.values) for item in dev_preloaded] == [ + {"AGENTSEEK_GRAPHS": "/deps/agent/langgraph.json"}, + {"STUDIO_AUTH_LOCAL_DEV": "true"}, + ] + assert [dict(item.values) for item in dev_without_mode] == [ + {"AGENTSEEK_GRAPHS": "/deps/agent/langgraph.json"} + ] + assert [dict(item.values) for item in non_dev_preloaded] == [ + {"AGENTSEEK_GRAPHS": "/deps/agent/langgraph.json"} + ] + + +def test_missing_explicit_pass_env_is_an_error() -> None: + with pytest.raises(ContainerPolicyError, match="MISSING"): + select_application_payload( + resolved_fixture(values={}, declared_keys=set()), + ContainerSelection( + pass_env=frozenset({"MISSING"}), compose_env=frozenset() + ), + ) + + +def test_application_registry_contains_every_settings_field() -> None: + assert set(Settings.model_fields) <= APPLICATION_COMPATIBILITY_KEYS + + +def test_provider_registry_matches_documented_exact_snapshot() -> None: + assert APPLICATION_COMPATIBILITY_KEYS == { + "AGENTSEEK_API_BASE", + "AGENTSEEK_API_KEY", + "AGENTSEEK_GRAPHS", + "AGENTSEEK_MODEL", + "AGENTSEEK_MODEL_API_KEY", + "AGENTSEEK_MODEL_PROVIDER", + "APP_NAME", + "AUTH_MODULE_PATH", + "OPENAI_API_BASE", + "OPENAI_API_KEY", + "OPENAI_BASE_URL", + "OPENAI_MODEL", + "ANTHROPIC_API_KEY", + "ANTHROPIC_API_URL", + "GOOGLE_API_BASE", + "GOOGLE_API_KEY", + "TAVILY_API_KEY", + "DAYTONA_API_KEY", + "LANGSMITH_API_KEY", + "BUB_API_BASE", + "BUB_API_KEY", + "BUB_MODEL", + "BUB_OPENAI_API_BASE", + "BUB_OPENAI_API_KEY", + "DEEPAGENTS_MODEL", + "EMBEDDING_API_KEY", + "EMBEDDING_BASE_URL", + "VLM_API_KEY", + "VLM_BASE_URL", + "SILICONFLOW_API_KEY", + "EXECUTOR_BACKEND", + "METADATA_DB_BACKEND", + "METADATA_DB_URL", + "OCEANBASE_DB_NAME", + "OCEANBASE_HOST", + "OCEANBASE_PASSWORD", + "OCEANBASE_PORT", + "OCEANBASE_USER", + "PORT", + "REDIS_RUN_PROCESSING_KEY", + "REDIS_RUN_QUEUE_KEY", + "REDIS_SCHEDULER_LOCK_KEY", + "REDIS_SCHEDULER_LOCK_TTL_SECONDS", + "REDIS_STREAM_MAXLEN", + "REDIS_STREAM_TTL_SECONDS", + "REDIS_URL", + "REDIS_WORKER_LOCK_KEY", + "REDIS_WORKER_LOCK_TTL_SECONDS", + "REDIS_WORKER_POLL_TIMEOUT_SECONDS", + "SCHEDULER_CLAIM_LIMIT", + "SCHEDULER_POLL_INTERVAL_SECONDS", + "SCHEDULER_STARTED_TICK_STALE_AFTER_SECONDS", + "SEEKDB_EMBED", + "SEEKDB_EMBED_DIR", + "SEEKDB_URL", + "STUDIO_AUTH_LOCAL_DEV", + "WORKER_CONCURRENT_JOBS", + } + + +def test_docker_control_environment_is_exact_for_linux_darwin_and_win32( + tmp_path: Path, +) -> None: + plan = _plan( + tmp_path, + launch={ + "PATH": "/safe/bin", + "HOME": "/safe/home", + "XDG_CONFIG_HOME": "/cfg", + "XDG_RUNTIME_DIR": "/run", + "USERPROFILE": "C:/Users/safe", + "SYSTEMROOT": "C:/Windows", + "UNRELATED": "must-not-pass", + }, + ) + + assert dict(docker_control_environment(plan, platform="linux")) == { + "PATH": "/safe/bin", + "HOME": "/safe/home", + "XDG_CONFIG_HOME": "/cfg", + "XDG_RUNTIME_DIR": "/run", + } + assert dict(docker_control_environment(plan, platform="darwin")) == { + "PATH": "/safe/bin", + "HOME": "/safe/home", + "XDG_CONFIG_HOME": "/cfg", + } + assert dict(docker_control_environment(plan, platform="win32")) == { + "Path": "/safe/bin", + "UserProfile": "C:/Users/safe", + "SystemRoot": "C:/Windows", + } + + +def test_macos_docker_plugin_discovery_uses_sanitized_home_and_path( + tmp_path: Path, +) -> None: + plan = _plan( + tmp_path, launch={"PATH": "/docker/bin", "HOME": "/safe/home", "SECRET": "no"} + ) + assert dict(docker_control_environment(plan, platform="darwin")) == { + "PATH": "/docker/bin", + "HOME": "/safe/home", + } + + +def test_windows_docker_native_invocation_uses_sanitized_systemroot_userprofile_and_path( + tmp_path: Path, +) -> None: + plan = _plan( + tmp_path, + launch={ + "Path": "C:/docker", + "SystemRoot": "C:/Windows", + "UserProfile": "C:/Users/safe", + }, + ) + assert dict(docker_control_environment(plan, platform="win32")) == { + "Path": "C:/docker", + "SystemRoot": "C:/Windows", + "UserProfile": "C:/Users/safe", + } + + +def test_command_assignments_apply_only_to_their_declared_target( + tmp_path: Path, +) -> None: + assignment = CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"AGENTSEEK_GRAPHS": "host"}, + reason="host config", + ) + plan = _plan(tmp_path, assignments=(assignment,)) + assert ( + resolve_environment(plan, HOST_RUNTIME_POLICY).values["AGENTSEEK_GRAPHS"] + == "host" + ) + assert ( + "AGENTSEEK_GRAPHS" not in resolve_environment(plan, APP_CONTAINER_POLICY).values + ) + + +def test_postgres_uri_overrides_app_metadata_without_reaching_host_or_docker_control( + tmp_path: Path, +) -> None: + assignment = CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={ + "METADATA_DB_URL": "postgresql://db", + "METADATA_DB_BACKEND": "postgresql", + }, + reason="postgres override", + ) + plan = _plan(tmp_path, assignments=(assignment,)) + app = resolve_environment(plan, APP_CONTAINER_POLICY) + assert app.values["METADATA_DB_URL"] == "postgresql://db" + assert ( + "METADATA_DB_URL" not in resolve_environment(plan, HOST_RUNTIME_POLICY).values + ) + assert "METADATA_DB_URL" not in docker_control_environment(plan, platform="linux") + + +def test_container_manifest_path_replaces_host_graph_path_only_in_app_payload( + tmp_path: Path, +) -> None: + assignments = ( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"AGENTSEEK_GRAPHS": "/host/config.json"}, + reason="host config", + ), + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={"AGENTSEEK_GRAPHS": "/deps/agent/config.json"}, + reason="container config", + ), + ) + plan = _plan(tmp_path, assignments=assignments) + assert ( + resolve_environment(plan, HOST_RUNTIME_POLICY).values["AGENTSEEK_GRAPHS"] + == "/host/config.json" + ) + assert ( + resolve_environment(plan, APP_CONTAINER_POLICY).values["AGENTSEEK_GRAPHS"] + == "/deps/agent/config.json" + ) + + +def test_public_boundary_repr_redacts_values(tmp_path: Path) -> None: + from agentseek_api.dotenv_adapter import parse_dotenv_document + + sentinel = "hostile-secret-sentinel" + plan = _plan( + tmp_path, config_mapping={"TOKEN": sentinel}, launch={"LAUNCH": sentinel} + ) + assignment = CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"COMMAND": sentinel}, + reason="test", + ) + resolved = resolved_fixture(values={"TOKEN": sentinel}, declared_keys={"TOKEN"}) + dotenv_path = tmp_path / "redacted.env" + dotenv_path.write_text(f"TOKEN={sentinel}\n", encoding="utf-8") + document = parse_dotenv_document(dotenv_path) + + rendered = [ + repr(plan), + repr(assignment), + repr(resolved), + repr(document), + repr(document[0]), + ] + assert all(sentinel not in item for item in rendered) + assert {field.name for field in fields(plan)} >= { + "config_mapping", + "launch_environment", + } diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py new file mode 100644 index 0000000..720dd84 --- /dev/null +++ b/tests/unit/test_docker_runtime.py @@ -0,0 +1,1372 @@ +from __future__ import annotations + +import hashlib +import json +import os +import shutil +import subprocess +import sys +import uuid +import zipfile +from dataclasses import replace +from pathlib import Path + +import pytest + +import tests.container_plan_helpers as container_plan_helpers + +from agentseek_api.docker_runtime import ( + MINIMUM_BUILDX_VERSION, + MINIMUM_COMPOSE_VERSION, + BuildImageInvocation, + ControlQueryInvocation, + IMAGE_COMPATIBILITY_FORMAT, + DockerRuntimeError, + ImageContractError, + LegacyRunnerAdapter, + ProcessInvocation, + ProcessResult, + SubprocessTransport, + build_image_invocation, + build_docker_control_invocation, + build_compose_invocation, + build_docker_query_invocation, + build_docker_run_invocation, + encode_compose_environment, + parse_buildx_version_result, + parse_compose_version_result, + parse_image_compatibility_result, + inspect_image_contract, + require_preloaded_v1, + require_buildx_available, + require_supported_buildx, + require_supported_compose, +) +from agentseek_api.container_build import ( + PUBLISHED_RUNTIME_ARTIFACT, + candidate_runtime_artifact, + materialize_build_bundle, + plan_container_image, + render_build_dockerfile, +) +from agentseek_api.environment import ContainerPolicyError +from tests.container_plan_helpers import ( + build_plan_fixture, + decode_with_supported_compose, + docker_compose_available, + docker_daemon_available, +) + + +DOCKER_EXECUTABLE = shutil.which("docker") or str( + Path(sys.executable).parent / ("docker.exe" if os.name == "nt" else "docker") +) + + +@pytest.mark.parametrize( + ("platform", "environment", "selected_path", "resolved"), + [ + ( + "linux", + {"PATH": "/selected/docker/bin"}, + "/selected/docker/bin", + "/selected/docker/bin/docker", + ), + ( + "win32", + {"Path": r"C:\selected\docker"}, + r"C:\selected\docker", + r"C:\selected\docker\docker.exe", + ), + ], +) +def test_resolve_docker_executable_uses_only_selected_platform_path( + monkeypatch: pytest.MonkeyPatch, + platform: str, + environment: dict[str, str], + selected_path: str, + resolved: str, +) -> None: + from agentseek_api.docker_runtime import resolve_docker_executable + + calls: list[tuple[str, str | None]] = [] + + def fake_which(executable: str, *, path: str | None = None) -> str: + calls.append((executable, path)) + return resolved + + monkeypatch.setattr(shutil, "which", fake_which) + + assert resolve_docker_executable(environment, platform=platform) == resolved + assert calls == [("docker", selected_path)] + + +@pytest.mark.parametrize( + ("environment", "resolved"), + [({}, None), ({"PATH": "/selected/docker/bin"}, None), ({"PATH": "."}, "docker")], +) +def test_resolve_docker_executable_fails_closed_without_absolute_selected_binary( + monkeypatch: pytest.MonkeyPatch, + environment: dict[str, str], + resolved: str | None, +) -> None: + from agentseek_api.docker_runtime import resolve_docker_executable + + monkeypatch.setattr(shutil, "which", lambda *_args, **_kwargs: resolved) + + with pytest.raises(DockerRuntimeError, match="Docker executable"): + resolve_docker_executable(environment, platform="linux") + + +def test_process_invocation_rejects_unresolved_docker_executable( + tmp_path: Path, +) -> None: + with pytest.raises(ContainerPolicyError, match="absolute"): + build_docker_query_invocation( + argv=("docker", "version"), + docker_control={}, + cwd=tmp_path, + ) + + +def test_build_image_invocation_uses_stdin_buildx_and_secret(tmp_path: Path) -> None: + plan = build_plan_fixture(tmp_path) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=render_build_dockerfile(plan), + output_root=tmp_path / "bundle", + ) + invocation = build_image_invocation( + bundle, + plan=plan, + docker_executable=DOCKER_EXECUTABLE, + docker_control={"PATH": "/usr/bin", "DOCKER_BUILDKIT": "0"}, + tag="agentseek:test", + platform="linux/amd64", + pull=True, + ) + assert isinstance(invocation, BuildImageInvocation) + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "buildx", + "build", + "--load", + "--file", + "Dockerfile", + "--platform", + "linux/amd64", + "--pull", + "--tag", + "agentseek:test", + "--secret", + f"id=pip_config,src={plan.pip_config_file}", + "-", + ) + assert invocation.environment == {"PATH": "/usr/bin"} + assert invocation.stdin_bytes == bundle.archive_bytes() + assert b"packages.example.invalid" not in invocation.stdin_bytes + assert "packages.example.invalid" not in repr(invocation) + + +def test_dockerfile_and_build_omit_pip_secret_when_not_configured( + tmp_path: Path, +) -> None: + plan = replace(build_plan_fixture(tmp_path), pip_config_file=None) + dockerfile = render_build_dockerfile(plan) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=tmp_path / "bundle-no-pip-secret", + ) + invocation = build_image_invocation( + bundle, + plan=plan, + docker_executable=DOCKER_EXECUTABLE, + docker_control={}, + ) + assert b"type=secret,id=pip_config" not in dockerfile + assert "--secret" not in invocation.argv + assert "None" not in invocation.argv + + +@pytest.mark.parametrize("mismatch", ["pip-secret", "runtime", "plan"]) +def test_build_image_invocation_rejects_bundle_plan_mismatch( + tmp_path: Path, mismatch: str +) -> None: + plan = build_plan_fixture(tmp_path) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=render_build_dockerfile(plan), + output_root=tmp_path / "bundle", + ) + if mismatch == "pip-secret": + supplied_plan = replace(plan, pip_config_file=None, pip_config_identity=None) + elif mismatch == "runtime": + wheel = plan.project_root / "candidate.whl" + with zipfile.ZipFile(wheel, "w") as archive: + archive.writestr( + "agentseek_api-0.3.0.dist-info/METADATA", + "Metadata-Version: 2.1\nName: agentseek-api\nVersion: 0.3.0\n", + ) + artifact = candidate_runtime_artifact( + wheel, hashlib.sha256(wheel.read_bytes()).hexdigest() + ) + assert artifact != PUBLISHED_RUNTIME_ARTIFACT + supplied_plan = replace(plan, runtime_artifact=artifact) + else: + supplied_plan = replace(plan, base_image="python:3.13-alpine") + + with pytest.raises(DockerRuntimeError, match="bundle does not match.*plan"): + build_image_invocation( + bundle, + plan=supplied_plan, + docker_executable=DOCKER_EXECUTABLE, + docker_control={}, + ) + + +def test_build_image_invocation_requires_stdin_bytes(tmp_path: Path) -> None: + with pytest.raises(TypeError): + BuildImageInvocation(argv=("docker",), environment={}, cwd=tmp_path) # type: ignore[call-arg] + + +def test_require_supported_buildx_uses_two_bounded_queries(tmp_path: Path) -> None: + plan = build_plan_fixture(tmp_path) + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + if invocation.argv == (DOCKER_EXECUTABLE, "buildx", "version"): + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.12.0 deadbeef\n", + ) + return ProcessResult(returncode=0) + + assert MINIMUM_BUILDX_VERSION == (0, 12, 0) + assert require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + plan=plan, + ) == (0, 12, 0) + assert [call.argv for call in calls] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ] + assert all(isinstance(call, ControlQueryInvocation) for call in calls) + assert all("--bootstrap" not in call.argv for call in calls) + + +@pytest.mark.parametrize( + ("version_result", "inspect_result", "match"), + [ + (ProcessResult(returncode=1), ProcessResult(returncode=0), "version query"), + ( + ProcessResult( + returncode=0, stdout=b"github.com/docker/buildx v0.11.2 deadbeef\n" + ), + ProcessResult(returncode=0), + "0.12.0 or newer", + ), + ( + ProcessResult( + returncode=0, stdout=b"github.com/docker/buildx v0.12.0-rc.1 deadbeef\n" + ), + ProcessResult(returncode=0), + "0.12.0 or newer", + ), + ( + ProcessResult( + returncode=0, stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n" + ), + ProcessResult(returncode=1), + "builder is unavailable", + ), + ], +) +def test_require_supported_buildx_fails_before_build( + tmp_path: Path, + version_result: ProcessResult, + inspect_result: ProcessResult, + match: str, +) -> None: + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + return version_result if len(calls) == 1 else inspect_result + + with pytest.raises(DockerRuntimeError, match=match): + require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + ) + assert all( + call.argv[:3] != (DOCKER_EXECUTABLE, "buildx", "build") for call in calls + ) + + +def test_buildx_without_secret_support_fails_bounded_and_value_free( + tmp_path: Path, +) -> None: + plan = build_plan_fixture(tmp_path) + assert plan.pip_config_file is not None + canary = "private-index-password-canary" + plan.pip_config_file.write_text(canary, encoding="utf-8") + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.11.2 deadbeef\n", + ) + + with pytest.raises(DockerRuntimeError, match="secret") as caught: + require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + plan=plan, + ) + + assert len(calls) == 1 + assert isinstance(calls[0], ControlQueryInvocation) + assert canary not in str(caught.value) + assert canary not in repr(calls) + + +def test_pip_config_swap_before_build_invocation_is_rejected(tmp_path: Path) -> None: + plan = build_plan_fixture(tmp_path) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=render_build_dockerfile(plan), + output_root=tmp_path / "bundle", + ) + assert plan.pip_config_file is not None + replacement = tmp_path / "replacement.conf" + replacement.write_text("password=swapped\n", encoding="utf-8") + replacement.replace(plan.pip_config_file) + with pytest.raises(DockerRuntimeError, match="pip config identity changed"): + build_image_invocation( + bundle, + plan=plan, + docker_executable=DOCKER_EXECUTABLE, + docker_control={}, + ) + + +def test_pip_config_swap_during_buildx_probes_is_rejected(tmp_path: Path) -> None: + plan = build_plan_fixture(tmp_path) + assert plan.pip_config_file is not None + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + if len(calls) == 1: + replacement = tmp_path / "replacement.conf" + replacement.write_text("password=swapped\n", encoding="utf-8") + replacement.replace(plan.pip_config_file) + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", + ) + return ProcessResult(returncode=0) + + with pytest.raises(DockerRuntimeError, match="pip config identity changed"): + require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + plan=plan, + ) + assert [call.argv for call in calls] == [ + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), + ] + + +@pytest.mark.docker +@pytest.mark.parametrize("valid_secret", [True, False]) +def test_buildx_secret_mount_consumes_canary_without_disclosure( + tmp_path: Path, valid_secret: bool +) -> None: + if not docker_daemon_available(cwd=tmp_path): + pytest.skip("requires a Docker daemon") + plan = build_plan_fixture(tmp_path) + assert plan.pip_config_file is not None + expected_canary = f"agentseek-buildx-secret-{uuid.uuid4().hex}" + actual_secret = ( + expected_canary + if valid_secret + else f"agentseek-buildx-wrong-secret-{uuid.uuid4().hex}" + ) + plan.pip_config_file.write_text(actual_secret, encoding="utf-8") + plan = plan_container_image(config_path=plan.config_path) + digest = hashlib.sha256(expected_canary.encode()).hexdigest() + dockerfile = ( + "# syntax=docker/dockerfile:1.7\n" + "FROM python:3.12-alpine\n" + "RUN --mount=type=secret,id=pip_config,target=/run/secrets/pip_config " + + json.dumps( + [ + "python", + "-c", + ( + "import hashlib,pathlib,sys;" + "data=pathlib.Path('/run/secrets/pip_config').read_bytes();" + f"sys.exit(1) if hashlib.sha256(data).hexdigest()!='{digest}' else None" + ), + ] + ) + + "\n" + ).encode() + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=tmp_path / f"secret-smoke-bundle-{valid_secret}", + ) + allowed = { + "PATH", + "HOME", + "USERPROFILE", + "SYSTEMROOT", + "DOCKER_HOST", + "DOCKER_CONTEXT", + "DOCKER_CONFIG", + "DOCKER_TLS_VERIFY", + "DOCKER_CERT_PATH", + } + docker_control = { + name: value for name, value in os.environ.items() if name in allowed + } + transport = SubprocessTransport() + require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control=docker_control, + cwd=tmp_path, + plan=plan, + ) + tag = f"agentseek-buildx-secret-smoke:{uuid.uuid4().hex}" + invocation = build_image_invocation( + bundle, + plan=plan, + docker_executable=DOCKER_EXECUTABLE, + docker_control=docker_control, + tag=tag, + ) + + try: + completed = subprocess.run( + list(invocation.argv), + cwd=invocation.cwd, + env=dict(invocation.environment), + input=invocation.stdin_bytes, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=240, + ) + combined = completed.stdout + completed.stderr + assert (completed.returncode == 0) is valid_secret, combined.decode( + errors="replace" + ) + history = subprocess.run( + [DOCKER_EXECUTABLE, "image", "history", "--no-trunc", tag], + cwd=tmp_path, + env=docker_control, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=30, + ) + assert (history.returncode == 0) is valid_secret + observed = combined + history.stdout + history.stderr + invocation.stdin_bytes + for secret in (expected_canary, actual_secret): + assert secret.encode() not in observed + assert all(secret not in argument for argument in invocation.argv) + assert secret not in repr(invocation) + finally: + subprocess.run( + [DOCKER_EXECUTABLE, "image", "rm", "--force", tag], + cwd=tmp_path, + env=docker_control, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + shell=False, + timeout=30, + ) + + +SPECIAL_VALUES = { + "DOLLAR": "${DOCKER_HOST}", + "LONE_DOLLAR": "$", + "HASH": "value # literal", + "EQUALS": "a=b", + "QUOTES": "single' double\" slash\\", + "TRAILING_SLASH": "trailing\\", + "SLASH_QUOTE": 'slash-before-\\"quote', + "MULTILINE": "line one\nline two\rline three\tend", + "UNICODE": "雪", + "EMPTY": "", +} + + +def test_compose_encoder_uses_one_literal_double_quoted_codec() -> None: + encoded = encode_compose_environment(SPECIAL_VALUES) + + assert 'DOLLAR="$${DOCKER_HOST}"' in encoded + assert 'LONE_DOLLAR="$$"' in encoded + assert 'HASH="value # literal"' in encoded + assert 'EQUALS="a=b"' in encoded + assert 'TRAILING_SLASH="trailing\\\\"' in encoded + assert 'SLASH_QUOTE="slash-before-\\\\\\"quote"' in encoded + assert 'MULTILINE="line one\\nline two\\rline three\\tend"' in encoded + assert encoded.endswith("\n") + assert "line one\nline two" not in encoded + + +@pytest.mark.docker +@pytest.mark.parametrize(("name", "value"), SPECIAL_VALUES.items()) +def test_compose_encoder_round_trips_config_oracle( + name: str, value: str, tmp_path: Path +) -> None: + if not docker_compose_available(cwd=tmp_path): + pytest.skip("requires Docker Compose 2.24 or newer") + decoded = decode_with_supported_compose( + encode_compose_environment({name: value}), tmp_path=tmp_path + ) + + assert decoded.substitution[name] == value + assert decoded.rendered[name] == value.replace("$", "$$") + + +@pytest.mark.docker +def test_compose_service_probe_command_references_environment_without_baking_value( + tmp_path: Path, +) -> None: + if not docker_compose_available(cwd=tmp_path): + pytest.skip("requires Docker Compose 2.24 or newer") + value = "must-not-be-baked-into-shell-source" + + decoded = decode_with_supported_compose( + encode_compose_environment({"PROBE_VALUE": value}), tmp_path=tmp_path + ) + + command = " ".join(decoded.commands["PROBE_VALUE"]) + assert value not in command + assert "${PROBE_VALUE}" in command + + +@pytest.mark.docker +def test_compose_encoder_round_trips_real_container_without_second_interpolation( + tmp_path: Path, +) -> None: + if not docker_compose_available(cwd=tmp_path) or not docker_daemon_available( + cwd=tmp_path + ): + pytest.skip("requires a Docker daemon") + + decoded = decode_with_supported_compose( + encode_compose_environment(SPECIAL_VALUES), + tmp_path=tmp_path, + run_service=True, + ) + + assert dict(decoded.substitution) == SPECIAL_VALUES + assert dict(decoded.runtime) == SPECIAL_VALUES + + +def test_compose_decoder_uses_floor_compatible_rendered_document( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[str, ...]] = [] + + def fake_run_private(command: list[str], *, cwd: Path) -> bytes: + assert cwd == tmp_path + calls.append(tuple(command)) + if "--environment" in command: + pytest.fail("Compose 2.24.0 does not support config --environment") + return json.dumps( + { + "services": { + "probe-dollar": { + "environment": { + "DOLLAR": "$${DOCKER_HOST}", + "PROJECT_DOTENV_CANARY": "unset", + }, + "command": [ + "sh", + "-c", + "printf '%s' \"$${DOLLAR}\" > /result/DOLLAR", + ], + } + } + } + ).encode() + + monkeypatch.setattr(container_plan_helpers, "_run_private", fake_run_private) + + decoded = decode_with_supported_compose( + encode_compose_environment({"DOLLAR": "${DOCKER_HOST}"}), + tmp_path=tmp_path, + ) + + assert decoded.substitution == {"DOLLAR": "${DOCKER_HOST}"} + assert decoded.rendered == {"DOLLAR": "$${DOCKER_HOST}"} + assert len(calls) == 1 + assert calls[0][-3:] == ("config", "--format", "json") + + +def test_compose_runtime_precreates_private_host_owned_result_files( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def fake_run_private(command: list[str], *, cwd: Path) -> bytes: + assert cwd == tmp_path + if command[-3:] == ["config", "--format", "json"]: + return json.dumps( + { + "services": { + "probe-value": { + "environment": { + "VALUE": "expected", + "PROJECT_DOTENV_CANARY": "unset", + }, + "command": [ + "sh", + "-c", + "printf '%s' \"$${VALUE}\" > /result/VALUE", + ], + } + } + } + ).encode() + result = tmp_path / "compose-results" / "VALUE" + assert result.is_file() + assert result.stat().st_mode & 0o777 == 0o600 + result.write_text("expected", encoding="utf-8") + return b"" + + monkeypatch.setattr(container_plan_helpers, "_run_private", fake_run_private) + monkeypatch.setattr( + container_plan_helpers.subprocess, + "run", + lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0), + ) + + decoded = decode_with_supported_compose( + encode_compose_environment({"VALUE": "expected"}), + tmp_path=tmp_path, + run_service=True, + ) + + assert decoded.runtime == {"VALUE": "expected"} + + +@pytest.mark.parametrize("value", ["nul\0value", "control\x01value"]) +def test_compose_encoder_rejects_unrepresentable_values_without_echoing_them( + value: str, +) -> None: + with pytest.raises(DockerRuntimeError) as exc_info: + encode_compose_environment({"PRIVATE_TOKEN": value}) + + assert "PRIVATE_TOKEN" in str(exc_info.value) + assert value not in str(exc_info.value) + + +def test_compose_invocation_is_explicit_control_only_and_value_redacted( + tmp_path: Path, +) -> None: + env_file = tmp_path / "agentseek-compose-private" + compose_file = tmp_path / "compose.yaml" + + invocation = build_compose_invocation( + docker_executable=DOCKER_EXECUTABLE, + compose_file=compose_file, + env_file=env_file, + docker_control={"DOCKER_HOST": "unix:///private/docker.sock"}, + application_payload={"TOKEN": "compose-secret"}, + selected_names=frozenset({"TOKEN"}), + cwd=tmp_path, + recreate=True, + ) + + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "compose", + "--env-file", + str(env_file), + "-f", + str(compose_file), + "up", + "-d", + "--force-recreate", + ) + assert dict(invocation.environment) == { + "DOCKER_HOST": "unix:///private/docker.sock" + } + assert "compose-secret" not in repr(invocation) + assert "compose-secret" not in " ".join(invocation.argv) + + +@pytest.mark.parametrize( + ("application_payload", "selected_names", "docker_control", "message"), + [ + ({}, frozenset({"MISSING"}), {}, "not present"), + ( + {"DOCKER_HOST": "application"}, + frozenset({"DOCKER_HOST"}), + {"DOCKER_HOST": "control"}, + "collides", + ), + ], +) +def test_compose_invocation_rejects_missing_names_and_control_collisions( + tmp_path: Path, + application_payload: dict[str, str], + selected_names: frozenset[str], + docker_control: dict[str, str], + message: str, +) -> None: + with pytest.raises(ContainerPolicyError, match=message): + build_compose_invocation( + docker_executable=DOCKER_EXECUTABLE, + compose_file=tmp_path / "compose.yaml", + env_file=tmp_path / "private.env", + docker_control=docker_control, + application_payload=application_payload, + selected_names=selected_names, + cwd=tmp_path, + ) + + +def test_require_supported_compose_uses_bounded_control_only_query( + tmp_path: Path, +) -> None: + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + return ProcessResult(returncode=0, stdout=b"v2.24.0\n") + + assert MINIMUM_COMPOSE_VERSION == (2, 24, 0) + assert require_supported_compose( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + ) == (2, 24, 0) + assert len(calls) == 1 + query = calls[0] + assert isinstance(query, ControlQueryInvocation) + assert query.argv == (DOCKER_EXECUTABLE, "compose", "version", "--short") + assert dict(query.environment) == {"PATH": "/usr/bin"} + assert query.timeout_seconds > 0 + + +def test_require_supported_compose_rejects_old_version_value_free( + tmp_path: Path, +) -> None: + def transport(_invocation: ProcessInvocation) -> ProcessResult: + return ProcessResult(returncode=0, stdout=b"2.23.3\n") + + with pytest.raises(DockerRuntimeError) as exc_info: + require_supported_compose( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + ) + + assert "2.24.0" in str(exc_info.value) + assert "2.23.3" not in str(exc_info.value) + + +def test_require_supported_compose_rejects_minimum_prerelease(tmp_path: Path) -> None: + def transport(_invocation: ProcessInvocation) -> ProcessResult: + return ProcessResult(returncode=0, stdout=b"2.24.0-rc.1\n") + + with pytest.raises(DockerRuntimeError, match="2.24.0 or newer"): + require_supported_compose( + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + ) + + +def test_docker_run_uses_names_in_argv_and_values_only_in_carrier( + tmp_path: Path, +) -> None: + invocation = build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run", "--rm"), + image="agentseek:test", + docker_control={"PATH": "/usr/bin"}, + application_payload={"OPENAI_API_KEY": "sk-$#=雪", "EMPTY": ""}, + container_argv=( + "agentseek-api", + "serve", + "--environment-mode=preloaded-v1", + ), + cwd=tmp_path, + ) + + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "run", + "--rm", + "-e", + "EMPTY", + "-e", + "OPENAI_API_KEY", + "agentseek:test", + "agentseek-api", + "serve", + "--environment-mode=preloaded-v1", + ) + assert invocation.environment["OPENAI_API_KEY"] == "sk-$#=雪" + assert invocation.application_names == frozenset({"EMPTY", "OPENAI_API_KEY"}) + assert "sk-$#=雪" not in " ".join(invocation.argv) + + +def test_docker_run_preserves_physical_newline_only_in_carrier( + tmp_path: Path, +) -> None: + value = "first line\nsecond line" + + invocation = build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run", "--rm"), + image="agentseek:test", + docker_control={}, + application_payload={"MULTILINE": value}, + container_argv=(), + cwd=tmp_path, + ) + + assert invocation.environment["MULTILINE"] == value + assert value not in " ".join(invocation.argv) + assert invocation.argv == ( + DOCKER_EXECUTABLE, + "run", + "--rm", + "-e", + "MULTILINE", + "agentseek:test", + ) + + +def test_windows_docker_run_rejects_casefolded_cross_map_collision( + tmp_path: Path, +) -> None: + with pytest.raises( + ContainerPolicyError, + match="Application payload collides with Docker control keys", + ): + build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run"), + image="agentseek:test", + docker_control={"Path": "control"}, + application_payload={"PATH": "application"}, + container_argv=(), + cwd=tmp_path, + platform="win32", + ) + + +@pytest.mark.parametrize( + ("docker_control", "application_payload", "message"), + [ + ( + {"Path": "first", "PATH": "second"}, + {}, + "Docker control environment contains duplicate Windows environment names.", + ), + ( + {}, + {"Token": "first", "TOKEN": "second"}, + "Application payload contains duplicate Windows environment names.", + ), + ], +) +def test_windows_docker_run_rejects_casefolded_duplicates_within_map( + tmp_path: Path, + docker_control: dict[str, str], + application_payload: dict[str, str], + message: str, +) -> None: + with pytest.raises(ContainerPolicyError) as exc_info: + build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run"), + image="agentseek:test", + docker_control=docker_control, + application_payload=application_payload, + container_argv=(), + cwd=tmp_path, + platform="win32", + ) + + assert str(exc_info.value) == message + + +def test_linux_docker_run_keeps_case_sensitive_name_semantics(tmp_path: Path) -> None: + invocation = build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run"), + image="agentseek:test", + docker_control={"Path": "control"}, + application_payload={"PATH": "application"}, + container_argv=(), + cwd=tmp_path, + platform="linux", + ) + + assert invocation.environment == {"Path": "control", "PATH": "application"} + + +def test_nul_collision_is_rejected_before_value_free_collision_diagnostic( + tmp_path: Path, +) -> None: + name = "PRIVATE\0NAME" + + with pytest.raises(ContainerPolicyError) as exc_info: + build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run"), + image="agentseek:test", + docker_control={name: "control"}, + application_payload={name: "application"}, + container_argv=(), + cwd=tmp_path, + ) + + message = str(exc_info.value) + assert message == "Docker invocation environment name contains NUL." + assert "\0" not in message + assert "PRIVATE" not in message + + +def test_non_run_docker_invocation_has_only_docker_control(tmp_path: Path) -> None: + invocation = build_docker_control_invocation( + argv=(DOCKER_EXECUTABLE, "image", "inspect", "agentseek:test"), + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + ) + + assert dict(invocation.environment) == {"PATH": "/usr/bin"} + with pytest.raises(TypeError): + invocation.environment["OPENAI_API_KEY"] = "not-allowed" # type: ignore[index] + + +@pytest.mark.parametrize( + ("docker_control", "application_payload"), + [ + ({"OPENAI_API_KEY": "control"}, {"OPENAI_API_KEY": "application"}), + ({"PATH": "bad\0path"}, {}), + ({}, {"BAD\0NAME": "value"}), + ({}, {"TOKEN": "bad\0value"}), + ], +) +def test_docker_run_rejects_collisions_and_nul( + tmp_path: Path, + docker_control: dict[str, str], + application_payload: dict[str, str], +) -> None: + with pytest.raises(ContainerPolicyError): + build_docker_run_invocation( + base_argv=(DOCKER_EXECUTABLE, "run"), + image="agentseek:test", + docker_control=docker_control, + application_payload=application_payload, + container_argv=(), + cwd=tmp_path, + ) + + +def test_query_invocation_is_bounded_and_redacted(tmp_path: Path) -> None: + canary = "baked-env-canary" + invocation = build_docker_query_invocation( + argv=( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "hostile:test", + ), + docker_control={"DOCKER_HOST": "unix:///private/docker.sock"}, + cwd=tmp_path, + timeout_seconds=3.0, + ) + result = ProcessResult( + returncode=0, + stdout=b'[{"contract":"preloaded-v1"},[],[]]', + stderr=canary.encode(), + ) + + assert isinstance(invocation, ControlQueryInvocation) + assert invocation.timeout_seconds == 3.0 + assert ".Config.Env" not in " ".join(invocation.argv) + assert "DOCKER_HOST" not in repr(invocation) + assert canary not in repr(result) + assert "preloaded-v1" not in repr(result) + + +def test_query_timeout_is_value_free( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def timeout(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: + raise subprocess.TimeoutExpired( + cmd=[DOCKER_EXECUTABLE, "image", "inspect", "secret-image-name"], + timeout=1.0, + output=b"private-output", + stderr=b"private-error", + ) + + monkeypatch.setattr(subprocess, "run", timeout) + invocation = build_docker_query_invocation( + argv=(DOCKER_EXECUTABLE, "image", "inspect", "secret-image-name"), + docker_control={}, + cwd=tmp_path, + timeout_seconds=1.0, + ) + + with pytest.raises(DockerRuntimeError) as exc_info: + SubprocessTransport()(invocation) + + message = str(exc_info.value) + assert message == "Docker control query timed out." + assert "secret-image-name" not in message + assert "private-output" not in message + assert "private-error" not in message + + +def test_subprocess_transport_captures_only_queries( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + calls: list[tuple[list[str], dict[str, object]]] = [] + + def fake_run( + argv: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + calls.append((argv, kwargs)) + return subprocess.CompletedProcess( + argv, + 0, + stdout=b"captured" if kwargs["stdout"] is subprocess.PIPE else None, + stderr=b"private" if kwargs["stderr"] is subprocess.PIPE else None, + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + query = build_docker_query_invocation( + argv=(DOCKER_EXECUTABLE, "compose", "version", "--short"), + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + timeout_seconds=2.0, + ) + control = build_docker_control_invocation( + argv=(DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123"), + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + ) + + query_result = SubprocessTransport()(query) + control_result = SubprocessTransport()(control) + + assert query_result.stdout == b"captured" + assert query_result.stderr == b"private" + assert control_result.stdout == b"" + assert control_result.stderr == b"" + assert calls[0][1]["stdout"] is subprocess.PIPE + assert calls[0][1]["stderr"] is subprocess.PIPE + assert calls[0][1]["timeout"] == 2.0 + assert calls[1][1]["stdout"] is None + assert calls[1][1]["stderr"] is None + assert calls[1][1]["timeout"] is None + for _, kwargs in calls: + assert kwargs["shell"] is False + assert kwargs["check"] is False + assert kwargs["env"] == {"PATH": "/usr/bin"} + assert kwargs["cwd"] == tmp_path + + +def test_legacy_runner_adapter_rejects_queries_and_stdin(tmp_path: Path) -> None: + calls: list[tuple[list[str], dict[str, str], str | None]] = [] + + def runner( + command: list[str], *, env: dict[str, str], cwd: str | None = None + ) -> int: + calls.append((command, env, cwd)) + return 7 + + adapter = LegacyRunnerAdapter(runner) + control = build_docker_control_invocation( + argv=(DOCKER_EXECUTABLE, "rm", "-f", "test"), docker_control={}, cwd=tmp_path + ) + query = build_docker_query_invocation( + argv=(DOCKER_EXECUTABLE, "image", "inspect", "test"), + docker_control={}, + cwd=tmp_path, + ) + stdin_invocation = ProcessInvocation( + argv=(DOCKER_EXECUTABLE, "build", "-"), + environment={}, + cwd=tmp_path, + stdin_bytes=b"archive", + ) + + assert adapter(control).returncode == 7 + assert calls == [([DOCKER_EXECUTABLE, "rm", "-f", "test"], {}, str(tmp_path))] + assert "archive" not in repr(stdin_invocation) + with pytest.raises(DockerRuntimeError, match="control queries"): + adapter(query) + with pytest.raises(DockerRuntimeError, match="standard input"): + adapter(stdin_invocation) + + +def test_narrow_version_and_availability_parsers_are_value_free() -> None: + assert ( + parse_compose_version_result(ProcessResult(returncode=0, stdout=b"v2.27.1\n")) + == "2.27.1" + ) + assert ( + parse_buildx_version_result( + ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 171fcbe\n", + ) + ) + == "0.14.0" + ) + require_buildx_available(ProcessResult(returncode=0)) + + canary = "private-version-output" + for parser, result in ( + ( + parse_compose_version_result, + ProcessResult(returncode=0, stdout=f"2.27.1\n{canary}\n".encode()), + ), + ( + parse_buildx_version_result, + ProcessResult(returncode=1, stderr=canary.encode()), + ), + (require_buildx_available, ProcessResult(returncode=1, stderr=canary.encode())), + ): + with pytest.raises(DockerRuntimeError) as exc_info: + parser(result) + assert canary not in str(exc_info.value) + + +def test_image_compatibility_query_never_requests_hostile_baked_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + baked_environment_canary = "hostile-baked-env-canary" + hostile_config = { + "Env": [f"PRIVATE_TOKEN={baked_environment_canary}"], + "Labels": {"org.agentseek.environment-contract": "preloaded-v1"}, + "Entrypoint": ["agentseek-api"], + "Cmd": ["serve"], + } + + def fake_run( + argv: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[bytes]: + assert argv == [ + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + IMAGE_COMPATIBILITY_FORMAT, + "hostile:test", + ] + assert ".Config.Env" not in " ".join(argv) + selected = [ + hostile_config["Labels"], + hostile_config["Entrypoint"], + hostile_config["Cmd"], + ] + return subprocess.CompletedProcess( + argv, 0, stdout=(json.dumps(selected) + "\n").encode(), stderr=b"" + ) + + monkeypatch.setattr(subprocess, "run", fake_run) + invocation = build_docker_query_invocation( + argv=( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + IMAGE_COMPATIBILITY_FORMAT, + "hostile:test", + ), + docker_control={}, + cwd=tmp_path, + ) + + result = SubprocessTransport()(invocation) + config = parse_image_compatibility_result(result) + + assert config.labels["org.agentseek.environment-contract"] == "preloaded-v1" + assert config.entrypoint == ("agentseek-api",) + assert config.command == ("serve",) + assert baked_environment_canary.encode() not in result.stdout + assert baked_environment_canary not in repr(config) + + +@pytest.mark.parametrize( + "result", + [ + ProcessResult(returncode=1, stderr=b"private-error"), + ProcessResult(returncode=0, stdout=b"not-json"), + ProcessResult(returncode=0, stdout=b"[{},[]]"), + ProcessResult(returncode=0, stdout=b'[{"label": 1},[],[]]'), + ProcessResult(returncode=0, stdout=b"[{},[],[]]\n[{},[],[]]"), + ], +) +def test_image_compatibility_parser_rejects_nonexact_private_output( + result: ProcessResult, +) -> None: + with pytest.raises(DockerRuntimeError) as exc_info: + parse_image_compatibility_result(result) + + message = str(exc_info.value) + assert message == "Docker image compatibility query returned an invalid result." + assert "private-error" not in message + assert "not-json" not in message + + +def _compatible_image_labels() -> dict[str, str]: + return { + "org.agentseek.environment-contract": "preloaded-v1", + "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-distribution": "agentseek-api", + "org.agentseek.runtime-version": "0.3.0", + } + + +def test_custom_image_without_preloaded_labels_is_rejected() -> None: + with pytest.raises(ImageContractError, match="preloaded-v1"): + require_preloaded_v1({}) + + +@pytest.mark.parametrize("label", list(_compatible_image_labels())) +def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: + labels = _compatible_image_labels() + labels[label] = "label-canary" + with pytest.raises(ImageContractError) as caught: + require_preloaded_v1(labels) + assert "label-canary" not in str(caught.value) + + +@pytest.mark.parametrize( + ("entrypoint", "expected_command"), + [ + ( + [], + ( + "agentseek-api", + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ( + ["agentseek-api"], + ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ( + ["python", "-m", "agentseek_api.cli"], + ( + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ], +) +def test_inspect_image_contract_uses_one_exact_query_and_overrides_cmd( + tmp_path: Path, entrypoint: list[str], expected_command: tuple[str, ...] +) -> None: + calls: list[ProcessInvocation] = [] + + def transport(invocation: ProcessInvocation) -> ProcessResult: + calls.append(invocation) + return ProcessResult( + returncode=0, + stdout=json.dumps( + [_compatible_image_labels(), entrypoint, ["hostile", "default"]] + ).encode(), + ) + + contract = inspect_image_contract( + "agentseek:test", + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={"DOCKER_HOST": "unix:///docker.sock"}, + cwd=tmp_path, + ) + + assert len(calls) == 1 + assert isinstance(calls[0], ControlQueryInvocation) + assert calls[0].argv == ( + DOCKER_EXECUTABLE, + "image", + "inspect", + "--format", + IMAGE_COMPATIBILITY_FORMAT, + "agentseek:test", + ) + assert ".Config.Env" not in " ".join(calls[0].argv) + assert contract.manifest_path == "/opt/agentseek/manifest.v1.json" + assert contract.container_argv == expected_command + + +@pytest.mark.parametrize( + "entrypoint", + ["agentseek-api", ["/bin/sh", "-c"], ["python", "agentseek_api/cli.py"]], +) +def test_inspect_image_contract_rejects_unsupported_entrypoint_value_free( + tmp_path: Path, entrypoint: object +) -> None: + canary = "entrypoint-canary" + + def transport(_invocation: ProcessInvocation) -> ProcessResult: + return ProcessResult( + returncode=0, + stdout=json.dumps( + [_compatible_image_labels(), entrypoint, [canary]] + ).encode(), + ) + + with pytest.raises(ImageContractError) as caught: + inspect_image_contract( + canary, + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, + ) + assert canary not in str(caught.value) diff --git a/tests/unit/test_dotenv_adapter.py b/tests/unit/test_dotenv_adapter.py index ea55c34..ce27419 100644 --- a/tests/unit/test_dotenv_adapter.py +++ b/tests/unit/test_dotenv_adapter.py @@ -5,6 +5,37 @@ import pytest +def test_parse_dotenv_document_is_physical_ordered_and_redacts_values( + tmp_path: Path, +) -> None: + from agentseek_api.dotenv_adapter import parse_dotenv_document + + env_file = tmp_path / "runtime.env" + env_file.write_text("FIRST=one\nSECOND=${FIRST}/two\n", encoding="utf-8") + + document = parse_dotenv_document(env_file) + + assert [binding.key for binding in document] == ["FIRST", "SECOND"] + assert document[1].referenced_names == frozenset({"FIRST"}) + assert "one" not in repr(document) + + +def test_parse_dotenv_document_rejects_malformed_without_value_leak( + tmp_path: Path, +) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_document + + env_file = tmp_path / "broken.env" + env_file.write_text( + 'TOKEN=hostile-secret-sentinel\nBROKEN "value"\n', encoding="utf-8" + ) + + with pytest.raises(DotenvFileError) as raised: + parse_dotenv_document(env_file) + + assert "hostile-secret-sentinel" not in str(raised.value) + + def test_parse_dotenv_file_preserves_file_local_physical_order( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_redis_runtime_script.py b/tests/unit/test_redis_runtime_script.py index 0975adb..ba21561 100644 --- a/tests/unit/test_redis_runtime_script.py +++ b/tests/unit/test_redis_runtime_script.py @@ -1,6 +1,34 @@ from pathlib import Path +def test_redis_runtime_builds_image_from_exact_candidate_wheel() -> None: + script = Path("scripts/test-redis-runtime.sh").read_text() + + assert "uv run agentseek-api build" not in script + assert 'mktemp -d "$ROOT_DIR/.tmp/agentseek-redis-candidate.XXXXXX"' in script + assert "${TMPDIR:-/tmp}/agentseek-redis-candidate" not in script + assert 'uv build --wheel --out-dir "$CANDIDATE_DIR"' in script + assert "agentseek_api-0.3.0-*.whl" in script + assert "candidate_runtime_artifact" in script + assert "runtime_artifact=artifact" in script + assert 'rm -rf -- "$CANDIDATE_DIR"' in script + + +def test_redis_runtime_launches_api_and_worker_in_preloaded_mode() -> None: + script = Path("scripts/test-redis-runtime.sh").read_text() + + assert script.count('-e AGENTSEEK_GRAPHS="/opt/agentseek/manifest.v1.json"') == 2 + assert ( + 'AUTH_MODULE_PATH="${AUTH_MODULE_PATH:-/deps/agent/examples/' + 'docker_ci_auth/auth_backend.py:HeaderAuthBackend}"' in script + ) + assert script.count('-e AUTH_MODULE_PATH="${AUTH_MODULE_PATH}"') == 2 + assert ( + "python -I -m agentseek_api.cli worker --environment-mode preloaded-v1" + in script + ) + + def test_redis_runtime_runs_live_queue_ownership_tests() -> None: script = Path("scripts/test-redis-runtime.sh").read_text() @@ -56,7 +84,9 @@ def test_redis_runtime_orders_probes_with_only_required_worker_restarts() -> Non assert "print_logs >&2" in script -def test_redis_runtime_logs_concurrency_suite_timing_without_polluting_probe_json() -> None: +def test_redis_runtime_logs_concurrency_suite_timing_without_polluting_probe_json() -> ( + None +): script = Path("scripts/test-redis-runtime.sh").read_text() assert "WORKER_CONCURRENCY_SUITE_STARTED_SECONDS=$SECONDS" in script diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py new file mode 100644 index 0000000..d92af48 --- /dev/null +++ b/tests/unit/test_secure_temp.py @@ -0,0 +1,705 @@ +from __future__ import annotations + +import os +import stat +import time +from pathlib import Path + +import pytest + +import agentseek_api.secure_temp as secure_temp +from agentseek_api.secure_temp import ( + SecureArtifactError, + private_artifact, + private_directory, + sweep_expired_artifacts, +) + +POSIX_ONLY = pytest.mark.skipif(os.name == "nt", reason="requires POSIX metadata") + + +@POSIX_ONLY +def test_private_artifact_is_user_only_and_removed_after_failure( + tmp_path: Path, +) -> None: + with pytest.raises(RuntimeError, match="subprocess failed"): + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"TOKEN='sentinel'\n", + ) as path: + assert path.read_bytes() == b"TOKEN='sentinel'\n" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert path.stat().st_uid == os.getuid() + raise RuntimeError("subprocess failed") + + assert list(tmp_path.iterdir()) == [] + + +@POSIX_ONLY +def test_private_directory_is_user_only_and_removed_with_contents( + tmp_path: Path, +) -> None: + with private_directory(tmp_root=tmp_path, prefix="agentseek-build-") as path: + assert stat.S_IMODE(path.stat().st_mode) == 0o700 + assert path.stat().st_uid == os.getuid() + (path / "inventory.json").write_text("{}", encoding="utf-8") + + assert list(tmp_path.iterdir()) == [] + + +@POSIX_ONLY +def test_private_artifact_fails_closed_when_mode_cannot_be_proved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def reject_mode(_fd: int, _path: Path) -> os.stat_result: + raise SecureArtifactError("Could not prove exclusive access.") + + monkeypatch.setattr(secure_temp, "_verify_open_posix", reject_mode) + + with pytest.raises(SecureArtifactError, match="exclusive access"): + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"private", + ): + pytest.fail("an unverified path must never be exposed") + + assert list(tmp_path.iterdir()) == [] + + +@POSIX_ONLY +def test_private_artifact_prep_failure_never_deletes_regular_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + captured = tmp_path / "captured-original" + + def substitute_then_reject(_fd: int, path: Path) -> os.stat_result: + path.rename(captured) + path.write_bytes(b"replacement-must-survive") + path.chmod(0o600) + raise SecureArtifactError("Could not prove exclusive access.") + + monkeypatch.setattr(secure_temp, "_verify_open_posix", substitute_then_reject) + + with pytest.raises(SecureArtifactError, match="exclusive access"): + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"created-object", + ): + pytest.fail("a substituted path must never be exposed") + + assert captured.read_bytes() == b"created-object" + assert any( + path.is_file() and path.read_bytes() == b"replacement-must-survive" + for path in tmp_path.iterdir() + ) + + +@POSIX_ONLY +@pytest.mark.parametrize("replacement_kind", ["regular", "symlink"]) +def test_quarantine_file_never_deletes_path_replacement( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + replacement_kind: str, +) -> None: + candidate = tmp_path / "agentseek-compose-race" + candidate.write_bytes(b"created-object") + candidate.chmod(0o600) + expected = candidate.lstat() + captured = tmp_path / "captured-original" + symlink_target = tmp_path / "replacement-target" + if replacement_kind == "symlink": + symlink_target.write_bytes(b"replacement-must-survive") + + def substitute_then_move(path: Path) -> Path: + path.rename(captured) + if replacement_kind == "regular": + path.write_bytes(b"replacement-must-survive") + path.chmod(0o600) + else: + path.symlink_to(symlink_target) + quarantine = tmp_path / ".agentseek-quarantine-test" + path.rename(quarantine) + return quarantine + + monkeypatch.setattr( + secure_temp, "_move_to_quarantine", substitute_then_move, raising=False + ) + + removed = secure_temp._quarantine_then_unlink(candidate, expected) + + assert removed is False + assert captured.read_bytes() == b"created-object" + if replacement_kind == "regular": + assert any( + path.is_file() and path.read_bytes() == b"replacement-must-survive" + for path in tmp_path.iterdir() + ) + else: + assert any(path.is_symlink() for path in tmp_path.iterdir()) + assert symlink_target.read_bytes() == b"replacement-must-survive" + + +@POSIX_ONLY +def test_quarantine_directory_never_deletes_regular_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + candidate = tmp_path / "agentseek-build-race" + candidate.mkdir(mode=0o700) + (candidate / "owned.txt").write_text("created-object", encoding="utf-8") + expected = candidate.lstat() + captured = tmp_path / "captured-original" + + def substitute_then_move(path: Path) -> Path: + path.rename(captured) + path.write_bytes(b"replacement-must-survive") + quarantine = tmp_path / ".agentseek-quarantine-directory-test" + path.rename(quarantine) + return quarantine + + monkeypatch.setattr( + secure_temp, "_move_to_quarantine", substitute_then_move, raising=False + ) + + removed = secure_temp._quarantine_then_rmtree(candidate, expected) + + assert removed is False + assert (captured / "owned.txt").read_text(encoding="utf-8") == "created-object" + assert any( + path.is_file() and path.read_bytes() == b"replacement-must-survive" + for path in tmp_path.iterdir() + ) + + +@POSIX_ONLY +def test_private_directory_never_chmods_a_pathname( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def reject_path_chmod(_path: Path, _mode: int) -> None: + raise AssertionError("private directories must be secured through their handle") + + monkeypatch.setattr(Path, "chmod", reject_path_chmod) + + with private_directory(tmp_root=tmp_path, prefix="agentseek-build-") as path: + assert path.is_dir() + + assert list(tmp_path.iterdir()) == [] + + +@POSIX_ONLY +def test_private_directory_cleans_captured_object_after_verification_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + def reject_directory(_fd: int, _path: Path, _expected: os.stat_result) -> None: + raise SecureArtifactError("Could not prove exclusive directory access.") + + monkeypatch.setattr(secure_temp, "_verify_open_directory_posix", reject_directory) + + with pytest.raises(SecureArtifactError, match="directory access"): + with private_directory(tmp_root=tmp_path, prefix="agentseek-build-"): + pytest.fail("an unverified directory must never be exposed") + + assert list(tmp_path.iterdir()) == [] + + +@POSIX_ONLY +def test_private_artifact_fails_closed_when_owner_cannot_be_proved( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(secure_temp, "_current_uid", lambda: os.getuid() + 1) + + with pytest.raises(SecureArtifactError, match="exclusive access"): + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"private", + ): + pytest.fail("an unverified path must never be exposed") + + quarantined = list(tmp_path.iterdir()) + assert len(quarantined) == 1 + assert quarantined[0].name.startswith(".agentseek-quarantine-") + assert quarantined[0].read_bytes() == b"private" + + +@POSIX_ONLY +def test_private_artifact_rejects_symlink_temporary_root(tmp_path: Path) -> None: + real_root = tmp_path / "real" + real_root.mkdir() + linked_root = tmp_path / "linked" + linked_root.symlink_to(real_root, target_is_directory=True) + + with pytest.raises(SecureArtifactError, match="temporary root"): + with private_artifact( + tmp_root=linked_root, + prefix="agentseek-compose-", + contents=b"private", + ): + pytest.fail("a symlink root must never be used") + + +@POSIX_ONLY +def test_private_artifact_fails_closed_on_symlink_substitution( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + replacement_target = tmp_path / "replacement-target" + replacement_target.write_bytes(b"must-not-read") + original_verify = secure_temp._verify_closed_posix + + def substitute(path: Path, expected: os.stat_result) -> None: + path.unlink() + path.symlink_to(replacement_target) + original_verify(path, expected) + + monkeypatch.setattr(secure_temp, "_verify_closed_posix", substitute) + + with pytest.raises(SecureArtifactError, match="exclusive access"): + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"private", + ): + pytest.fail("a substituted path must never be exposed") + + links = [path for path in tmp_path.iterdir() if path.is_symlink()] + assert len(links) == 1 + assert replacement_target.read_bytes() == b"must-not-read" + + +@POSIX_ONLY +def test_sweep_removes_only_owned_private_old_regular_artifacts( + tmp_path: Path, +) -> None: + old_private = tmp_path / "agentseek-compose-old" + old_private.write_bytes(b"old") + old_private.chmod(0o600) + old_public = tmp_path / "agentseek-compose-public" + old_public.write_bytes(b"public") + old_public.chmod(0o644) + recent = tmp_path / "agentseek-compose-recent" + recent.write_bytes(b"recent") + recent.chmod(0o600) + other_product = tmp_path / "agentseek-build-old" + other_product.write_bytes(b"other-product") + other_product.chmod(0o600) + symlink = tmp_path / "agentseek-compose-link" + symlink.symlink_to(old_private) + old = time.time() - 48 * 60 * 60 + os.utime(old_private, (old, old)) + os.utime(old_public, (old, old)) + os.utime(other_product, (old, old)) + os.utime(symlink, (old, old), follow_symlinks=False) + + removed = sweep_expired_artifacts( + tmp_root=tmp_path, + prefix="agentseek-compose-", + older_than_seconds=24 * 60 * 60, + now=time.time(), + ) + + assert removed == (old_private,) + assert not old_private.exists() + assert old_public.exists() + assert recent.exists() + assert other_product.exists() + assert symlink.is_symlink() + + +@POSIX_ONLY +def test_stale_sweep_never_deletes_regular_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + candidate = tmp_path / "agentseek-compose-old" + candidate.write_bytes(b"created-object") + candidate.chmod(0o600) + old = time.time() - 48 * 60 * 60 + os.utime(candidate, (old, old)) + captured = tmp_path / "captured-original" + moved = False + + def substitute_then_move(path: Path) -> Path: + nonlocal moved + moved = True + path.rename(captured) + path.write_bytes(b"replacement-must-survive") + path.chmod(0o600) + quarantine = tmp_path / ".agentseek-quarantine-stale-test" + path.rename(quarantine) + return quarantine + + monkeypatch.setattr( + secure_temp, "_move_to_quarantine", substitute_then_move, raising=False + ) + + removed = sweep_expired_artifacts( + tmp_root=tmp_path, + prefix="agentseek-compose-", + older_than_seconds=24 * 60 * 60, + now=time.time(), + ) + + assert moved is True + assert removed == () + assert captured.read_bytes() == b"created-object" + assert any( + path.is_file() and path.read_bytes() == b"replacement-must-survive" + for path in tmp_path.iterdir() + ) + + +@POSIX_ONLY +def test_sweep_removes_only_verified_old_private_build_directory( + tmp_path: Path, +) -> None: + old_private = tmp_path / "agentseek-build-old" + old_private.mkdir(mode=0o700) + nested = old_private / "nested" + nested.mkdir(mode=0o700) + (nested / "inventory.json").write_text("{}", encoding="utf-8") + old_public = tmp_path / "agentseek-build-public" + old_public.mkdir(mode=0o755) + recent = tmp_path / "agentseek-build-recent" + recent.mkdir(mode=0o700) + other_prefix = tmp_path / "agentseek-compose-old" + other_prefix.mkdir(mode=0o700) + symlink = tmp_path / "agentseek-build-link" + symlink.symlink_to(old_private, target_is_directory=True) + old = time.time() - 48 * 60 * 60 + for path in (old_private, old_public, other_prefix): + os.utime(path, (old, old)) + os.utime(symlink, (old, old), follow_symlinks=False) + + removed = sweep_expired_artifacts( + tmp_root=tmp_path, + prefix="agentseek-build-", + older_than_seconds=24 * 60 * 60, + now=time.time(), + ) + + assert removed == (old_private,) + assert not old_private.exists() + assert old_public.is_dir() + assert recent.is_dir() + assert other_prefix.is_dir() + assert symlink.is_symlink() + + +@POSIX_ONLY +def test_stale_directory_sweep_never_deletes_replacement( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + candidate = tmp_path / "agentseek-build-old" + candidate.mkdir(mode=0o700) + (candidate / "inventory.json").write_text("created-object", encoding="utf-8") + old = time.time() - 48 * 60 * 60 + os.utime(candidate, (old, old)) + captured = tmp_path / "captured-original" + moved = False + + def substitute_then_move(path: Path) -> Path: + nonlocal moved + moved = True + path.rename(captured) + path.mkdir(mode=0o700) + (path / "replacement.txt").write_text( + "replacement-must-survive", encoding="utf-8" + ) + quarantine = tmp_path / ".agentseek-quarantine-stale-directory-test" + path.rename(quarantine) + return quarantine + + monkeypatch.setattr(secure_temp, "_move_to_quarantine", substitute_then_move) + + removed = sweep_expired_artifacts( + tmp_root=tmp_path, + prefix="agentseek-build-", + older_than_seconds=24 * 60 * 60, + now=time.time(), + ) + + assert moved is True + assert removed == () + assert (captured / "inventory.json").read_text(encoding="utf-8") == ( + "created-object" + ) + assert any( + path.is_dir() + and (path / "replacement.txt").read_text(encoding="utf-8") + == "replacement-must-survive" + for path in tmp_path.iterdir() + if (path / "replacement.txt").exists() + ) + + +@pytest.mark.parametrize( + ("control", "entries"), + [ + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0100 | 0x0400, + ( + (0, 0x01 | 0x02 | 0x10, 0x001F01FF, "user"), + (0, 0x01 | 0x02 | 0x10, 0x001F01FF, "system"), + ), + ), + ], + ids=["inherited-file", "auto-inherited-directory"], +) +def test_windows_descendant_dacl_accepts_effective_inherited_user_and_system_aces( + control: int, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + secure_temp._validate_windows_descendant_dacl( + control=control, + entries=entries, + ) + + +@pytest.mark.parametrize( + ("control", "entries"), + [ + ( + 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0008 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x001F01FF, "other"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (1, 0x10, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10 | 0x08, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10 | 0x04, 0x001F01FF, "system"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x00120089, "system"), + ), + ), + ( + 0x0004 | 0x0400, + ( + (0, 0x10, 0x001F01FF, "user"), + (0, 0x10, 0x001F01FF, "system"), + (0, 0x10, 0x001F01FF, "other"), + ), + ), + ], + ids=[ + "missing-dacl", + "defaulted-dacl", + "unexpected-sid", + "deny-ace", + "inherit-only-ace", + "unexpected-inheritance-flag", + "partial-control-mask", + "extra-ace", + ], +) +def test_windows_descendant_dacl_rejects_unsafe_or_ineffective_aces( + control: int, + entries: tuple[tuple[int, int, int, str], ...], +) -> None: + with pytest.raises(SecureArtifactError, match="exclusive Windows access"): + secure_temp._validate_windows_descendant_dacl( + control=control, + entries=entries, + ) + + +@pytest.mark.parametrize( + ("directory", "ace_flags"), + [(False, 0x00), (True, 0x01 | 0x02)], + ids=["file", "directory"], +) +def test_windows_private_dacl_keeps_strict_explicit_root_contract( + directory: bool, + ace_flags: int, +) -> None: + secure_temp._validate_windows_private_dacl( + control=0x0004 | 0x1000, + entries=( + (0, ace_flags, 0x001F01FF, "user"), + (0, ace_flags, 0x001F01FF, "system"), + ), + directory=directory, + ) + + for control in (0x0004, 0x0004 | 0x1000 | 0x0400): + with pytest.raises(SecureArtifactError, match="exclusive Windows access"): + secure_temp._validate_windows_private_dacl( + control=control, + entries=( + (0, ace_flags, 0x001F01FF, "user"), + (0, ace_flags, 0x001F01FF, "system"), + ), + directory=directory, + ) + + +@pytest.mark.skipif(os.name != "nt", reason="requires native Windows security APIs") +def test_windows_private_artifact_dacl_round_trips_by_security_api( + tmp_path: Path, +) -> None: + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"private", + ) as path: + secure_temp._verify_private_dacl(path, directory=False) + secure_temp._verify_private_dacl(path.parent, directory=True) + + +@pytest.mark.skipif(os.name != "nt", reason="requires native Windows security APIs") +def test_windows_sweep_removes_private_directory_with_inherited_descendants( + tmp_path: Path, +) -> None: + manager = private_directory(tmp_root=tmp_path, prefix="agentseek-build-") + path = manager.__enter__() + try: + nested = path / "nested" + nested.mkdir() + (nested / "ordinary.txt").write_text("ordinary", encoding="utf-8") + now = time.time() + old = now - 48 * 60 * 60 + os.utime(path, (old, old)) + + expected = path.lstat() + assert secure_temp._quarantined_directory_matches(path, expected) + secure_temp._verify_descendant_dacl(nested, directory=True) + secure_temp._verify_descendant_dacl(nested / "ordinary.txt", directory=False) + assert secure_temp._verify_windows_private_tree(path) + + removed = sweep_expired_artifacts( + tmp_root=tmp_path, + prefix="agentseek-build-", + older_than_seconds=24 * 60 * 60, + now=now, + ) + + assert removed == (path,) + assert not path.exists() + finally: + manager.__exit__(None, None, None) + + +@POSIX_ONLY +def test_private_artifact_opens_binary_on_platforms_with_text_mode_fds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + binary_flag = 0x40000000 + real_open = os.open + observed_flags: list[int] = [] + + monkeypatch.setattr(os, "O_BINARY", binary_flag, raising=False) + + def binary_open(path, flags, mode=0o777): + observed_flags.append(flags) + return real_open(path, flags & ~binary_flag, mode) + + monkeypatch.setattr(os, "open", binary_open) + + with private_artifact( + tmp_root=tmp_path, + prefix="agentseek-compose-", + contents=b"line-one\nline-two\n", + ) as path: + assert path.read_bytes() == b"line-one\nline-two\n" + + assert observed_flags + assert all(flags & binary_flag for flags in observed_flags) + + +@POSIX_ONLY +def test_verify_private_directory_rechecks_owner_mode_and_identity( + tmp_path: Path, +) -> None: + directory = tmp_path / "bundle-output" + directory.mkdir(mode=0o700) + + expected = secure_temp.verify_private_directory(directory) + assert (expected.st_dev, expected.st_ino) == ( + directory.lstat().st_dev, + directory.lstat().st_ino, + ) + + directory.chmod(0o755) + with pytest.raises(SecureArtifactError, match="exclusive directory access"): + secure_temp.verify_private_directory(directory, expected=expected) + + +@pytest.mark.skipif(os.name != "nt", reason="requires native Windows security APIs") +def test_verify_private_directory_uses_native_windows_dacl_collection( + tmp_path: Path, +) -> None: + with private_directory(tmp_root=tmp_path, prefix="agentseek-build-") as path: + expected = secure_temp.verify_private_directory(path) + secure_temp.verify_private_directory(path, expected=expected) + secure_temp._verify_private_dacl(path, directory=True) + + +@POSIX_ONLY +def test_create_private_directory_establishes_exact_private_output( + tmp_path: Path, +) -> None: + output = tmp_path / "persistent-bundle" + + expected = secure_temp.create_private_directory(output) + + assert stat.S_IMODE(output.stat().st_mode) == 0o700 + assert output.stat().st_uid == os.getuid() + secure_temp.verify_private_directory(output, expected=expected) + + with pytest.raises(FileExistsError): + secure_temp.create_private_directory(output) + + +@pytest.mark.skipif(os.name != "nt", reason="requires native Windows security APIs") +def test_create_private_directory_establishes_native_windows_output_dacl( + tmp_path: Path, +) -> None: + output = tmp_path / "persistent-bundle" + + expected = secure_temp.create_private_directory(output) + + secure_temp.verify_private_directory(output, expected=expected) + secure_temp._verify_private_dacl(output, directory=True) diff --git a/uv.lock b/uv.lock index d9448c4..27f6ecb 100644 --- a/uv.lock +++ b/uv.lock @@ -29,7 +29,7 @@ wheels = [ [[package]] name = "agentseek-api" -version = "0.2.3" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "aiomysql" }, @@ -46,6 +46,7 @@ dependencies = [ { name = "langgraph" }, { name = "langgraph-sdk" }, { name = "mcp" }, + { name = "packaging" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pymysql" }, @@ -90,6 +91,7 @@ requires-dist = [ { name = "langgraph", specifier = ">=1.0.6" }, { name = "langgraph-sdk", specifier = ">=0.3.5" }, { name = "mcp", specifier = ">=1.27.1,<2" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, { name = "pymysql", specifier = ">=1.1.0" },