From 783ca8141b277818c2ebac620285f722eb499598 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 15:51:39 +0800 Subject: [PATCH 01/42] feat: define explicit container environment policies --- src/agentseek_api/cli.py | 408 +++++++++++++++++------ src/agentseek_api/container_policy.py | 334 +++++++++++++++++++ src/agentseek_api/dotenv_adapter.py | 110 +++++-- src/agentseek_api/environment.py | 395 +++++++++++++++++++++++ tests/container_plan_helpers.py | 27 ++ tests/unit/test_cli.py | 447 ++++++++++++++++++++------ tests/unit/test_container_policy.py | 413 ++++++++++++++++++++++++ tests/unit/test_dotenv_adapter.py | 31 ++ 8 files changed, 1959 insertions(+), 206 deletions(-) create mode 100644 src/agentseek_api/container_policy.py create mode 100644 src/agentseek_api/environment.py create mode 100644 tests/container_plan_helpers.py create mode 100644 tests/unit/test_container_policy.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 60971cf..1326ee8 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -15,8 +15,20 @@ from typing import TextIO from agentseek_api import __version__ +from agentseek_api.container_policy import ( + APP_CONTAINER_POLICY, + HOST_RUNTIME_POLICY, + ContainerSelection, + select_application_payload, +) from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.environment import ( + CommandDerivedAssignment, + EnvironmentPlan, + EnvironmentTarget, + resolve_environment, +) from agentseek_api.process_supervisor import ( ForegroundChildSupervisor, ForwardingSignalGuard, @@ -88,6 +100,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 +242,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 +269,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 +393,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, ) @@ -324,31 +414,35 @@ def build_runtime_env( base_env: dict[str, str] | None = None, ) -> dict[str, str]: 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 - - if config is not None: - if config.env_file is not None: - _apply_env_layer( - env, - _read_env_layer(config.env_file, inherited=inherited), - ) - 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), - ) - - env.update(inherited) - env.pop("AGENTSEEK_GRAPHS", None) + cli_dotenv = _resolve_path(env_file, cwd=cwd) if env_file else None + assignments: tuple[CommandDerivedAssignment, ...] = () + inherited.pop("AGENTSEEK_GRAPHS", None) if config_path is not None: - env["AGENTSEEK_GRAPHS"] = str(config_path) - return env + assignments = ( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), + values={"AGENTSEEK_GRAPHS": str(config_path)}, + reason="selected host config path", + ), + ) + try: + resolved = resolve_environment( + 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=assignments, + explicit_names=frozenset(), + ), + HOST_RUNTIME_POLICY, + ) + except DotenvFileError as exc: + raise CliError(str(exc)) from exc + return dict(resolved.values) def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: @@ -387,7 +481,9 @@ def build_scheduler_command() -> list[str]: ] -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 +567,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,7 +645,9 @@ def _terminate_child(_signum, _frame) -> None: continue -def _execute_runtime_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +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) command = build_uvicorn_command( @@ -588,13 +688,17 @@ def _execute_dev_command( ) -def _execute_worker_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +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_scheduler_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +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)) @@ -604,12 +708,18 @@ 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 +727,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,7 +738,12 @@ 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 @@ -640,12 +757,19 @@ def _resolve_dependency_path(dependency: str, *, config_path: Path) -> Path: def _is_local_dependency(dependency: str) -> bool: - return dependency == "." or dependency.startswith(".") or "/" in dependency or "\\" in dependency + 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(): + 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" @@ -670,14 +794,18 @@ def _find_installable_project_root(*, start: Path, cwd: Path) -> Path: 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(): + 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]]: +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() @@ -685,13 +813,22 @@ def _docker_dependency_plan(*, config: CliConfig, config_path: Path, cwd: Path) 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) + 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_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 @@ -712,17 +849,49 @@ def _ambient_container_env() -> 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, - cwd=cwd, - base_env=_ambient_container_env(), - ) - env["AGENTSEEK_GRAPHS"] = _container_config_path(config_path=config_path, cwd=cwd) +def build_container_env( + *, + config_path: Path, + env_file: str | None, + cwd: Path, + pass_env: frozenset[str] = frozenset(), + compose_env: frozenset[str] = frozenset(), +) -> dict[str, str]: + config = _load_cli_config(config_path) + cli_dotenv = _resolve_path(env_file, cwd=cwd) if env_file else None + container_manifest = _container_config_path(config_path=config_path, cwd=cwd) + try: + resolved = resolve_environment( + EnvironmentPlan( + config_path=config_path, + config_dotenv=config.env_file, + config_mapping=config.env_mapping, + auth_path=config.auth_path, + cli_dotenv=cli_dotenv, + launch_environment=dict(os.environ), + command_assignments=( + CommandDerivedAssignment( + targets=frozenset({EnvironmentTarget.APP_CONTAINER}), + values={"AGENTSEEK_GRAPHS": container_manifest}, + reason="container manifest path", + ), + ), + explicit_names=pass_env, + ), + APP_CONTAINER_POLICY, + ) + env = dict( + select_application_payload( + resolved, ContainerSelection(pass_env=pass_env, compose_env=compose_env) + ) + ) + except (DotenvFileError, ValueError) as exc: + raise CliError(str(exc)) from exc 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 @@ -734,7 +903,9 @@ def _default_base_image(*, python_version: str | None, image_distro: str | None) 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( + "image_distro 'wolfi' is not supported without an explicit base_image." + ) raise CliError(f"Unsupported image_distro '{image_distro}'.") @@ -742,7 +913,9 @@ 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")) + return any( + marker in normalized for marker in ("debian", "ubuntu", "bookworm", "bullseye") + ) def _validate_base_image(base_image: str) -> None: @@ -754,7 +927,9 @@ def _validate_base_image(base_image: str) -> None: ) -def render_dockerfile(*, config_path: Path, cwd: Path, base_image_override: str | None = None) -> str: +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) @@ -764,16 +939,25 @@ def render_dockerfile(*, config_path: Path, cwd: Path, base_image_override: str 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: + 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, + 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_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( [ @@ -787,7 +971,10 @@ def render_dockerfile(*, config_path: Path, cwd: Path, base_image_override: str "", "WORKDIR /deps/agent", "COPY . /deps/agent", - *[f"RUN {pip_install_prefix}{command}" for command in dependency_install_commands], + *[ + 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}", @@ -797,29 +984,45 @@ def render_dockerfile(*, config_path: Path, cwd: Path, base_image_override: str ) -def write_dockerfile(*, config_path: Path, save_path: Path, cwd: Path, base_image_override: str | None = None) -> Path: +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), + 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 +) -> 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." + ) 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") return 0 -def _execute_build_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +def _execute_build_command( + args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path +) -> 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." + ) generated_dockerfile = write_dockerfile( config_path=config_path, save_path=(cwd / ".agentseek" / "Dockerfile").resolve(), @@ -874,14 +1077,29 @@ def _container_exists( return runner(inspect_command, env=env, cwd=str(cwd)) == 0 -def _execute_up_command(args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path) -> int: +def _execute_up_command( + args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path +) -> 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 + config = _load_cli_config(config_path) + selection = ContainerSelection( + pass_env=frozenset(args.pass_env), + compose_env=frozenset((*config.compose_env, *args.compose_pass_env)), + ) 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) + container_env = build_container_env( + config_path=config_path, + env_file=args.env_file, + cwd=cwd, + pass_env=selection.pass_env, + compose_env=selection.compose_env, + ) if not image: image = f"agentseek-up:{args.port}" generated_dockerfile = write_dockerfile( @@ -947,7 +1165,9 @@ def _execute_up_command(args: argparse.Namespace, *, runner: Callable[..., int], 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) + _wait_for_http_ready( + f"http://127.0.0.1:{args.port}/health", timeout_seconds=30.0 + ) return 0 @@ -993,6 +1213,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) diff --git a/src/agentseek_api/container_policy.py b/src/agentseek_api/container_policy.py new file mode 100644 index 0000000..a215657 --- /dev/null +++ b/src/agentseek_api/container_policy.py @@ -0,0 +1,334 @@ +"""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 _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) + ) + 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 + ) + 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/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..ed22026 --- /dev/null +++ b/src/agentseek_api/environment.py @@ -0,0 +1,395 @@ +"""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 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"] + + +@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 + + +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"], +) -> tuple[ + dict[str, str | None], + dict[str, EnvironmentOrigin], + set[str], + dict[str, frozenset[str]], + tuple[EnvironmentDiagnostic, ...], +]: + context: dict[str, str | None] = dict(ambient) + values: dict[str, str | None] = {} + origins: dict[str, EnvironmentOrigin] = {} + declared: set[str] = set() + unresolved: dict[str, frozenset[str]] = {} + diagnostics: list[EnvironmentDiagnostic] = [] + 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 + diagnostics.extend( + EnvironmentDiagnostic( + "unresolved-reference", + binding.key, + _source_name(document.path), + binding.line, + ) + for _ in missing + ) + return values, origins, declared, unresolved, tuple(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 _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]], +) -> None: + for key, value in values.items(): + if value is None: + continue + 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], +) -> dict[str, str]: + if policy.interpolation_scope is NameScope.ALL: + return dict(launch) + if policy.interpolation_scope is NameScope.CONTAINER_ELIGIBLE: + 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")) + + 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 + ) + 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) + _merge_values( + final, origins, unresolved, values, document_origins, document_unresolved + ) + 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, {}) + 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", + ) + }, + {}, + ) + 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) + _merge_values( + final, origins, unresolved, values, document_origins, document_unresolved + ) + 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 = { + key: value + for key, value in plan.launch_environment.items() + if key in eligible_names + } + else: + launch_values = {} + _merge_values( + final, + origins, + unresolved, + launch_values, + {key: EnvironmentOrigin("launch", "launch") for key in launch_values}, + {}, + ) + + 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 + }, + {}, + ) + + 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/tests/container_plan_helpers.py b/tests/container_plan_helpers.py new file mode 100644 index 0000000..5bbfe05 --- /dev/null +++ b/tests/container_plan_helpers.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import AbstractSet + +from agentseek_api.environment import ResolvedEnvironment + + +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/unit/test_cli.py b/tests/unit/test_cli.py index 519bf84..3497dd5 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -21,6 +21,27 @@ 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"] + + @dataclass class _RunCapture: calls: list[list[str]] | None = None @@ -28,7 +49,9 @@ 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) @@ -510,7 +533,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 +557,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 +599,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 +625,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 +640,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 @@ -718,7 +755,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 +796,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 +830,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 +857,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 +913,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 +958,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 +981,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 +1060,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 +1088,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,12 +1111,17 @@ 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 project_config["version"] == __version__ @@ -1038,12 +1129,17 @@ def test_release_versions_are_consistent() -> None: def test_package_exposes_library_and_cli_entrypoints() -> None: - project_config = tomllib.loads(Path("pyproject.toml").read_text(encoding="utf-8"))["project"] + 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 project_config["optional-dependencies"]["embedded"] - assert any("langchain-oceanbase" in dep for dep in 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: @@ -1088,7 +1184,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 +1195,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,7 +1216,9 @@ 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) @@ -1126,16 +1228,24 @@ def test_dockerfile_command_writes_langgraph_compatible_runtime_file(tmp_path: P assert exit_code == 0 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 "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 + ) -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( @@ -1159,7 +1269,9 @@ def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag(tmp_pat assert "ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json" not in content -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 +1287,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 +1310,18 @@ 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") 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 ( + "RUN PIP_CONFIG_FILE=/deps/agent/pip.conf pip install --no-cache-dir /deps/agent" + in content + ) def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> None: @@ -1222,7 +1341,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 +1358,30 @@ 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 ( + "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 -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,7 +1401,9 @@ 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") @@ -1276,7 +1411,9 @@ def test_dockerfile_command_skips_root_install_when_root_is_not_installable(tmp_ assert "RUN pip install --no-cache-dir ." 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,7 +1448,9 @@ 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") @@ -1319,7 +1458,9 @@ def test_dockerfile_command_uses_manifest_project_root_not_invocation_root(tmp_p assert "RUN pip install --no-cache-dir /deps/agent\n" 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( @@ -1346,15 +1487,22 @@ 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 + assert ( + "RUN pip install --no-cache-dir /deps/agent/examples/docker_ci_auth" + not in content + ) -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) @@ -1387,10 +1535,16 @@ def test_build_command_plans_docker_build_from_generated_dockerfile(tmp_path: Pa ] 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 ( + "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 + ) def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: @@ -1403,27 +1557,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 +1626,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 +1638,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 +1670,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 +1693,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 +1723,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 +1750,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,7 +1777,11 @@ 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() @@ -1608,13 +1804,19 @@ def test_dockerfile_command_rejects_non_apt_base_image(tmp_path: Path) -> None: ) 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 "require apt-get" in stderr.getvalue() -def test_dockerfile_command_rejects_unknown_non_debian_base_image(tmp_path: Path) -> None: +def test_dockerfile_command_rejects_unknown_non_debian_base_image( + tmp_path: Path, +) -> None: from agentseek_api.cli import main config_path = tmp_path / "langgraph.json" @@ -1631,13 +1833,19 @@ def test_dockerfile_command_rejects_unknown_non_debian_base_image(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 "Debian/Ubuntu-compatible" 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,7 +1862,9 @@ 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") @@ -1751,7 +1961,15 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: 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[1] == [ + "docker", + "compose", + "-f", + str(compose_path.resolve()), + "up", + "-d", + "--force-recreate", + ] assert capture.calls[2][-1] == "agentseek:test" @@ -1779,7 +1997,9 @@ 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) @@ -1788,7 +2008,9 @@ def test_up_command_rejects_existing_container_before_starting_compose_sidecars( stderr = io.StringIO() capture = _RunCapture() - def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: + 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 @@ -1815,7 +2037,9 @@ def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: s 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) @@ -1860,11 +2084,16 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri(tmp_path: 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 ( + 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" @@ -1924,7 +2153,9 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths(tmp_path 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) @@ -1949,7 +2180,9 @@ def test_up_command_passes_ambient_env_into_container(tmp_path: Path, monkeypatc 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( @@ -2035,7 +2268,9 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No 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_rejects_non_apt_base_image_before_docker_build( + tmp_path: Path, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) @@ -2058,20 +2293,35 @@ def test_up_command_rejects_non_apt_base_image_before_docker_build(tmp_path: Pat assert "require apt-get" in stderr.getvalue() -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: + 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 exit_code = main(["up", "--port", "8125"], runner=fail_build, 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 == [ + [ + "docker", + "build", + "--pull", + "-t", + "agentseek-up:8125", + "-f", + str((tmp_path / ".agentseek" / "Dockerfile").resolve()), + ".", + ] + ] def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) -> None: @@ -2081,7 +2331,9 @@ def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) stderr = io.StringIO() capture = _RunCapture() - def existing_container_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: + 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 @@ -2132,14 +2384,21 @@ def fake_run(command: list[str], **kwargs: object) -> _Completed: ) assert exists is True - assert observed["command"] == ["docker", "container", "inspect", "agentseek-up-8123"] + 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 -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) diff --git a/tests/unit/test_container_policy.py b/tests/unit/test_container_policy.py new file mode 100644 index 0000000..5a642e8 --- /dev/null +++ b/tests/unit/test_container_policy.py @@ -0,0 +1,413 @@ +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_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 { + "AGENTSEEK_API_BASE", + "AGENTSEEK_API_KEY", + "AGENTSEEK_MODEL", + "AGENTSEEK_MODEL_API_KEY", + "AGENTSEEK_MODEL_PROVIDER", + "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", + } <= APPLICATION_COMPATIBILITY_KEYS + + +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_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: From a245534b42b5fc4323de2c5df17fb3e9a0e67462 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 16:02:04 +0800 Subject: [PATCH 02/42] fix: keep container policies out of docker execution --- src/agentseek_api/cli.py | 167 +++++++++++++++++--------- src/agentseek_api/container_policy.py | 16 +++ src/agentseek_api/environment.py | 145 ++++++++++++++++++---- tests/unit/test_cli.py | 49 ++++++++ tests/unit/test_container_policy.py | 151 ++++++++++++++++++++++- 5 files changed, 444 insertions(+), 84 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 1326ee8..8ddebf9 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -16,10 +16,8 @@ from agentseek_api import __version__ from agentseek_api.container_policy import ( - APP_CONTAINER_POLICY, HOST_RUNTIME_POLICY, ContainerSelection, - select_application_payload, ) from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file @@ -413,38 +411,115 @@ 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 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) 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: tuple[CommandDerivedAssignment, ...] = () + assignments: list[CommandDerivedAssignment] = [] inherited.pop("AGENTSEEK_GRAPHS", None) if config_path is not None: - assignments = ( + assignments.append( CommandDerivedAssignment( targets=frozenset({EnvironmentTarget.HOST_RUNTIME}), values={"AGENTSEEK_GRAPHS": str(config_path)}, reason="selected host config path", - ), + ) ) - try: - resolved = resolve_environment( - 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=assignments, - explicit_names=frozenset(), - ), - HOST_RUNTIME_POLICY, + 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) +def build_container_command_assignments( + *, config_path: Path, cwd: Path, postgres_uri: str | 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", + ) + ) + return tuple(assignments) + + +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.""" + + 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_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: command = [ sys.executable, @@ -668,8 +743,14 @@ def _execute_dev_command( _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 = _resolve_host_environment_plan( + build_host_environment_plan( + config_path=config_path, + env_file=args.env_file, + cwd=cwd, + role="dev", + ) + ) command = build_uvicorn_command( host=args.host, port=args.port, @@ -854,39 +935,14 @@ def build_container_env( config_path: Path, env_file: str | None, cwd: Path, - pass_env: frozenset[str] = frozenset(), - compose_env: frozenset[str] = frozenset(), ) -> dict[str, str]: - config = _load_cli_config(config_path) - cli_dotenv = _resolve_path(env_file, cwd=cwd) if env_file else None - container_manifest = _container_config_path(config_path=config_path, cwd=cwd) - try: - resolved = resolve_environment( - EnvironmentPlan( - config_path=config_path, - config_dotenv=config.env_file, - config_mapping=config.env_mapping, - auth_path=config.auth_path, - cli_dotenv=cli_dotenv, - launch_environment=dict(os.environ), - command_assignments=( - CommandDerivedAssignment( - targets=frozenset({EnvironmentTarget.APP_CONTAINER}), - values={"AGENTSEEK_GRAPHS": container_manifest}, - reason="container manifest path", - ), - ), - explicit_names=pass_env, - ), - APP_CONTAINER_POLICY, - ) - env = dict( - select_application_payload( - resolved, ContainerSelection(pass_env=pass_env, compose_env=compose_env) - ) - ) - except (DotenvFileError, ValueError) as exc: - raise CliError(str(exc)) from exc + env = build_runtime_env( + config_path=config_path, + env_file=env_file, + cwd=cwd, + base_env=_ambient_container_env(), + ) + 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( @@ -1087,18 +1143,11 @@ def _execute_up_command( ) image = args.image - config = _load_cli_config(config_path) - selection = ContainerSelection( - pass_env=frozenset(args.pass_env), - compose_env=frozenset((*config.compose_env, *args.compose_pass_env)), - ) 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, - pass_env=selection.pass_env, - compose_env=selection.compose_env, ) if not image: image = f"agentseek-up:{args.port}" diff --git a/src/agentseek_api/container_policy.py b/src/agentseek_api/container_policy.py index a215657..5b2e475 100644 --- a/src/agentseek_api/container_policy.py +++ b/src/agentseek_api/container_policy.py @@ -195,6 +195,20 @@ def _windows_index( 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()) @@ -251,6 +265,7 @@ def select_application_payload( | 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: @@ -300,6 +315,7 @@ def select_compose_payload( _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) diff --git a/src/agentseek_api/environment.py b/src/agentseek_api/environment.py index ed22026..0083723 100644 --- a/src/agentseek_api/environment.py +++ b/src/agentseek_api/environment.py @@ -121,6 +121,20 @@ 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) @@ -138,6 +152,7 @@ def _resolve_document( *, ambient: Mapping[str, str], source_kind: Literal["config-dotenv", "cli-dotenv"], + platform: str, ) -> tuple[ dict[str, str | None], dict[str, EnvironmentOrigin], @@ -145,12 +160,15 @@ def _resolve_document( dict[str, frozenset[str]], tuple[EnvironmentDiagnostic, ...], ]: - context: dict[str, str | None] = dict(ambient) + 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]] = {} - diagnostics: list[EnvironmentDiagnostic] = [] for binding in document: if binding.key is None: continue @@ -174,16 +192,16 @@ def _resolve_document( declared.add(binding.key) if missing: unresolved[binding.key] = missing - diagnostics.extend( - EnvironmentDiagnostic( - "unresolved-reference", - binding.key, - _source_name(document.path), - binding.line, - ) - for _ in missing - ) - return values, origins, declared, unresolved, tuple(diagnostics) + 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( @@ -202,6 +220,22 @@ def _check_windows_duplicates( 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], @@ -209,10 +243,25 @@ def _merge_values( 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: @@ -226,10 +275,18 @@ def _allowed_ambient( *, 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 {} @@ -258,6 +315,17 @@ def resolve_environment( 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) @@ -271,7 +339,10 @@ def resolve_environment( eligible_names.update(APPLICATION_COMPATIBILITY_KEYS) ambient = _allowed_ambient( - plan.launch_environment, policy=policy, eligible_names=eligible_names + plan.launch_environment, + policy=policy, + eligible_names=eligible_names, + platform=platform, ) final: dict[str, str] = {} origins: dict[str, EnvironmentOrigin] = {} @@ -288,9 +359,17 @@ def resolve_environment( document_declared, document_unresolved, document_diagnostics, - ) = _resolve_document(document, ambient=ambient, source_kind=source_kind) + ) = _resolve_document( + document, ambient=ambient, source_kind=source_kind, platform=platform + ) _merge_values( - final, origins, unresolved, values, document_origins, document_unresolved + final, + origins, + unresolved, + values, + document_origins, + document_unresolved, + platform=platform, ) declared.update(document_declared) diagnostics.extend(document_diagnostics) @@ -303,7 +382,15 @@ def resolve_environment( ) for key in mapping_values } - _merge_values(final, origins, unresolved, mapping_values, mapping_origins, {}) + _merge_values( + final, + origins, + unresolved, + mapping_values, + mapping_origins, + {}, + platform=platform, + ) declared.update(mapping_values) if plan.auth_path is not None: @@ -320,6 +407,7 @@ def resolve_environment( ) }, {}, + platform=platform, ) declared.add("AUTH_MODULE_PATH") @@ -332,9 +420,17 @@ def resolve_environment( document_declared, document_unresolved, document_diagnostics, - ) = _resolve_document(document, ambient=ambient, source_kind=source_kind) + ) = _resolve_document( + document, ambient=ambient, source_kind=source_kind, platform=platform + ) _merge_values( - final, origins, unresolved, values, document_origins, document_unresolved + final, + origins, + unresolved, + values, + document_origins, + document_unresolved, + platform=platform, ) declared.update(document_declared) diagnostics.extend(document_diagnostics) @@ -342,11 +438,12 @@ def resolve_environment( if policy.assignment_scope is NameScope.ALL: launch_values = dict(plan.launch_environment) elif policy.assignment_scope is NameScope.CONTAINER_ELIGIBLE: - launch_values = { - key: value - for key, value in plan.launch_environment.items() - if key in eligible_names - } + launch_values = _allowed_ambient( + plan.launch_environment, + policy=policy, + eligible_names=eligible_names, + platform=platform, + ) else: launch_values = {} _merge_values( @@ -356,6 +453,7 @@ def resolve_environment( launch_values, {key: EnvironmentOrigin("launch", "launch") for key in launch_values}, {}, + platform=platform, ) for assignment in plan.command_assignments: @@ -374,6 +472,7 @@ def resolve_environment( for key in assignment.values }, {}, + platform=platform, ) if policy.unresolved == "error": diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 3497dd5..bbeaf36 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -42,6 +42,55 @@ def test_up_parser_collects_explicit_container_and_compose_names() -> None: assert args.compose_pass_env == ["TOKEN"] +def test_up_parses_pass_env_without_forwarding_it_to_live_docker_execution( + 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 = _RunCapture() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--pass-env", + "ARBITRARY_HOST_SECRET", + ], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + assert "ARBITRARY_HOST_SECRET" not in _docker_env_from_run_command(capture.calls[1]) + + +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 diff --git a/tests/unit/test_container_policy.py b/tests/unit/test_container_policy.py index 5a642e8..5b6ef70 100644 --- a/tests/unit/test_container_policy.py +++ b/tests/unit/test_container_policy.py @@ -208,6 +208,123 @@ def test_explicit_pass_env_allows_ambient_reference_and_export(tmp_path: Path) - 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" + ) + + 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", + }, + ] + + def test_missing_explicit_pass_env_is_an_error() -> None: with pytest.raises(ContainerPolicyError, match="MISSING"): select_application_payload( @@ -223,12 +340,15 @@ def test_application_registry_contains_every_settings_field() -> None: def test_provider_registry_matches_documented_exact_snapshot() -> None: - assert { + 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", @@ -251,7 +371,34 @@ def test_provider_registry_matches_documented_exact_snapshot() -> None: "VLM_API_KEY", "VLM_BASE_URL", "SILICONFLOW_API_KEY", - } <= APPLICATION_COMPATIBILITY_KEYS + "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( From 62de6d1011c02a34a22fdce9dded93d52c7f70d2 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 16:32:02 +0800 Subject: [PATCH 03/42] fix: model preloaded dev container assignment --- src/agentseek_api/cli.py | 15 +++++++++++++- tests/unit/test_container_policy.py | 31 +++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 8ddebf9..34faad6 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -471,7 +471,12 @@ def _resolve_host_environment_plan(plan: EnvironmentPlan) -> dict[str, str]: def build_container_command_assignments( - *, config_path: Path, cwd: Path, postgres_uri: str | None + *, + config_path: Path, + cwd: Path, + postgres_uri: str | None, + role: str | None = None, + environment_mode: str | None = None, ) -> tuple[CommandDerivedAssignment, ...]: assignments = [ CommandDerivedAssignment( @@ -495,6 +500,14 @@ def build_container_command_assignments( 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", + ) + ) return tuple(assignments) diff --git a/tests/unit/test_container_policy.py b/tests/unit/test_container_policy.py index 5b6ef70..425060a 100644 --- a/tests/unit/test_container_policy.py +++ b/tests/unit/test_container_policy.py @@ -310,6 +310,27 @@ def test_cli_builders_construct_target_scoped_command_assignments( 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" @@ -323,6 +344,16 @@ def test_cli_builders_construct_target_scoped_command_assignments( "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: From dd0a3b3f85dd0f3e7af249970f1de62be29069fd Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 17:31:05 +0800 Subject: [PATCH 04/42] feat: isolate Docker run payload transport --- src/agentseek_api/cli.py | 190 +++++++++++---- src/agentseek_api/docker_runtime.py | 328 +++++++++++++++++++++++++ tests/unit/test_cli.py | 358 ++++++++++++++++++---------- tests/unit/test_docker_runtime.py | 348 +++++++++++++++++++++++++++ 4 files changed, 1056 insertions(+), 168 deletions(-) create mode 100644 src/agentseek_api/docker_runtime.py create mode 100644 tests/unit/test_docker_runtime.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 34faad6..10f4018 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -16,13 +16,26 @@ from agentseek_api import __version__ from agentseek_api.container_policy import ( + APP_CONTAINER_POLICY, HOST_RUNTIME_POLICY, ContainerSelection, + docker_control_environment, + select_application_payload, ) from agentseek_api.constants import DEFAULT_API_PORT +from agentseek_api.docker_runtime import ( + DockerRuntimeError, + LegacyRunnerAdapter, + ProcessTransport, + SubprocessTransport, + build_docker_control_invocation, + build_docker_query_invocation, + build_docker_run_invocation, +) from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file from agentseek_api.environment import ( CommandDerivedAssignment, + ContainerPolicyError, EnvironmentPlan, EnvironmentTarget, resolve_environment, @@ -533,6 +546,55 @@ def normalized(names: Sequence[str]) -> frozenset[str]: ) +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, + cwd: Path, +) -> dict[str, str]: + 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_module_path = payload.get("AUTH_MODULE_PATH") + if auth_module_path: + payload["AUTH_MODULE_PATH"] = _containerize_symbol_reference( + auth_module_path, cwd=cwd + ) + return payload + + def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: command = [ sys.executable, @@ -1085,7 +1147,7 @@ def _execute_dockerfile_command( def _execute_build_command( - args: argparse.Namespace, *, runner: Callable[..., int], cwd: Path + args: argparse.Namespace, *, process_transport: ProcessTransport, cwd: Path ) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) if config_path is None: @@ -1103,8 +1165,18 @@ def _execute_build_command( 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)) + plan = build_host_environment_plan( + config_path=config_path, + env_file=args.env_file, + cwd=cwd, + role=None, + ) + invocation = build_docker_control_invocation( + argv=tuple(command), + docker_control=docker_control_environment(plan), + cwd=cwd, + ) + return process_transport(invocation).returncode def _container_name_for_port(port: int) -> str: @@ -1128,26 +1200,20 @@ def _wait_for_http_ready(url: str, *, timeout_seconds: float) -> None: def _container_exists( name: str, *, - runner: Callable[..., int], - env: dict[str, 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", "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 + args: argparse.Namespace, *, process_transport: ProcessTransport, cwd: Path ) -> int: config_path = discover_config_path(explicit_path=args.config, cwd=cwd) if config_path is None: @@ -1156,11 +1222,21 @@ def _execute_up_command( ) image = args.image - env = build_runtime_env(config_path=config_path, env_file=args.env_file, cwd=cwd) - container_env = build_container_env( + 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)) + application_payload = _resolve_application_container_payload( + environment_plan, selection=selection, cwd=cwd ) if not image: image = f"agentseek-up:{args.port}" @@ -1174,7 +1250,10 @@ def _execute_up_command( 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)) + build_invocation = build_docker_control_invocation( + argv=tuple(build_command), docker_control=docker_control, cwd=cwd + ) + build_exit_code = process_transport(build_invocation).returncode if build_exit_code != 0: return build_exit_code @@ -1186,8 +1265,18 @@ def _execute_up_command( 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): + remove_invocation = build_docker_control_invocation( + argv=("docker", "rm", "-f", container_name), + docker_control=docker_control, + cwd=cwd, + ) + process_transport(remove_invocation) + elif _container_exists( + container_name, + 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." ) @@ -1196,11 +1285,14 @@ def _execute_up_command( compose_command = ["docker", "compose", "-f", str(compose_path), "up", "-d"] if args.recreate: compose_command.append("--force-recreate") - compose_exit_code = runner(compose_command, env=env, cwd=str(cwd)) + compose_invocation = build_docker_control_invocation( + argv=tuple(compose_command), docker_control=docker_control, cwd=cwd + ) + compose_exit_code = process_transport(compose_invocation).returncode if compose_exit_code != 0: return compose_exit_code - command = [ + base_argv = ( "docker", "run", "--detach", @@ -1210,20 +1302,16 @@ def _execute_up_command( "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", - ] - ) - command.append(image) - run_exit_code = runner(command, env=env, cwd=str(cwd)) + ) + run_invocation = build_docker_run_invocation( + base_argv=base_argv, + image=image, + docker_control=docker_control, + application_payload=application_payload, + container_argv=(), + cwd=cwd, + ) + run_exit_code = process_transport(run_invocation).returncode if run_exit_code != 0: return run_exit_code if args.wait: @@ -1324,6 +1412,7 @@ 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, @@ -1331,6 +1420,9 @@ def run_namespace( 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 @@ -1362,7 +1454,9 @@ def run_namespace( if command == "dockerfile": return _execute_dockerfile_command(args, stdout=out, cwd=workdir) if command == "build": - return _execute_build_command(args, runner=run, cwd=workdir) + return _execute_build_command( + args, process_transport=docker_transport, cwd=workdir + ) if command == "up": _reject_unsupported_options( args, @@ -1374,9 +1468,11 @@ def run_namespace( "verbose", ), ) - return _execute_up_command(args, runner=run, cwd=workdir) + return _execute_up_command( + args, process_transport=docker_transport, cwd=workdir + ) raise CliError(f"Unsupported command '{command}'.") - except CliError as exc: + except (CliError, ContainerPolicyError, DockerRuntimeError) as exc: err.write(f"{exc}\n") return 2 @@ -1386,6 +1482,7 @@ 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, @@ -1394,7 +1491,14 @@ def main( 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, + ) if __name__ == "__main__": diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py new file mode 100644 index 0000000..e5fa253 --- /dev/null +++ b/src/agentseek_api/docker_runtime.py @@ -0,0 +1,328 @@ +"""Value-redacted, shell-free process transport for Docker boundaries.""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +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.environment import ContainerPolicyError + +DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS = 10.0 +IMAGE_COMPATIBILITY_FORMAT = ( + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]" +) + +_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.*)?$" +) + + +class DockerRuntimeError(RuntimeError): + """A value-free Docker transport 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)) + object.__setattr__( + self, "environment", MappingProxyType(dict(self.environment)) + ) + object.__setattr__(self, "cwd", Path(self.cwd)) + + +@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)) + + +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.") + + +def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]: + validated: dict[str, str] = {} + 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." + ) + 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, +) -> DockerRunInvocation: + collisions = docker_control.keys() & application_payload.keys() + if collisions: + names = ", ".join(sorted(collisions)) + raise ContainerPolicyError( + f"Application payload collides with Docker control keys: {names}" + ) + control = _validated_environment(docker_control) + application = _validated_environment(application_payload) + 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, + ) + + +@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 _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, + ) + + +__all__ = [ + "ControlQueryInvocation", + "DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS", + "DockerRunInvocation", + "DockerImageConfig", + "DockerRuntimeError", + "LegacyRunnerAdapter", + "IMAGE_COMPATIBILITY_FORMAT", + "ProcessInvocation", + "ProcessResult", + "ProcessTransport", + "SubprocessTransport", + "build_docker_control_invocation", + "build_docker_query_invocation", + "build_docker_run_invocation", + "parse_buildx_version_result", + "parse_compose_version_result", + "parse_image_compatibility_result", + "require_buildx_available", +] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index bbeaf36..76f8f8b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -12,6 +12,12 @@ from pydantic import ValidationError from agentseek_api import __version__ +from agentseek_api.docker_runtime import ( + ControlQueryInvocation, + DockerRunInvocation, + ProcessInvocation, + ProcessResult, +) from agentseek_api.services.langgraph_service import LangGraphService @@ -42,14 +48,14 @@ def test_up_parser_collects_explicit_container_and_compose_names() -> None: assert args.compose_pass_env == ["TOKEN"] -def test_up_parses_pass_env_without_forwarding_it_to_live_docker_execution( +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 = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -61,13 +67,15 @@ def test_up_parses_pass_env_without_forwarding_it_to_live_docker_execution( "--pass-env", "ARBITRARY_HOST_SECRET", ], - runner=capture, + process_transport=capture, cwd=tmp_path, ) assert exit_code == 0 assert capture.calls is not None - assert "ARBITRARY_HOST_SECRET" not in _docker_env_from_run_command(capture.calls[1]) + 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( @@ -112,6 +120,24 @@ def __call__( return 0 +@dataclass +class _ProcessCapture: + calls: list[ProcessInvocation] | None = None + container_exists: bool = False + return_codes: dict[tuple[str, ...], int] | None = None + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if self.calls is None: + self.calls = [] + self.calls.append(invocation) + return_code = 0 + if invocation.argv[:3] == ("docker", "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) + + class _EncodingTextStream: def __init__(self, encoding: str) -> None: self.encoding = encoding @@ -397,14 +423,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: @@ -1555,7 +1577,7 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -1566,13 +1588,16 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( "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] == [ + assert capture.calls is not None + invocation = capture.calls[0] + assert type(invocation) is ProcessInvocation + assert "AGENTSEEK_GRAPHS" not in invocation.environment + assert invocation.argv[:8] == ( "docker", "build", "--platform", @@ -1581,8 +1606,8 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( "agentseek:test", "-f", str((tmp_path / ".agentseek" / "Dockerfile").resolve()), - ] - assert capture.command[-1] == "." + ) + assert invocation.argv[-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/*" @@ -1943,7 +1968,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( [ @@ -1958,14 +1983,19 @@ 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] == [ + assert capture.calls[0].argv == ( + "docker", + "rm", + "-f", + "agentseek-up-8123", + ) + assert capture.calls[1].argv[:9] == ( "docker", "run", "--detach", @@ -1975,22 +2005,103 @@ 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 capture.calls[1].argv[-1] == "agentseek:test" + container_env = _application_environment(capture) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.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) == 2 + remove, run = capture.calls + 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[0] + assert isinstance(probe, ControlQueryInvocation) + assert probe.timeout_seconds > 0 + assert probe.argv == ( + "docker", + "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[1].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( [ @@ -2003,14 +2114,19 @@ 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[0] == ["docker", "rm", "-f", "agentseek-up-8123"] - assert capture.calls[1] == [ + assert capture.calls[0].argv == ( + "docker", + "rm", + "-f", + "agentseek-up-8123", + ) + assert capture.calls[1].argv == ( "docker", "compose", "-f", @@ -2018,8 +2134,10 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: "up", "-d", "--force-recreate", - ] - assert capture.calls[2][-1] == "agentseek:test" + ) + assert capture.calls[2].argv[-1] == "agentseek:test" + for invocation in capture.calls[:2]: + assert "AGENTSEEK_GRAPHS" not in invocation.environment def test_up_command_rejects_missing_docker_compose_file(tmp_path: Path) -> None: @@ -2055,15 +2173,7 @@ def test_up_command_rejects_existing_container_before_starting_compose_sidecars( 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( [ @@ -2075,13 +2185,16 @@ def existing_container_runner( "--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", "container", "inspect", "agentseek-up-8123") + ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() @@ -2092,7 +2205,7 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2103,13 +2216,13 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "--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] == [ + assert capture.calls[0].argv == ( "docker", "build", "-t", @@ -2117,9 +2230,14 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-f", str((tmp_path / ".agentseek" / "Dockerfile").resolve()), ".", - ] - assert capture.calls[1] == ["docker", "container", "inspect", "agentseek-up-8124"] - assert capture.calls[2][:9] == [ + ) + assert capture.calls[1].argv == ( + "docker", + "container", + "inspect", + "agentseek-up-8124", + ) + assert capture.calls[2].argv[:9] == ( "docker", "run", "--detach", @@ -2129,9 +2247,14 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "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 capture.calls[2].argv[-1] == "agentseek-up:8124" + for invocation in capture.calls[:2]: + 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"] == "/deps/agent/langgraph.json" assert ( container_env["METADATA_DB_URL"] @@ -2167,7 +2290,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( """.strip(), encoding="utf-8", ) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2177,14 +2300,19 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "--image", "agentseek:test", ], - 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] == [ + assert capture.calls[0].argv == ( + "docker", + "container", + "inspect", + "agentseek-up-8123", + ) + assert capture.calls[1].argv[:9] == ( "docker", "run", "--detach", @@ -2194,9 +2322,9 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "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 capture.calls[1].argv[-1] == "agentseek:test" + container_env = _application_environment(capture) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/auth.py:backend" assert container_env["FEATURE_FLAG"] == "True" @@ -2209,7 +2337,7 @@ def test_up_command_passes_ambient_env_into_container( config_path = _write_basic_langgraph_config(tmp_path) monkeypatch.setenv("OPENAI_API_KEY", "ambient-key") - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2219,13 +2347,13 @@ def test_up_command_passes_ambient_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 container_env["OPENAI_API_KEY"] == "ambient-key" @@ -2245,7 +2373,7 @@ def test_up_command_prefers_agentseek_json_without_explicit_flag( encoding="utf-8", ) _write_basic_langgraph_config(tmp_path) - capture = _RunCapture() + capture = _ProcessCapture() exit_code = main( [ @@ -2253,13 +2381,13 @@ def test_up_command_prefers_agentseek_json_without_explicit_flag( "--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["AGENTSEEK_GRAPHS"] == "/deps/agent/agentseek.json" @@ -2272,7 +2400,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( [ @@ -2282,13 +2410,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 @@ -2297,7 +2425,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( [ @@ -2308,7 +2436,7 @@ 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, ) @@ -2348,29 +2476,27 @@ def test_up_command_returns_build_failure_without_running_container( 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", + "build", + "--pull", + "-t", + "agentseek-up:8125", + "-f", + str((tmp_path / ".agentseek" / "Dockerfile").resolve()), + ".", + ) + 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] == [build_argv] def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) -> None: @@ -2378,15 +2504,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( [ @@ -2396,53 +2514,43 @@ def existing_container_runner( "--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", "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={}, + process_transport=capture, + docker_control={}, cwd=tmp_path, ) assert exists is True - assert observed["command"] == [ + assert capture.calls is not None + invocation = capture.calls[0] + assert isinstance(invocation, ControlQueryInvocation) + assert invocation.timeout_seconds > 0 + assert invocation.argv == ( "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 + ) def test_up_command_waits_for_http_health_when_requested( @@ -2451,7 +2559,7 @@ def test_up_command_waits_for_http_health_when_requested( 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: @@ -2470,7 +2578,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_docker_runtime.py b/tests/unit/test_docker_runtime.py new file mode 100644 index 0000000..61d3b3a --- /dev/null +++ b/tests/unit/test_docker_runtime.py @@ -0,0 +1,348 @@ +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + +import pytest + +from agentseek_api.docker_runtime import ( + ControlQueryInvocation, + IMAGE_COMPATIBILITY_FORMAT, + DockerRuntimeError, + LegacyRunnerAdapter, + ProcessInvocation, + ProcessResult, + SubprocessTransport, + build_docker_control_invocation, + build_docker_query_invocation, + build_docker_run_invocation, + parse_buildx_version_result, + parse_compose_version_result, + parse_image_compatibility_result, + require_buildx_available, +) +from agentseek_api.environment import ContainerPolicyError + + +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", "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", + "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_non_run_docker_invocation_has_only_docker_control(tmp_path: Path) -> None: + invocation = build_docker_control_invocation( + argv=("docker", "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", "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", + "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", "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", "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", "compose", "version", "--short"), + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + timeout_seconds=2.0, + ) + control = build_docker_control_invocation( + argv=("docker", "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", "rm", "-f", "test"), docker_control={}, cwd=tmp_path + ) + query = build_docker_query_invocation( + argv=("docker", "image", "inspect", "test"), + docker_control={}, + cwd=tmp_path, + ) + stdin_invocation = ProcessInvocation( + argv=("docker", "build", "-"), + environment={}, + cwd=tmp_path, + stdin_bytes=b"archive", + ) + + assert adapter(control).returncode == 7 + assert calls == [(["docker", "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", + "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", + "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 From afadc90038aee7de0f44ceae27e8a7414224c2ee Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 17:36:35 +0800 Subject: [PATCH 05/42] fix: reject Windows carrier collisions --- src/agentseek_api/docker_runtime.py | 35 ++++++++- tests/unit/test_docker_runtime.py | 114 ++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+), 4 deletions(-) diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index e5fa253..91b59c8 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -6,6 +6,7 @@ import math import re import subprocess +import sys from collections.abc import Callable, Mapping from dataclasses import dataclass, field from pathlib import Path @@ -105,8 +106,14 @@ def _validate_argv(argv: tuple[str, ...]) -> None: raise ContainerPolicyError("Docker invocation argv contains NUL.") -def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]: +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( @@ -116,6 +123,13 @@ def _validated_environment(environment: Mapping[str, str]) -> dict[str, str]: 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 @@ -128,15 +142,28 @@ def build_docker_run_invocation( application_payload: Mapping[str, str], container_argv: tuple[str, ...], cwd: Path, + platform: str = sys.platform, ) -> DockerRunInvocation: - collisions = docker_control.keys() & application_payload.keys() + 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}" ) - control = _validated_environment(docker_control) - application = _validated_environment(application_payload) argv = [*base_argv] for name in sorted(application): argv.extend(("-e", name)) diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 61d3b3a..733cfbf 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -59,6 +59,120 @@ def test_docker_run_uses_names_in_argv_and_values_only_in_carrier( 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", "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", + "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", "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", "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", "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", "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", "image", "inspect", "agentseek:test"), From a273b9a403bce38781f4e89436b97ea7335da439 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 17:55:52 +0800 Subject: [PATCH 06/42] feat: isolate Compose substitution environment --- src/agentseek_api/cli.py | 70 ++- src/agentseek_api/docker_runtime.py | 102 ++++ src/agentseek_api/secure_temp.py | 712 ++++++++++++++++++++++++++++ tests/container_plan_helpers.py | 202 +++++++- tests/unit/test_cli.py | 162 ++++++- tests/unit/test_docker_runtime.py | 196 ++++++++ tests/unit/test_secure_temp.py | 176 +++++++ 7 files changed, 1600 insertions(+), 20 deletions(-) create mode 100644 src/agentseek_api/secure_temp.py create mode 100644 tests/unit/test_secure_temp.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 10f4018..a6917bf 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -21,6 +21,7 @@ ContainerSelection, docker_control_environment, select_application_payload, + select_compose_payload, ) from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.docker_runtime import ( @@ -28,9 +29,12 @@ LegacyRunnerAdapter, ProcessTransport, SubprocessTransport, + build_compose_invocation, build_docker_control_invocation, build_docker_query_invocation, build_docker_run_invocation, + encode_compose_environment, + require_supported_compose, ) from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file from agentseek_api.environment import ( @@ -46,6 +50,11 @@ ProcessSupervisionError, _ForwardedSignal, ) +from agentseek_api.secure_temp import ( + SecureArtifactError, + private_artifact, + sweep_expired_artifacts, +) DEFAULT_CLI_NAME = "agentseek-api" @@ -1238,6 +1247,32 @@ def _execute_up_command( application_payload = _resolve_application_container_payload( environment_plan, selection=selection, cwd=cwd ) + + compose_path: Path | None = None + compose_payload: dict[str, str] = {} + encoded_compose: bytes | None = None + if args.docker_compose: + compose_path = _resolve_path(args.docker_compose, cwd=cwd) + if not compose_path.exists(): + raise CliError(f"Docker compose file '{compose_path}' does not exist.") + compose_payload = dict( + select_compose_payload( + application_payload=application_payload, + selected_names=selection.compose_env, + docker_control=docker_control, + ) + ) + require_supported_compose( + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) + sweep_expired_artifacts( + prefix="agentseek-compose-", + older_than_seconds=24 * 60 * 60, + ) + encoded_compose = encode_compose_environment(compose_payload).encode("utf-8") + if not image: image = f"agentseek-up:{args.port}" generated_dockerfile = write_dockerfile( @@ -1257,12 +1292,6 @@ def _execute_up_command( if build_exit_code != 0: return build_exit_code - compose_path: Path | None = None - if args.docker_compose: - compose_path = _resolve_path(args.docker_compose, cwd=cwd) - 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: remove_invocation = build_docker_control_invocation( @@ -1282,13 +1311,21 @@ def _execute_up_command( ) if compose_path is not None: - compose_command = ["docker", "compose", "-f", str(compose_path), "up", "-d"] - if args.recreate: - compose_command.append("--force-recreate") - compose_invocation = build_docker_control_invocation( - argv=tuple(compose_command), docker_control=docker_control, cwd=cwd - ) - compose_exit_code = process_transport(compose_invocation).returncode + assert encoded_compose is not None + with private_artifact( + prefix="agentseek-compose-", + contents=encoded_compose, + ) as env_path: + compose_invocation = build_compose_invocation( + compose_file=compose_path, + env_file=env_path, + docker_control=docker_control, + application_payload=application_payload, + selected_names=selection.compose_env, + cwd=cwd, + recreate=args.recreate, + ) + compose_exit_code = process_transport(compose_invocation).returncode if compose_exit_code != 0: return compose_exit_code @@ -1472,7 +1509,12 @@ def run_namespace( args, process_transport=docker_transport, cwd=workdir ) raise CliError(f"Unsupported command '{command}'.") - except (CliError, ContainerPolicyError, DockerRuntimeError) as exc: + except ( + CliError, + ContainerPolicyError, + DockerRuntimeError, + SecureArtifactError, + ) as exc: err.write(f"{exc}\n") return 2 diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 91b59c8..1ae7e44 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -13,9 +13,11 @@ from types import MappingProxyType from typing import Protocol +from agentseek_api.container_policy import select_compose_payload from agentseek_api.environment import ContainerPolicyError DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS = 10.0 +MINIMUM_COMPOSE_VERSION = (2, 24, 0) IMAGE_COMPATIBILITY_FORMAT = ( "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]" ) @@ -25,6 +27,7 @@ _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): @@ -212,6 +215,100 @@ def build_docker_query_invocation( ) +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( + *, + 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", + "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( + *, + 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", "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: @@ -341,15 +438,20 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "DockerRuntimeError", "LegacyRunnerAdapter", "IMAGE_COMPATIBILITY_FORMAT", + "MINIMUM_COMPOSE_VERSION", "ProcessInvocation", "ProcessResult", "ProcessTransport", "SubprocessTransport", "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", "require_buildx_available", + "require_supported_compose", + "validate_environment_name", ] diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py new file mode 100644 index 0000000..13e91f1 --- /dev/null +++ b/src/agentseek_api/secure_temp.py @@ -0,0 +1,712 @@ +"""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 + + +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_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) + return opened + + +def _verify_closed_windows( # pragma: no cover - native Windows only + path: Path, expected: os.stat_result +) -> None: + try: + named = path.lstat() + except OSError as exc: + raise SecureArtifactError("Could not prove exclusive Windows access.") from exc + if not stat.S_ISREG(named.st_mode) or not _same_object(named, expected): + raise SecureArtifactError("Could not prove exclusive Windows access.") + _verify_private_dacl(path) + + +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( # pragma: no cover - native Windows only + root: Path, prefix: str +) -> tuple[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), + ] + + for _ in range(_MAX_CREATE_ATTEMPTS): + candidate = _candidate_path(root, prefix) + 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) + except SecureArtifactError: + _safe_remove_directory(candidate, expected) + raise + return candidate, expected + if ctypes.get_last_error() != 183: + raise SecureArtifactError("Could not create a private directory.") + 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 _verify_private_dacl( # pragma: no cover - native Windows only + path: Path, +) -> 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() + if 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) + ) + or not control.value & 0x1000 + ): + 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.") + seen_user = False + seen_system = False + if info.AceCount != 2: + raise _win32_error("Could not prove exclusive Windows access.") + 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 ace_type != 0 or ace_flags & 0x10 or mask & 0x1F01FF != 0x1F01FF: + raise _win32_error("Could not prove exclusive Windows access.") + if _sid_matches(ace_sid, user_sid): + seen_user = True + elif _sid_matches(ace_sid, system_sid): + seen_system = True + else: + raise _win32_error("Could not prove exclusive Windows access.") + if not seen_user or not seen_system: + raise _win32_error("Could not prove exclusive Windows access.") + finally: + kernel32.LocalFree(descriptor) + + +def _safe_unlink(path: Path, expected: os.stat_result | None = None) -> None: + try: + metadata = path.lstat() + except FileNotFoundError: + return + except OSError: + return + if _is_link_or_junction(path, metadata): + return + if expected is not None and not _same_object(metadata, expected): + return + with contextlib.suppress(OSError): + path.unlink() + + +def _safe_remove_directory(path: Path, expected: os.stat_result | None) -> None: + try: + metadata = path.lstat() + except OSError: + return + if _is_link_or_junction(path, metadata): + return + if expected is not None and not _same_object(metadata, expected): + return + with contextlib.suppress(OSError): + shutil.rmtree(path) + + +@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 + 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: + _safe_remove_directory(private_parent, private_parent_expected) + raise SecureArtifactError("Could not create a private artifact.") from exc + path = candidate + break + if path is None or fd is None: + if private_parent is not None: + _safe_remove_directory(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) + expected = _verify_open_windows(fd, path) + else: + os.fchmod(fd, _PRIVATE_FILE_MODE) + expected = _verify_open_posix(fd, path) + except (OSError, SecureArtifactError) as exc: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + fd = None + _safe_unlink(path, expected) + if private_parent is not None: + _safe_remove_directory(private_parent, private_parent_expected) + if isinstance(exc, SecureArtifactError): + raise + raise SecureArtifactError("Could not prepare a private artifact.") from exc + + try: + try: + os.close(fd) + except OSError as exc: + raise SecureArtifactError("Could not prepare a private artifact.") from exc + fd = None + if os.name == "nt": # pragma: no cover - native Windows only + assert expected is not None + _verify_closed_windows(path, expected) + else: + assert expected is not None + _verify_closed_posix(path, expected) + yield path + finally: + if fd is not None: + with contextlib.suppress(OSError): + os.close(fd) + _safe_unlink(path, expected) + if private_parent is not None: + _safe_remove_directory(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 + 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 + break + if path is None: + raise SecureArtifactError("Could not create a private directory.") + try: + if os.name == "nt": # pragma: no cover - native Windows only + _verify_private_dacl(path) + assert expected is not None + else: + path.chmod(_PRIVATE_DIRECTORY_MODE) + expected = _verify_directory_posix(path) + except OSError as exc: + _safe_remove_directory(path, expected) + raise SecureArtifactError("Could not prepare a private directory.") from exc + try: + yield path + finally: + _safe_remove_directory(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 ( # pragma: no cover - native Windows only + os.name == "nt" and stat.S_ISDIR(metadata.st_mode) + ): + _verify_private_dacl(candidate) + descendants = tuple(candidate.rglob("*")) + if any(descendant.is_symlink() for descendant in descendants): + continue + for descendant in descendants: + if descendant.is_dir(): + _verify_private_dacl(descendant) + elif descendant.is_file(): + _verify_private_dacl(descendant) + else: + raise SecureArtifactError( + "Could not prove exclusive Windows access." + ) + _safe_remove_directory(candidate, metadata) + if candidate.exists(): + continue + elif not stat.S_ISREG(metadata.st_mode): + continue + elif os.name == "nt": # pragma: no cover - native Windows only + _verify_private_dacl(candidate) + candidate.unlink() + 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) + ): + continue + else: + candidate.unlink() + except (OSError, SecureArtifactError): + continue + removed.append(candidate) + return tuple(sorted(removed)) + + +__all__ = [ + "SecureArtifactError", + "private_artifact", + "private_directory", + "sweep_expired_artifacts", +] diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index 5bbfe05..43a114d 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -1,12 +1,212 @@ from __future__ import annotations -from collections.abc import Mapping +import json +import os +import re +import subprocess +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 +@dataclass(frozen=True) +class ComposeDecodedEnvironment(Mapping[str, str]): + substitution: Mapping[str, str] = field(repr=False) + rendered: Mapping[str, str] = field(repr=False) + runtime: Mapping[str, str] = field(default_factory=dict, 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 _parse_compose_environment(output: bytes) -> dict[str, str]: + text = output.decode("utf-8", errors="strict") + matches = list( + re.finditer( + r"(?ms)^([A-Za-z_][A-Za-z0-9_]*)=(.*?)(?=^[A-Za-z_][A-Za-z0-9_]*=|\Z)", + text, + ) + ) + return {match.group(1): match.group(2).removesuffix("\n") for match in matches} + + +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) + 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), + ] + substitution = _parse_compose_environment( + _run_private([*base, "config", "--environment"], cwd=tmp_path) + ) + rendered_document = json.loads( + _run_private([*base, "config", "--format", "json"], cwd=tmp_path) + ) + rendered: dict[str, 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] + + 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), + runtime=MappingProxyType(runtime), + ) + + def resolved_fixture( *, values: Mapping[str, str], diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 76f8f8b..fbbd250 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -130,6 +130,8 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: if self.calls is None: self.calls = [] self.calls.append(invocation) + if invocation.argv == ("docker", "compose", "version", "--short"): + return ProcessResult(returncode=0, stdout=b"2.40.3\n") return_code = 0 if invocation.argv[:3] == ("docker", "container", "inspect"): return_code = 0 if self.container_exists else 1 @@ -2121,23 +2123,172 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: assert exit_code == 0 assert capture.calls is not None assert capture.calls[0].argv == ( + "docker", + "compose", + "version", + "--short", + ) + assert capture.calls[1].argv == ( "docker", "rm", "-f", "agentseek-up-8123", ) - assert capture.calls[1].argv == ( + compose_invocation = capture.calls[2] + assert compose_invocation.argv[:3] == ( "docker", "compose", + "--env-file", + ) + assert compose_invocation.argv[4:] == ( "-f", str(compose_path.resolve()), "up", "-d", "--force-recreate", ) - assert capture.calls[2].argv[-1] == "agentseek:test" - for invocation in capture.calls[:2]: - assert "AGENTSEEK_GRAPHS" not in invocation.environment + assert capture.calls[3].argv[-1] == "agentseek:test" + 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", "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 + compose = next( + call + for call in capture.calls + if call.argv[:3] == ("docker", "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", "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: @@ -2193,7 +2344,8 @@ def test_up_command_rejects_existing_container_before_starting_compose_sidecars( assert exit_code == 2 assert capture.calls is not None assert [call.argv for call in capture.calls] == [ - ("docker", "container", "inspect", "agentseek-up-8123") + ("docker", "compose", "version", "--short"), + ("docker", "container", "inspect", "agentseek-up-8123"), ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 733cfbf..2a0b739 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -7,6 +7,7 @@ import pytest from agentseek_api.docker_runtime import ( + MINIMUM_COMPOSE_VERSION, ControlQueryInvocation, IMAGE_COMPATIBILITY_FORMAT, DockerRuntimeError, @@ -15,14 +16,209 @@ ProcessResult, SubprocessTransport, 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, require_buildx_available, + require_supported_compose, ) from agentseek_api.environment import ContainerPolicyError +from tests.container_plan_helpers import ( + decode_with_supported_compose, + docker_compose_available, + docker_daemon_available, +) + + +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.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("$", "$$") + + +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 + + +@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( + 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", + "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( + 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( + 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", "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( + 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( + transport=transport, + docker_control={}, + cwd=tmp_path, + ) def test_docker_run_uses_names_in_argv_and_values_only_in_carrier( diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py new file mode 100644 index 0000000..8ef20fd --- /dev/null +++ b/tests/unit/test_secure_temp.py @@ -0,0 +1,176 @@ +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_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") + + assert list(tmp_path.iterdir()) == [] + + +@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) + 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(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 symlink.is_symlink() + + +@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) + secure_temp._verify_private_dacl(path.parent) From e3f58746a8cfb76f0866abb6e00ccac2b35dc5e7 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 18:14:34 +0800 Subject: [PATCH 07/42] fix: harden secure artifact cleanup --- pyproject.toml | 1 + src/agentseek_api/secure_temp.py | 330 ++++++++++++++++++++++-------- tests/container_plan_helpers.py | 12 +- tests/unit/test_docker_runtime.py | 19 ++ tests/unit/test_secure_temp.py | 187 ++++++++++++++++- 5 files changed, 459 insertions(+), 90 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 236f1a9..1e08161 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -60,5 +60,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/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 13e91f1..102afec 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -116,6 +116,32 @@ def _verify_directory_posix(path: Path) -> os.stat_result: 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: @@ -130,20 +156,23 @@ def _verify_open_windows( # pragma: no cover - native Windows only or not _same_object(opened, named) ): raise SecureArtifactError("Could not prove exclusive Windows access.") - _verify_private_dacl(path) + _verify_private_dacl(path, directory=False) return opened def _verify_closed_windows( # pragma: no cover - native Windows only - path: Path, expected: os.stat_result + 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 - if not stat.S_ISREG(named.st_mode) or not _same_object(named, expected): + 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) + _verify_private_dacl(path, directory=directory) def _win32_libraries( # pragma: no cover - native Windows only @@ -373,9 +402,9 @@ class SecurityAttributes(ctypes.Structure): if kernel32.CreateDirectoryW(str(candidate), ctypes.byref(attributes)): expected = candidate.lstat() try: - _verify_private_dacl(candidate) + _verify_private_dacl(candidate, directory=True) except SecureArtifactError: - _safe_remove_directory(candidate, expected) + _quarantine_then_rmtree(candidate, expected) raise return candidate, expected if ctypes.get_last_error() != 183: @@ -393,6 +422,8 @@ def _sid_matches( # pragma: no cover - native Windows only def _verify_private_dacl( # pragma: no cover - native Windows only path: Path, + *, + directory: bool, ) -> None: """Read owner and effective ACEs back without localized command output.""" @@ -426,11 +457,14 @@ def _verify_private_dacl( # pragma: no cover - native Windows only control = wintypes.WORD() revision = wintypes.DWORD() + required_control = 0x0004 | 0x1000 + forbidden_control = 0x0008 | 0x0100 | 0x0400 if ( not advapi32.GetSecurityDescriptorControl( descriptor, ctypes.byref(control), ctypes.byref(revision) ) - or not control.value & 0x1000 + or control.value & required_control != required_control + or control.value & forbidden_control ): raise _win32_error("Could not prove exclusive Windows access.") @@ -450,6 +484,8 @@ class AclSizeInformation(ctypes.Structure): seen_system = False if info.AceCount != 2: raise _win32_error("Could not prove exclusive Windows access.") + expected_flags = 0x03 if directory else 0x00 + full_control = 0x001F01FF for index in range(info.AceCount): ace = ctypes.c_void_p() if not advapi32.GetAce(dacl, index, ctypes.byref(ace)) or not ace.value: @@ -459,7 +495,7 @@ class AclSizeInformation(ctypes.Structure): ace_flags = header[1] mask = int.from_bytes(header[4:8], byteorder="little") ace_sid = ctypes.c_void_p(ace.value + 8) - if ace_type != 0 or ace_flags & 0x10 or mask & 0x1F01FF != 0x1F01FF: + if ace_type != 0 or ace_flags != expected_flags or mask != full_control: raise _win32_error("Could not prove exclusive Windows access.") if _sid_matches(ace_sid, user_sid): seen_user = True @@ -473,32 +509,131 @@ class AclSizeInformation(ctypes.Structure): kernel32.LocalFree(descriptor) -def _safe_unlink(path: Path, expected: os.stat_result | None = None) -> None: +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 FileNotFoundError: - return except OSError: - return - if _is_link_or_junction(path, metadata): - return - if expected is not None and not _same_object(metadata, expected): - return - with contextlib.suppress(OSError): - path.unlink() + 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 _safe_remove_directory(path: Path, expected: os.stat_result | None) -> None: +def _quarantined_directory_matches(path: Path, expected: os.stat_result) -> bool: try: metadata = path.lstat() except OSError: - return - if _is_link_or_junction(path, metadata): - return - if expected is not None and not _same_object(metadata, expected): - return - with contextlib.suppress(OSError): - shutil.rmtree(path) + 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_private_dacl(path, directory=True) + pending.append(path) + elif stat.S_ISREG(metadata.st_mode): + _verify_private_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 @@ -536,14 +671,23 @@ def private_artifact( except FileExistsError: continue except OSError as exc: - if private_parent is not None: - _safe_remove_directory(private_parent, private_parent_expected) + 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: - if private_parent is not None: - _safe_remove_directory(private_parent, private_parent_expected) + 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: @@ -556,42 +700,47 @@ def private_artifact( os.fsync(fd) if os.name == "nt": # pragma: no cover - native Windows only _apply_private_dacl(path) - expected = _verify_open_windows(fd, path) + verified = _verify_open_windows(fd, path) else: os.fchmod(fd, _PRIVATE_FILE_MODE) - expected = _verify_open_posix(fd, path) + 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 - _safe_unlink(path, expected) - if private_parent is not None: - _safe_remove_directory(private_parent, private_parent_expected) + 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: - try: - os.close(fd) - except OSError as exc: - raise SecureArtifactError("Could not prepare a private artifact.") from exc - fd = None - if os.name == "nt": # pragma: no cover - native Windows only - assert expected is not None - _verify_closed_windows(path, expected) - else: - assert expected is not None - _verify_closed_posix(path, expected) yield path finally: if fd is not None: with contextlib.suppress(OSError): os.close(fd) - _safe_unlink(path, expected) - if private_parent is not None: - _safe_remove_directory(private_parent, private_parent_expected) + 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 @@ -602,6 +751,7 @@ def private_directory(*, prefix: str, tmp_root: Path | None = None) -> Iterator[ _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: @@ -616,23 +766,43 @@ def private_directory(*, prefix: str, tmp_root: Path | None = None) -> Iterator[ "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: + 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_private_dacl(path) - assert expected is not None + _verify_closed_windows(path, expected, directory=True) else: - path.chmod(_PRIVATE_DIRECTORY_MODE) - expected = _verify_directory_posix(path) - except OSError as exc: - _safe_remove_directory(path, expected) + 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: - _safe_remove_directory(path, expected) + _quarantine_then_rmtree(path, expected) def sweep_expired_artifacts( @@ -666,41 +836,31 @@ def sweep_expired_artifacts( or candidate.resolve(strict=True).parent != root.resolve(strict=True) ): continue + if os.name != "nt" and ( + metadata.st_uid != _current_uid() + or ( + stat.S_ISREG(metadata.st_mode) + and ( + stat.S_IMODE(metadata.st_mode) != _PRIVATE_FILE_MODE + or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) + ) + ) + ): + continue if ( # pragma: no cover - native Windows only os.name == "nt" and stat.S_ISDIR(metadata.st_mode) ): - _verify_private_dacl(candidate) - descendants = tuple(candidate.rglob("*")) - if any(descendant.is_symlink() for descendant in descendants): - continue - for descendant in descendants: - if descendant.is_dir(): - _verify_private_dacl(descendant) - elif descendant.is_file(): - _verify_private_dacl(descendant) - else: - raise SecureArtifactError( - "Could not prove exclusive Windows access." - ) - _safe_remove_directory(candidate, metadata) - if candidate.exists(): - continue + was_removed = _quarantine_then_rmtree( + candidate, metadata, verify_windows_tree=True + ) elif not stat.S_ISREG(metadata.st_mode): continue - elif os.name == "nt": # pragma: no cover - native Windows only - _verify_private_dacl(candidate) - candidate.unlink() - 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) - ): - continue else: - candidate.unlink() + was_removed = _quarantine_then_unlink(candidate, metadata) except (OSError, SecureArtifactError): continue - removed.append(candidate) + if was_removed: + removed.append(candidate) return tuple(sorted(removed)) diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index 43a114d..165f943 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -17,7 +17,12 @@ class ComposeDecodedEnvironment(Mapping[str, str]): substitution: Mapping[str, str] = field(repr=False) rendered: Mapping[str, str] = field(repr=False) - runtime: Mapping[str, str] = field(default_factory=dict, 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] @@ -145,7 +150,7 @@ def decode_with_supported_compose( "command": [ "sh", "-c", - f"umask 077; printf '%s' \"${{{name}}}\" > /result/{name}", + f"umask 077; printf '%s' \"$${{{name}}}\" > /result/{name}", ], "volumes": [f"{result_path}:/result"], } @@ -170,6 +175,7 @@ def decode_with_supported_compose( _run_private([*base, "config", "--format", "json"], cwd=tmp_path) ) rendered: dict[str, str] = {} + commands: dict[str, tuple[str, ...]] = {} for name in names: service = rendered_document["services"][ f"probe-{name.lower().replace('_', '-')}" @@ -177,6 +183,7 @@ def decode_with_supported_compose( if service["environment"]["PROJECT_DOTENV_CANARY"] != "unset": raise RuntimeError("Compose loaded the project dotenv unexpectedly.") rendered[name] = service["environment"][name] + commands[name] = tuple(service["command"]) runtime: dict[str, str] = {} if run_service: @@ -203,6 +210,7 @@ def decode_with_supported_compose( return ComposeDecodedEnvironment( substitution=MappingProxyType(substitution), rendered=MappingProxyType(rendered), + commands=MappingProxyType(commands), runtime=MappingProxyType(runtime), ) diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 2a0b739..fa1d770 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -62,6 +62,7 @@ def test_compose_encoder_uses_one_literal_double_quoted_codec() -> None: 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 @@ -76,6 +77,24 @@ def test_compose_encoder_round_trips_config_oracle( 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: diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index 8ef20fd..e0cc3b0 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -68,6 +68,142 @@ def reject_mode(_fd: int, _path: Path) -> os.stat_result: 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 @@ -82,7 +218,10 @@ def test_private_artifact_fails_closed_when_owner_cannot_be_proved( ): pytest.fail("an unverified path must never be exposed") - assert list(tmp_path.iterdir()) == [] + 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 @@ -163,6 +302,48 @@ def test_sweep_removes_only_owned_private_old_regular_artifacts( 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() + ) + + @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, @@ -172,5 +353,5 @@ def test_windows_private_artifact_dacl_round_trips_by_security_api( prefix="agentseek-compose-", contents=b"private", ) as path: - secure_temp._verify_private_dacl(path) - secure_temp._verify_private_dacl(path.parent) + secure_temp._verify_private_dacl(path, directory=False) + secure_temp._verify_private_dacl(path.parent, directory=True) From 8707a540cff66b51295e7a4d6bffa62723aeb55a Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 18:24:43 +0800 Subject: [PATCH 08/42] fix: accept safe inherited Windows ACLs --- src/agentseek_api/secure_temp.py | 143 +++++++++++++++++++++----- tests/unit/test_secure_temp.py | 170 +++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+), 24 deletions(-) diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 102afec..11d6733 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -22,6 +22,17 @@ class SecureArtifactError(RuntimeError): _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: @@ -420,10 +431,80 @@ def _sid_matches( # pragma: no cover - native Windows only return bool(advapi32.EqualSid(left, right)) -def _verify_private_dacl( # pragma: no cover - native Windows only +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.""" @@ -457,14 +538,8 @@ def _verify_private_dacl( # pragma: no cover - native Windows only control = wintypes.WORD() revision = wintypes.DWORD() - required_control = 0x0004 | 0x1000 - forbidden_control = 0x0008 | 0x0100 | 0x0400 - if ( - not advapi32.GetSecurityDescriptorControl( - descriptor, ctypes.byref(control), ctypes.byref(revision) - ) - or control.value & required_control != required_control - or control.value & forbidden_control + if not advapi32.GetSecurityDescriptorControl( + descriptor, ctypes.byref(control), ctypes.byref(revision) ): raise _win32_error("Could not prove exclusive Windows access.") @@ -480,12 +555,7 @@ class AclSizeInformation(ctypes.Structure): dacl, ctypes.byref(info), ctypes.sizeof(info), 2 ): raise _win32_error("Could not prove exclusive Windows access.") - seen_user = False - seen_system = False - if info.AceCount != 2: - raise _win32_error("Could not prove exclusive Windows access.") - expected_flags = 0x03 if directory else 0x00 - full_control = 0x001F01FF + 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: @@ -495,20 +565,45 @@ class AclSizeInformation(ctypes.Structure): ace_flags = header[1] mask = int.from_bytes(header[4:8], byteorder="little") ace_sid = ctypes.c_void_p(ace.value + 8) - if ace_type != 0 or ace_flags != expected_flags or mask != full_control: - raise _win32_error("Could not prove exclusive Windows access.") if _sid_matches(ace_sid, user_sid): - seen_user = True + principal = "user" elif _sid_matches(ace_sid, system_sid): - seen_system = True + principal = "system" else: - raise _win32_error("Could not prove exclusive Windows access.") - if not seen_user or not seen_system: - raise _win32_error("Could not prove exclusive Windows access.") + 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.""" @@ -596,10 +691,10 @@ def _verify_windows_private_tree( # pragma: no cover - native Windows only return False try: if stat.S_ISDIR(metadata.st_mode): - _verify_private_dacl(path, directory=True) + _verify_descendant_dacl(path, directory=True) pending.append(path) elif stat.S_ISREG(metadata.st_mode): - _verify_private_dacl(path, directory=False) + _verify_descendant_dacl(path, directory=False) else: return False except SecureArtifactError: diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index e0cc3b0..a6d1d0c 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -344,6 +344,149 @@ def substitute_then_move(path: Path) -> Path: ) +@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, @@ -355,3 +498,30 @@ def test_windows_private_artifact_dacl_round_trips_by_security_api( ) 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)) + + 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) From 5d16486afadb38bc9decbcb946c1dc3bfbb79da6 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 18:28:13 +0800 Subject: [PATCH 09/42] chore: prepare agentseek-api 0.3.0 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ pyproject.toml | 2 +- src/agentseek_api/__init__.py | 2 +- tests/unit/test_cli.py | 1 + uv.lock | 2 +- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31808b7..c9b1e4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ 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. + ## 0.2.3 - 2026-08-17 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 1e08161..7e7bbf6 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" 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/tests/unit/test_cli.py b/tests/unit/test_cli.py index fbbd250..e3390c7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1197,6 +1197,7 @@ def test_release_versions_are_consistent() -> None: and package.get("source") == {"editable": "."} ) + assert __version__ == "0.3.0" assert project_config["version"] == __version__ assert root_package["version"] == __version__ diff --git a/uv.lock b/uv.lock index d9448c4..cc075a3 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" }, From c25c67da236266999443a899b96c08ba0fc49490 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 18:47:01 +0800 Subject: [PATCH 10/42] feat: materialize sanitized container build bundles --- src/agentseek_api/cli.py | 60 +- src/agentseek_api/container_build.py | 1846 ++++++++++++++++++++++++++ tests/container_plan_helpers.py | 13 + tests/unit/test_cli.py | 68 + tests/unit/test_container_build.py | 836 ++++++++++++ 5 files changed, 2817 insertions(+), 6 deletions(-) create mode 100644 src/agentseek_api/container_build.py create mode 100644 tests/unit/test_container_build.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index a6917bf..8f5c81d 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -23,6 +23,12 @@ select_application_payload, select_compose_payload, ) +from agentseek_api.container_build import ( + PUBLISHED_RUNTIME_ARTIFACT, + ContainerBuildError, + RuntimeArtifactV1, + plan_container_image, +) from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.docker_runtime import ( DockerRuntimeError, @@ -1142,27 +1148,45 @@ def write_dockerfile( def _execute_dockerfile_command( - args: argparse.Namespace, *, stdout: TextIO, cwd: Path + 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." ) + _load_cli_config(config_path) save_path = _resolve_path(args.save_path, cwd=cwd) + plan_container_image( + config_path=config_path, + runtime_artifact=runtime_artifact, + ) write_dockerfile(config_path=config_path, save_path=save_path, cwd=cwd) stdout.write(f"{save_path}\n") return 0 def _execute_build_command( - args: argparse.Namespace, *, process_transport: ProcessTransport, cwd: Path + 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." ) + _load_cli_config(config_path) + plan_container_image( + config_path=config_path, + runtime_artifact=runtime_artifact, + ) generated_dockerfile = write_dockerfile( config_path=config_path, save_path=(cwd / ".agentseek" / "Dockerfile").resolve(), @@ -1222,7 +1246,11 @@ def _container_exists( def _execute_up_command( - args: argparse.Namespace, *, process_transport: ProcessTransport, cwd: Path + 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: @@ -1274,6 +1302,11 @@ def _execute_up_command( encoded_compose = encode_compose_environment(compose_payload).encode("utf-8") if not image: + plan_container_image( + config_path=config_path, + base_image_override=args.base_image, + runtime_artifact=runtime_artifact, + ) image = f"agentseek-up:{args.port}" generated_dockerfile = write_dockerfile( config_path=config_path, @@ -1453,6 +1486,7 @@ def run_namespace( 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() @@ -1489,10 +1523,18 @@ 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, process_transport=docker_transport, cwd=workdir + args, + process_transport=docker_transport, + cwd=workdir, + runtime_artifact=runtime_artifact, ) if command == "up": _reject_unsupported_options( @@ -1506,7 +1548,10 @@ def run_namespace( ), ) return _execute_up_command( - args, process_transport=docker_transport, cwd=workdir + args, + process_transport=docker_transport, + cwd=workdir, + runtime_artifact=runtime_artifact, ) raise CliError(f"Unsupported command '{command}'.") except ( @@ -1514,6 +1559,7 @@ def run_namespace( ContainerPolicyError, DockerRuntimeError, SecureArtifactError, + ContainerBuildError, ) as exc: err.write(f"{exc}\n") return 2 @@ -1528,6 +1574,7 @@ def main( 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 @@ -1540,6 +1587,7 @@ def main( stdout=stdout, stderr=stderr, cwd=cwd, + runtime_artifact=runtime_artifact, ) diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py new file mode 100644 index 0000000..128f433 --- /dev/null +++ b/src/agentseek_api/container_build.py @@ -0,0 +1,1846 @@ +"""Sanitized, deterministic build planning for the preloaded-v1 container contract.""" + +from __future__ import annotations + +import hashlib +import io +import json +import os +import re +import shutil +import stat +import tarfile +import tomllib +import zipfile +from collections.abc import Mapping, Sequence +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 urlsplit + +from packaging.requirements import InvalidRequirement, Requirement +from packaging.version import Version + +from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.environment import EnvironmentOrigin + +JsonScalar: TypeAlias = None | bool | int | float | str +JsonValue: TypeAlias = JsonScalar | tuple["JsonValue", ...] | Mapping[str, "JsonValue"] + +_RUNTIME_VERSION = "0.3.0" +_CONTAINER_ROOT = PurePosixPath("/deps/agent") + + +class ContainerBuildError(ValueError): + """A value-free failure to produce a safe container build plan.""" + + +def _freeze_json(value: object, *, location: str) -> JsonValue: + 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 + ) + + +@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] + + 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=(",", ":"), + ).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) + project_root: Path = field(repr=False) + excluded_paths: frozenset[Path] = field(default_factory=frozenset, repr=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, ...] + + def archive_bytes(self) -> bytes: + return create_deterministic_context_archive( + context=self.context, expected_inventory=self.inventory + ) + + +@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: + raw = path.absolute() + status = raw.lstat() + 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.") + resolved = raw.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 ".git" in 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_status = path.absolute().lstat() + 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 ".git" in resolved.relative_to(project_root).parts: + raise ContainerBuildError( + f"The {purpose} source selects excluded VCS metadata." + ) + return resolved + + +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, +) -> None: + if destination.startswith("/") or ".." in PurePosixPath(destination).parts: + raise ContainerBuildError("A selected destination escaped the build context.") + existing = selected.get(destination) + if existing is None: + selected[destination] = SelectedSource(source, frozenset({reason})) + return + if existing.source_path != source: + raise ContainerBuildError( + "Two different sources selected the same destination." + ) + selected[destination] = SelectedSource( + source, existing.reasons | frozenset({reason}) + ) + + +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 ".git" in 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) + 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 ".git" in 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.") + return value + + +def _parse_graphs( + raw: object, + *, + reference_base: Path, + project_root: Path, + selected: dict[str, SelectedSource], + excluded: frozenset[Path], +) -> 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: + 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], +) -> 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): + 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], +) -> 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 _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." + ) + allowed = { + "type", + "description", + "name", + "in", + "scheme", + "bearerFormat", + "flows", + "openIdConnectUrl", + } + _validate_allowed(raw, allowed, f"auth.openapi.securitySchemes.{name}") + 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"auth.openapi.securitySchemes.{name}.flows" + ) + continue + if not isinstance(value, str): + raise ContainerBuildError( + f"auth.openapi.securitySchemes.{name}.{key} must be a string." + ) + if key.endswith("Url"): + _reject_credential_url(value) + return _freeze_json(raw, location=f"auth.openapi.securitySchemes.{name}") # 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." + ) + + +def _validate_oauth_flows(value: object, *, location: str) -> None: + if not isinstance(value, dict): + 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}") + 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 _dependency_is_local(value: str) -> bool: + if value.startswith(("https://", "http://")) or " @ https://" in value: + return False + return value == "." or value.startswith(".") or "/" in value or "\\" in value + + +def _validate_requirement_url(value: str) -> None: + candidate = value.split(" @ ", 1)[-1].strip() + if candidate.startswith(("http://", "https://")): + parsed = urlsplit(candidate) + if ( + parsed.scheme != "https" + or parsed.username is not None + or parsed.password is not None + ): + raise ContainerBuildError( + "Dependency URLs must use HTTPS without embedded credentials; use pip_config_file." + ) + if parsed.fragment.startswith("subdirectory="): + raise ContainerBuildError("Dependency URL fragments are not supported.") + + +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, +) -> ContainerBuildPlan: + config = Path(config_path).absolute() + project_root = _discover_project_root(config) + 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 = project_root / 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 + if raw_pip is not None: + if not isinstance(raw_pip, str): + raise ContainerBuildError("pip_config_file must be a string.") + pip_candidate = Path(raw_pip).expanduser() + if not pip_candidate.is_absolute(): + pip_candidate = reference_base / pip_candidate + pip_path = _safe_regular( + pip_candidate, project_root=project_root, purpose="pip config" + ) + 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 = project_root + 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, + ) + + 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.") + base = base_image_override or raw_base or f"python:{raw_python}-slim" + 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, + ) + 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, + project_root=project_root, + excluded_paths=excluded, + ) + + +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) + 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 + ) + located = _module_file(selection.value, base=plan.project_root) + 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 _digest(path: Path) -> tuple[str, int]: + data = path.read_bytes() + return hashlib.sha256(data).hexdigest(), len(data) + + +def _write_file(path: Path, data: bytes) -> None: + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + fd = os.open(path, flags, 0o600) + try: + os.write(fd, data) + os.fsync(fd) + finally: + os.close(fd) + + +def _read_regular_source( + path: Path, *, expected_identity: tuple[int, int, int, int] | None = None +) -> 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 = os.O_RDONLY + 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( + "The candidate wheel identity changed before materialization." + ) + 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: + root = Path(output_root).absolute() + try: + root_status = 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(root.iterdir()) + ): + raise ContainerBuildError( + "The build output root must be a new empty directory." + ) + else: + try: + root.mkdir(parents=True, mode=0o700) + except OSError as exc: + raise ContainerBuildError( + "The build output root could not be created." + ) from exc + _verify_private_output_root(root) + context = root / "context" + context.mkdir(mode=0o700) + created: list[Path] = [] + try: + for destination, selected in sorted(plan.selected_sources.items()): + source = selected.source_path + candidate_identity = ( + plan.runtime_artifact.candidate_identity + if selected.reasons == frozenset({SourceReason.RUNTIME_ARTIFACT}) + else None + ) + data = _read_regular_source(source, expected_identity=candidate_identity) + 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_file(target, data) + created.append(target) + manifest = context / "manifest.v1.json" + _write_file(manifest, plan.manifest.to_json_bytes()) + constraints = context / "runtime-constraints.txt" + _write_file(constraints, b"agentseek-api==0.3.0\n") + dockerfile = context / "Dockerfile" + _write_file(dockerfile, bytes(dockerfile_bytes)) + inventory = tuple( + BuildInventoryEntry( + relative_path=path.relative_to(context).as_posix(), + sha256=_digest(path)[0], + size=_digest(path)[1], + ) + for path in sorted(created + [manifest, constraints, dockerfile]) + ) + inventory_path = root / "inventory.json" + _write_file( + 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, + ) + except Exception: + shutil.rmtree(context, ignore_errors=True) + inventory_path = root / "inventory.json" + if inventory_path.exists() and not inventory_path.is_symlink(): + try: + inventory_path.unlink() + except OSError: + pass + if root_was_created: + try: + root.rmdir() + except OSError: + pass + raise + + +def _verify_private_output_root(root: Path) -> None: + try: + status = root.lstat() + except OSError as exc: + raise ContainerBuildError( + "The build output root could not be verified private." + ) from exc + if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode): + raise ContainerBuildError("The build output root is not a private directory.") + if os.name != "nt": + if status.st_uid != os.getuid() or stat.S_IMODE(status.st_mode) != 0o700: + raise ContainerBuildError("The build output root is not user-private.") + + +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", + "create_deterministic_context_archive", + "interpret_host_runtime_policy", + "interpret_manifest_runtime_policy", + "materialize_build_bundle", + "plan_container_image", + "plan_generated_up_auth", +] diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index 165f943..fb9d5ea 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -13,6 +13,19 @@ from agentseek_api.environment import ResolvedEnvironment +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 + + @dataclass(frozen=True) class ComposeDecodedEnvironment(Mapping[str, str]): substitution: Mapping[str, str] = field(repr=False) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index e3390c7..ff62f6b 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1202,6 +1202,71 @@ def test_release_versions_are_consistent() -> None: assert root_package["version"] == __version__ +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 + + 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) + + 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" @@ -1547,6 +1612,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( """ diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py new file mode 100644 index 0000000..0d0b151 --- /dev/null +++ b/tests/unit/test_container_build.py @@ -0,0 +1,836 @@ +from __future__ import annotations + +import hashlib +import io +import json +import os +import tarfile +import zipfile +from pathlib import Path + +import pytest + +import agentseek_api.container_build as container_build +from agentseek_api.container_build import ( + PUBLISHED_RUNTIME_ARTIFACT, + AuthPayloadPatch, + FinalAuthSelection, + InstallActionKind, + RuntimeArtifactSource, + RuntimeArtifactV1, + SourceReason, + candidate_runtime_artifact, + interpret_host_runtime_policy, + interpret_manifest_runtime_policy, + materialize_build_bundle, + plan_container_image, + plan_generated_up_auth, +) +from agentseek_api.environment import EnvironmentOrigin +from tests.container_plan_helpers import make_graph_project + + +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") + + 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 + + assert interpret_host_runtime_policy( + host_payload, config_path=config + ) == interpret_manifest_runtime_policy(manifest) + + +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() From e4d3a03a565d3eb2fadaaf9d7e30862f23a75a27 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 19:18:58 +0800 Subject: [PATCH 11/42] fix: enforce sanitized container CLI handoff --- src/agentseek_api/cli.py | 149 +++++-- src/agentseek_api/container_build.py | 407 ++++++++++++++++--- src/agentseek_api/secure_temp.py | 106 ++++- tests/unit/test_cli.py | 254 ++++++++++-- tests/unit/test_container_build.py | 577 +++++++++++++++++++++++++++ tests/unit/test_secure_temp.py | 56 +++ 6 files changed, 1401 insertions(+), 148 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 8f5c81d..914b15e 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -26,8 +26,11 @@ from agentseek_api.container_build import ( PUBLISHED_RUNTIME_ARTIFACT, ContainerBuildError, + FinalAuthSelection, RuntimeArtifactV1, + materialize_build_bundle, plan_container_image, + plan_generated_up_auth, ) from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.docker_runtime import ( @@ -59,6 +62,7 @@ from agentseek_api.secure_temp import ( SecureArtifactError, private_artifact, + private_directory, sweep_expired_artifacts, ) @@ -595,19 +599,35 @@ def _resolve_application_container_payload( plan: EnvironmentPlan, *, selection: ContainerSelection, - cwd: Path, -) -> dict[str, str]: +) -> 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_module_path = payload.get("AUTH_MODULE_PATH") - if auth_module_path: - payload["AUTH_MODULE_PATH"] = _containerize_symbol_reference( - auth_module_path, cwd=cwd + 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 + return payload, auth_selection + + +def _is_host_auth_reference(reference: str) -> bool: + parts = _split_symbol_reference(reference) + if parts is None: + return False + module_name, _ = parts + return ( + module_name.endswith(".py") + or module_name.startswith(".") + or "/" in module_name + or "\\" in module_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 build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: @@ -1161,12 +1181,21 @@ def _execute_dockerfile_command( ) _load_cli_config(config_path) save_path = _resolve_path(args.save_path, cwd=cwd) - plan_container_image( + 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_dockerfile(config_path=config_path, cwd=cwd).encode( + "utf-8" + ) + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile_bytes, + output_root=save_path, ) - write_dockerfile(config_path=config_path, save_path=save_path, cwd=cwd) - stdout.write(f"{save_path}\n") + stdout.write(f"{bundle.dockerfile}\n") return 0 @@ -1183,33 +1212,40 @@ def _execute_build_command( f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." ) _load_cli_config(config_path) - plan_container_image( + 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, ) - generated_dockerfile = write_dockerfile( - config_path=config_path, - save_path=(cwd / ".agentseek" / "Dockerfile").resolve(), - cwd=cwd, + dockerfile_bytes = render_dockerfile(config_path=config_path, cwd=cwd).encode( + "utf-8" ) 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), "."]) - plan = build_host_environment_plan( + command.extend(["-t", args.tag, "-f", "Dockerfile", "-"]) + environment_plan = build_host_environment_plan( config_path=config_path, env_file=args.env_file, cwd=cwd, role=None, ) - invocation = build_docker_control_invocation( - argv=tuple(command), - docker_control=docker_control_environment(plan), - cwd=cwd, - ) - return process_transport(invocation).returncode + with private_directory(prefix="agentseek-build-") as output_root: + bundle = materialize_build_bundle( + build_plan, + dockerfile_bytes=dockerfile_bytes, + output_root=output_root, + ) + invocation = build_docker_control_invocation( + argv=tuple(command), + docker_control=docker_control_environment(environment_plan), + cwd=cwd, + stdin_bytes=bundle.archive_bytes(), + ) + return process_transport(invocation).returncode def _container_name_for_port(port: int) -> str: @@ -1272,10 +1308,41 @@ def _execute_up_command( postgres_uri=args.postgres_uri, ) docker_control = dict(docker_control_environment(environment_plan)) - application_payload = _resolve_application_container_payload( - environment_plan, selection=selection, cwd=cwd + application_payload, final_auth = _resolve_application_container_payload( + environment_plan, selection=selection ) + generated_plan = None + generated_dockerfile_bytes: bytes | None = None + if image: + if ( + final_auth is not None + and final_auth.value + and _is_host_auth_reference(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: + 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 + generated_dockerfile_bytes = render_dockerfile( + config_path=config_path, + cwd=cwd, + base_image_override=args.base_image, + ).encode("utf-8") + compose_path: Path | None = None compose_payload: dict[str, str] = {} encoded_compose: bytes | None = None @@ -1302,26 +1369,26 @@ def _execute_up_command( encoded_compose = encode_compose_environment(compose_payload).encode("utf-8") if not image: - plan_container_image( - config_path=config_path, - base_image_override=args.base_image, - runtime_artifact=runtime_artifact, - ) + assert generated_plan is not None + assert generated_dockerfile_bytes is not None 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_invocation = build_docker_control_invocation( - argv=tuple(build_command), docker_control=docker_control, cwd=cwd - ) - build_exit_code = process_transport(build_invocation).returncode + build_command.extend(["-t", image, "-f", "Dockerfile", "-"]) + with private_directory(prefix="agentseek-build-") as output_root: + bundle = materialize_build_bundle( + generated_plan, + dockerfile_bytes=generated_dockerfile_bytes, + output_root=output_root, + ) + build_invocation = build_docker_control_invocation( + argv=tuple(build_command), + docker_control=docker_control, + cwd=cwd, + stdin_bytes=bundle.archive_bytes(), + ) + build_exit_code = process_transport(build_invocation).returncode if build_exit_code != 0: return build_exit_code diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 128f433..65cc701 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -5,6 +5,7 @@ import hashlib import io import json +import math import os import re import shutil @@ -25,12 +26,18 @@ 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" _CONTAINER_ROOT = PurePosixPath("/deps/agent") +_VCS_METADATA_NAMES = frozenset({".git", ".hg", ".svn", ".bzr"}) class ContainerBuildError(ValueError): @@ -38,6 +45,8 @@ class ContainerBuildError(ValueError): 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)): @@ -125,6 +134,28 @@ class AuthOpenApiManifestV1: 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: @@ -160,6 +191,13 @@ class SourceReason(StrEnum): 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: @@ -286,6 +324,7 @@ def to_json_bytes(self) -> bytes: ensure_ascii=False, sort_keys=True, separators=(",", ":"), + allow_nan=False, ).encode("utf-8") + b"\n" ) @@ -302,7 +341,10 @@ class ContainerBuildPlan: 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) def __post_init__(self) -> None: @@ -442,7 +484,22 @@ def _auth_json(auth: AuthPolicyManifestV1) -> dict[str, object]: def _safe_regular(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[:-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): @@ -454,7 +511,7 @@ def _safe_regular(path: Path, *, project_root: Path, purpose: str) -> Path: raise ContainerBuildError( f"The {purpose} source must remain inside the project root." ) from exc - if ".git" in resolved.relative_to(project_root).parts: + if _VCS_METADATA_NAMES.intersection(resolved.relative_to(project_root).parts): raise ContainerBuildError( f"The {purpose} source selects excluded VCS metadata." ) @@ -463,7 +520,23 @@ def _safe_regular(path: Path, *, project_root: Path, purpose: str) -> Path: def _safe_directory(path: Path, *, project_root: Path, purpose: str) -> Path: try: - raw_status = path.absolute().lstat() + 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): @@ -475,7 +548,7 @@ def _safe_directory(path: Path, *, project_root: Path, purpose: str) -> Path: raise ContainerBuildError( f"The {purpose} source must remain inside the project root." ) from exc - if ".git" in resolved.relative_to(project_root).parts: + if _VCS_METADATA_NAMES.intersection(resolved.relative_to(project_root).parts): raise ContainerBuildError( f"The {purpose} source selects excluded VCS metadata." ) @@ -521,19 +594,52 @@ def _add_selected( 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})) + 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, + existing.reasons | frozenset({reason}), + source_identity=source_identity, + source_sha256=source_sha256, + ancestor_identities=ancestor_identities, ) @@ -550,7 +656,7 @@ def _select_file( relative = source.relative_to(project_root) if ( source in excluded - or ".git" in relative.parts + or _VCS_METADATA_NAMES.intersection(relative.parts) or source.name in { ".gitignore", @@ -565,7 +671,13 @@ def _select_file( destination = str( PurePosixPath(destination_prefix) / PurePosixPath(relative.as_posix()) ) - _add_selected(selected, destination=destination, source=source, reason=reason) + _add_selected( + selected, + destination=destination, + source=source, + reason=reason, + project_root=project_root, + ) return source @@ -580,7 +692,7 @@ def _select_tree( root = _safe_directory(root, project_root=project_root, purpose=reason.value) for candidate in sorted(root.rglob("*")): relative = candidate.relative_to(project_root) - if ".git" in relative.parts: + if _VCS_METADATA_NAMES.intersection(relative.parts): continue status = candidate.lstat() if stat.S_ISLNK(status.st_mode): @@ -636,6 +748,8 @@ def _optional_number( 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 @@ -909,34 +1023,44 @@ def _validate_security_scheme(name: str, raw: object) -> Mapping[str, JsonValue] raise ContainerBuildError( f"auth.openapi.securitySchemes.{name} must be an object." ) - allowed = { - "type", - "description", - "name", - "in", - "scheme", - "bearerFormat", - "flows", - "openIdConnectUrl", + 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"}, } - _validate_allowed(raw, allowed, f"auth.openapi.securitySchemes.{name}") + 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"auth.openapi.securitySchemes.{name}.flows" - ) + _validate_oauth_flows(value, location=f"{location}.flows") continue if not isinstance(value, str): - raise ContainerBuildError( - f"auth.openapi.securitySchemes.{name}.{key} must be a string." - ) + raise ContainerBuildError(f"{location}.{key} must be a string.") if key.endswith("Url"): _reject_credential_url(value) - return _freeze_json(raw, location=f"auth.openapi.securitySchemes.{name}") # type: ignore[return-value] + return _freeze_json(raw, location=location) # type: ignore[return-value] def _reject_credential_url(value: str) -> None: @@ -945,10 +1069,18 @@ def _reject_credential_url(value: str) -> 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): + if not isinstance(value, dict) or not value: raise ContainerBuildError(f"{location} must be an object.") _validate_allowed( value, @@ -962,6 +1094,16 @@ def _validate_oauth_flows(value: object, *, location: str) -> None: 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) @@ -1057,7 +1199,13 @@ def _validate_requirement_url(value: str) -> None: raise ContainerBuildError( "Dependency URLs must use HTTPS without embedded credentials; use pip_config_file." ) - if parsed.fragment.startswith("subdirectory="): + if parsed.query: + raise ContainerBuildError( + "Dependency URL query parameters are not permitted; use pip_config_file." + ) + if parsed.fragment and not re.fullmatch( + r"sha256=[0-9a-fA-F]{64}", parsed.fragment + ): raise ContainerBuildError("Dependency URL fragments are not supported.") @@ -1322,9 +1470,23 @@ def plan_container_image( 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() - project_root = _discover_project_root(config) + 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) @@ -1333,7 +1495,7 @@ def plan_container_image( if isinstance(raw_env, str): path = Path(raw_env).expanduser() if not path.is_absolute(): - path = project_root / path + path = reference_base / path configured_dotenv.append(path) all_dotenv = [*configured_dotenv, *(Path(item) for item in dotenv_paths)] resolved_dotenv: list[Path] = [] @@ -1410,7 +1572,7 @@ def plan_container_image( value = raw_env.get("AUTH_MODULE_PATH") if isinstance(value, str): static_auth = value - static_auth_base = project_root + 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: @@ -1506,6 +1668,7 @@ def plan_container_image( destination=destination, source=candidate_source, reason=SourceReason.RUNTIME_ARTIFACT, + project_root=project_root, ) raw_base = payload.get("base_image") @@ -1544,7 +1707,10 @@ def plan_container_image( 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, ) @@ -1554,7 +1720,13 @@ def _without_auth_reasons(plan: ContainerBuildPlan) -> 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) + selected[destination] = SelectedSource( + source.source_path, + reasons, + source_identity=source.source_identity, + source_sha256=source.source_sha256, + ancestor_identities=source.ancestor_identities, + ) return selected @@ -1570,7 +1742,12 @@ def plan_generated_up_auth( return replace(plan, selected_sources=selected), AuthPayloadPatch( selection.value ) - located = _module_file(selection.value, base=plan.project_root) + 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 @@ -1587,11 +1764,6 @@ def plan_generated_up_auth( return replace(plan, selected_sources=selected), AuthPayloadPatch(rewritten) -def _digest(path: Path) -> tuple[str, int]: - data = path.read_bytes() - return hashlib.sha256(data).hexdigest(), len(data) - - def _write_file(path: Path, data: bytes) -> None: path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL @@ -1599,14 +1771,22 @@ def _write_file(path: Path, data: bytes) -> None: flags |= os.O_NOFOLLOW fd = os.open(path, flags, 0o600) try: - os.write(fd, data) + 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 + 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() @@ -1630,9 +1810,7 @@ def _read_regular_source( "A selected build source identity changed before copy." ) if expected_identity is not None and opened_identity != expected_identity: - raise ContainerBuildError( - "The candidate wheel identity changed before materialization." - ) + raise ContainerBuildError(identity_error) chunks: list[bytes] = [] while True: chunk = os.read(fd, 1024 * 1024) @@ -1650,6 +1828,15 @@ def materialize_build_bundle( 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 root = Path(output_root).absolute() try: root_status = root.lstat() @@ -1671,24 +1858,80 @@ def materialize_build_bundle( ) else: try: - root.mkdir(parents=True, mode=0o700) - except OSError as exc: + 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] = {} + + def verify_output_directories() -> None: + _verify_directory_identity( + root, + expected=root_identity, + message="The build output root identity changed.", + ) + _verify_directory_identity( + context, + expected=context_identity, + message="The build output context identity changed.", + ) + + def write_output(path: Path, data: bytes) -> None: + verify_output_directories() + _write_file(path, data) + verify_output_directories() + frozen_outputs[path] = data + try: for destination, selected in sorted(plan.selected_sources.items()): source = selected.source_path - candidate_identity = ( - plan.runtime_artifact.candidate_identity - if selected.reasons == frozenset({SourceReason.RUNTIME_ARTIFACT}) - else None + 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." + ), ) - data = _read_regular_source(source, expected_identity=candidate_identity) + 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: @@ -1702,24 +1945,24 @@ def materialize_build_bundle( ): raise ContainerBuildError("The candidate wheel identity changed.") target = context / PurePosixPath(destination) - _write_file(target, data) + write_output(target, data) created.append(target) manifest = context / "manifest.v1.json" - _write_file(manifest, plan.manifest.to_json_bytes()) + write_output(manifest, plan.manifest.to_json_bytes()) constraints = context / "runtime-constraints.txt" - _write_file(constraints, b"agentseek-api==0.3.0\n") + write_output(constraints, b"agentseek-api==0.3.0\n") dockerfile = context / "Dockerfile" - _write_file(dockerfile, bytes(dockerfile_bytes)) + write_output(dockerfile, bytes(dockerfile_bytes)) inventory = tuple( BuildInventoryEntry( relative_path=path.relative_to(context).as_posix(), - sha256=_digest(path)[0], - size=_digest(path)[1], + 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_file( + write_output( inventory_path, ( json.dumps( @@ -1745,14 +1988,23 @@ def materialize_build_bundle( inventory=inventory, ) except Exception: - shutil.rmtree(context, ignore_errors=True) + 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 inventory_path.exists() and not inventory_path.is_symlink(): + 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: + if root_was_created and root_is_original: try: root.rmdir() except OSError: @@ -1762,16 +2014,39 @@ def materialize_build_bundle( def _verify_private_output_root(root: Path) -> None: try: - status = root.lstat() - except OSError as exc: + verify_private_directory(root) + except SecureArtifactError as exc: raise ContainerBuildError( "The build output root could not be verified private." ) from exc - if stat.S_ISLNK(status.st_mode) or not stat.S_ISDIR(status.st_mode): - raise ContainerBuildError("The build output root is not a private directory.") - if os.name != "nt": - if status.st_uid != os.getuid() or stat.S_IMODE(status.st_mode) != 0o700: - raise ContainerBuildError("The build output root is not user-private.") + + +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( diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 11d6733..309bfe4 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -186,6 +186,65 @@ def _verify_closed_windows( # pragma: no cover - native Windows only _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": @@ -390,9 +449,9 @@ def _apply_private_dacl( # pragma: no cover - native Windows only raise _win32_error("Could not establish exclusive Windows access.") -def _create_private_windows_directory( # pragma: no cover - native Windows only - root: Path, prefix: str -) -> tuple[Path, os.stat_result]: +def _create_private_windows_directory_at( # pragma: no cover - native Windows only + candidate: Path, +) -> os.stat_result: from ctypes import wintypes _, kernel32 = _win32_libraries() @@ -404,22 +463,33 @@ class SecurityAttributes(ctypes.Structure): ("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) - 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 candidate, expected - if ctypes.get_last_error() != 183: - raise SecureArtifactError("Could not create a private directory.") + try: + expected = _create_private_windows_directory_at(candidate) + except FileExistsError: + continue + return candidate, expected raise SecureArtifactError("Could not create a private directory.") @@ -961,7 +1031,9 @@ def sweep_expired_artifacts( __all__ = [ "SecureArtifactError", + "create_private_directory", "private_artifact", "private_directory", "sweep_expired_artifacts", + "verify_private_directory", ] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ff62f6b..6915fe8 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1,10 +1,13 @@ from __future__ import annotations import argparse +import hashlib import importlib import io import signal +import tarfile import tomllib +import zipfile from dataclasses import dataclass from pathlib import Path @@ -1361,12 +1364,16 @@ def test_dockerfile_command_writes_langgraph_compatible_runtime_file( 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 (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 ( "RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*" @@ -1403,7 +1410,7 @@ def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag( exit_code = main(["dockerfile", 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 "ENV AGENTSEEK_GRAPHS=/deps/agent/agentseek.json" in content assert "ENV AGENTSEEK_GRAPHS=/deps/agent/langgraph.json" not in content @@ -1454,7 +1461,7 @@ def test_dockerfile_command_honors_base_image_python_and_custom_lines( ) 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 ( @@ -1502,7 +1509,7 @@ def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").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 @@ -1545,7 +1552,7 @@ def test_dockerfile_command_skips_root_install_when_root_is_not_installable( ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").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 @@ -1592,7 +1599,7 @@ def test_dockerfile_command_uses_manifest_project_root_not_invocation_root( ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").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 @@ -1634,7 +1641,7 @@ def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifes ) assert exit_code == 0 - content = dockerfile_path.read_text(encoding="utf-8") + content = (dockerfile_path / "context" / "Dockerfile").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" @@ -1668,7 +1675,7 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( invocation = capture.calls[0] assert type(invocation) is ProcessInvocation assert "AGENTSEEK_GRAPHS" not in invocation.environment - assert invocation.argv[:8] == ( + assert invocation.argv == ( "docker", "build", "--platform", @@ -1676,10 +1683,21 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( "-t", "agentseek:test", "-f", - str((tmp_path / ".agentseek" / "Dockerfile").resolve()), - ) - assert invocation.argv[-1] == "." - generated = (tmp_path / ".agentseek" / "Dockerfile").read_text(encoding="utf-8") + "Dockerfile", + "-", + ) + 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 ( "RUN apt-get update && apt-get install -y --no-install-recommends git && rm -rf /var/lib/apt/lists/*" in generated @@ -1690,6 +1708,187 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' in generated ) + assert not (tmp_path / ".agentseek").exists() + + +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 = capture.calls[0].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 = capture.calls[0].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 = capture.calls[0] + assert build.argv == ( + "docker", + "build", + "-t", + "agentseek-up:8123", + "-f", + "Dockerfile", + "-", + ) + 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"]) +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("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 def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: @@ -2012,7 +2211,7 @@ def test_dockerfile_command_allows_supported_explicit_langgraph_base_image( ) 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 @@ -2449,9 +2648,10 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-t", "agentseek-up:8124", "-f", - str((tmp_path / ".agentseek" / "Dockerfile").resolve()), - ".", + "Dockerfile", + "-", ) + assert capture.calls[0].stdin_bytes is not None assert capture.calls[1].argv == ( "docker", "container", @@ -2511,6 +2711,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( """.strip(), encoding="utf-8", ) + (tmp_path / "auth.py").write_text("backend = object()\n", encoding="utf-8") capture = _ProcessCapture() exit_code = main( @@ -2518,8 +2719,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "up", "--config", str(config_path), - "--image", - "agentseek:test", + "--no-pull", ], process_transport=capture, cwd=tmp_path, @@ -2527,13 +2727,13 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0].argv == ( + assert capture.calls[1].argv == ( "docker", "container", "inspect", "agentseek-up-8123", ) - assert capture.calls[1].argv[:9] == ( + assert capture.calls[2].argv[:9] == ( "docker", "run", "--detach", @@ -2544,7 +2744,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "-p", "8123:2024", ) - assert capture.calls[1].argv[-1] == "agentseek:test" + assert capture.calls[2].argv[-1] == "agentseek-up:8123" container_env = _application_environment(capture) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/auth.py:backend" @@ -2662,7 +2862,13 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No ) assert exit_code == 0 - dockerfile = (tmp_path / ".agentseek" / "Dockerfile").read_text(encoding="utf-8") + assert capture.calls is not None + archive_bytes = capture.calls[0].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 @@ -2704,8 +2910,8 @@ def test_up_command_returns_build_failure_without_running_container( "-t", "agentseek-up:8125", "-f", - str((tmp_path / ".agentseek" / "Dockerfile").resolve()), - ".", + "Dockerfile", + "-", ) capture = _ProcessCapture(return_codes={build_argv: 9}) diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 0d0b151..1112f2b 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -221,6 +221,13 @@ def test_generated_up_auth_preserves_empty_and_rewrites_local_file( 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) ) @@ -834,3 +841,573 @@ def test_archive_rejects_added_symlink(tmp_path: Path) -> None: (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 + ) + + +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_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_secure_temp.py b/tests/unit/test_secure_temp.py index a6d1d0c..7def47a 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -525,3 +525,59 @@ def test_windows_sweep_removes_private_directory_with_inherited_descendants( assert not path.exists() finally: manager.__exit__(None, None, None) + + +@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) From 04952a3ae060923c1badf1621cd215330985c8bd Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 19:43:28 +0800 Subject: [PATCH 12/42] fix: secure nested bundle output paths --- src/agentseek_api/container_build.py | 218 +++++++++++++++++++++++---- tests/unit/test_container_build.py | 165 ++++++++++++++++++++ 2 files changed, 357 insertions(+), 26 deletions(-) diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 65cc701..4e78e4d 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -13,7 +13,8 @@ import tarfile import tomllib import zipfile -from collections.abc import Mapping, Sequence +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 @@ -483,9 +484,22 @@ def _auth_json(auth: AuthPolicyManifestV1) -> dict[str, object]: def _safe_regular(path: Path, *, project_root: Path, purpose: str) -> Path: try: - raw = path.absolute() - relative_raw = raw.relative_to(project_root) - current = project_root + 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() @@ -504,13 +518,6 @@ def _safe_regular(path: Path, *, project_root: Path, purpose: str) -> Path: 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.") - resolved = raw.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." @@ -1765,11 +1772,132 @@ def plan_generated_up_auth( def _write_file(path: Path, data: bytes) -> None: - path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + 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 if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW - fd = os.open(path, flags, 0o600) + return flags + + +def _write_file_descriptor(fd: int, data: bytes) -> None: try: remaining = memoryview(data) while remaining: @@ -1837,9 +1965,9 @@ def materialize_build_bundle( raise ContainerBuildError( "The project root changed before materialization." ) from exc - root = Path(output_root).absolute() + requested_root = Path(output_root).absolute() try: - root_status = root.lstat() + root_status = requested_root.lstat() except FileNotFoundError: root_status = None except OSError as exc: @@ -1851,12 +1979,14 @@ def materialize_build_bundle( if ( stat.S_ISLNK(root_status.st_mode) or not stat.S_ISDIR(root_status.st_mode) - or any(root.iterdir()) + 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) @@ -1871,23 +2001,59 @@ def materialize_build_bundle( 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: - _verify_directory_identity( - root, - expected=root_identity, - message="The build output root identity changed.", - ) - _verify_directory_identity( - context, - expected=context_identity, - message="The build output context identity changed.", - ) + 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: diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 1112f2b..fbf3344 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -5,6 +5,7 @@ import json import os import tarfile +import tempfile import zipfile from pathlib import Path @@ -1250,6 +1251,128 @@ def test_invocation_cwd_must_be_an_existing_directory( ) +@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: @@ -1370,6 +1493,48 @@ def reject_output(_path: Path) -> None: ) +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: From 30c28eada581a66b1dce71bf29df61097d9116b6 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 20:12:01 +0800 Subject: [PATCH 13/42] feat: enforce preloaded container image contract --- pyproject.toml | 1 + scripts/test_minimum_cli_dependencies.py | 15 + src/agentseek_api/cli.py | 258 ++++------------ src/agentseek_api/container_build.py | 357 +++++++++++++++++++++-- src/agentseek_api/docker_runtime.py | 118 ++++++++ tests/container_plan_helpers.py | 57 ++++ tests/unit/test_cli.py | 217 +++++++++----- tests/unit/test_container_build.py | 247 +++++++++++++++- tests/unit/test_docker_runtime.py | 186 ++++++++++++ uv.lock | 2 + 10 files changed, 1163 insertions(+), 295 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 7e7bbf6..9579044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] 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/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 914b15e..3ca0d4b 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -31,6 +31,7 @@ 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 ( @@ -40,10 +41,12 @@ SubprocessTransport, build_compose_invocation, build_docker_control_invocation, + build_image_invocation, build_docker_query_invocation, build_docker_run_invocation, encode_compose_environment, require_supported_compose, + require_supported_buildx, ) from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file from agentseek_api.environment import ( @@ -97,7 +100,6 @@ "main", "register_subcommands", "run_namespace", - "write_dockerfile", ] _CONTAINER_ENV_PREFIXES = ( @@ -940,98 +942,6 @@ def _containerize_symbol_reference(reference: str, *, cwd: Path) -> str: 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 @@ -1093,80 +1003,6 @@ def _validate_base_image(base_image: str) -> None: ) -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, *, @@ -1179,7 +1015,14 @@ def _execute_dockerfile_command( raise CliError( f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." ) - _load_cli_config(config_path) + config = _load_cli_config(config_path) + _validate_base_image( + config.base_image + or _default_base_image( + python_version=config.python_version, + image_distro=config.image_distro, + ) + ) save_path = _resolve_path(args.save_path, cwd=cwd) plan = plan_container_image( config_path=config_path, @@ -1187,9 +1030,7 @@ def _execute_dockerfile_command( runtime_artifact=runtime_artifact, invocation_cwd=cwd, ) - dockerfile_bytes = render_dockerfile(config_path=config_path, cwd=cwd).encode( - "utf-8" - ) + dockerfile_bytes = render_build_dockerfile(plan) bundle = materialize_build_bundle( plan, dockerfile_bytes=dockerfile_bytes, @@ -1211,22 +1052,21 @@ def _execute_build_command( raise CliError( f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." ) - _load_cli_config(config_path) + config = _load_cli_config(config_path) + _validate_base_image( + config.base_image + or _default_base_image( + python_version=config.python_version, + image_distro=config.image_distro, + ) + ) 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_dockerfile(config_path=config_path, cwd=cwd).encode( - "utf-8" - ) - command = ["docker", "build"] - if args.platform: - command.extend(["--platform", args.platform]) - if args.pull: - command.append("--pull") - command.extend(["-t", args.tag, "-f", "Dockerfile", "-"]) + dockerfile_bytes = render_build_dockerfile(build_plan) environment_plan = build_host_environment_plan( config_path=config_path, env_file=args.env_file, @@ -1239,12 +1079,23 @@ def _execute_build_command( dockerfile_bytes=dockerfile_bytes, output_root=output_root, ) - invocation = build_docker_control_invocation( - argv=tuple(command), + invocation = build_image_invocation( + bundle, + plan=build_plan, docker_control=docker_control_environment(environment_plan), - cwd=cwd, - stdin_bytes=bundle.archive_bytes(), + tag=args.tag, + platform=args.platform, + pull=args.pull, ) + try: + require_supported_buildx( + transport=process_transport, + docker_control=docker_control_environment(environment_plan), + cwd=cwd, + plan=build_plan, + ) + except DockerRuntimeError as exc: + raise CliError(str(exc)) from exc return process_transport(invocation).returncode @@ -1324,6 +1175,15 @@ def _execute_up_command( "Custom-image auth cannot reference a host file; bake the module into the image and use an importable package reference." ) else: + config = _load_cli_config(config_path) + _validate_base_image( + args.base_image + or config.base_image + or _default_base_image( + python_version=config.python_version, + image_distro=config.image_distro, + ) + ) generated_plan = plan_container_image( config_path=config_path, dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), @@ -1337,11 +1197,7 @@ def _execute_up_command( application_payload.pop("AUTH_MODULE_PATH", None) else: application_payload["AUTH_MODULE_PATH"] = auth_patch.value - generated_dockerfile_bytes = render_dockerfile( - config_path=config_path, - cwd=cwd, - base_image_override=args.base_image, - ).encode("utf-8") + generated_dockerfile_bytes = render_build_dockerfile(generated_plan) compose_path: Path | None = None compose_payload: dict[str, str] = {} @@ -1372,22 +1228,28 @@ def _execute_up_command( assert generated_plan is not None assert generated_dockerfile_bytes is not None image = f"agentseek-up:{args.port}" - build_command = ["docker", "build"] - if args.pull: - build_command.append("--pull") - build_command.extend(["-t", image, "-f", "Dockerfile", "-"]) with private_directory(prefix="agentseek-build-") as output_root: bundle = materialize_build_bundle( generated_plan, dockerfile_bytes=generated_dockerfile_bytes, output_root=output_root, ) - build_invocation = build_docker_control_invocation( - argv=tuple(build_command), + build_invocation = build_image_invocation( + bundle, + plan=generated_plan, docker_control=docker_control, - cwd=cwd, - stdin_bytes=bundle.archive_bytes(), + tag=image, + pull=args.pull, ) + try: + require_supported_buildx( + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + plan=generated_plan, + ) + except DockerRuntimeError as exc: + raise CliError(str(exc)) from exc build_exit_code = process_transport(build_invocation).returncode if build_exit_code != 0: return build_exit_code diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 4e78e4d..5bbfedd 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -20,11 +20,12 @@ from pathlib import Path, PurePosixPath from types import MappingProxyType from typing import Literal, TypeAlias -from urllib.parse import urlsplit +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 ( @@ -347,6 +348,9 @@ class ContainerBuildPlan: 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__( @@ -562,6 +566,40 @@ def _safe_directory(path: Path, *, project_root: Path, purpose: str) -> Path: 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 = os.O_RDONLY + 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())) @@ -1189,31 +1227,125 @@ def _parse_auth(raw: object) -> tuple[AuthPolicyManifestV1 | None, str | None]: def _dependency_is_local(value: str) -> bool: - if value.startswith(("https://", "http://")) or " @ https://" in value: + if re.match(r"^[A-Za-z]:[\\/]", value): + return True + try: + Requirement(value) + except InvalidRequirement: + pass + else: return False - return value == "." or value.startswith(".") or "/" in value or "\\" in value + 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 _validate_requirement_url(value: str) -> None: - candidate = value.split(" @ ", 1)[-1].strip() - if candidate.startswith(("http://", "https://")): - parsed = urlsplit(candidate) - if ( - parsed.scheme != "https" - or parsed.username is not None - or parsed.password is not None +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 ContainerBuildError( - "Dependency URLs must use HTTPS without embedded credentials; use pip_config_file." + 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" ) - if parsed.query: - raise ContainerBuildError( - "Dependency URL query parameters are not permitted; use pip_config_file." + 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, ) - if parsed.fragment and not re.fullmatch( - r"sha256=[0-9a-fA-F]{64}", parsed.fragment - ): - raise ContainerBuildError("Dependency URL fragments are not supported.") + ) + 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: @@ -1515,15 +1647,14 @@ def plan_container_image( 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): - raise ContainerBuildError("pip_config_file must be a string.") + 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 = _safe_regular( - pip_candidate, project_root=project_root, purpose="pip config" - ) + 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 @@ -1692,7 +1823,17 @@ def plan_container_image( isinstance(line, str) for line in raw_lines ): raise ContainerBuildError("dockerfile_lines must be an array of strings.") - base = base_image_override or raw_base or f"python:{raw_python}-slim" + 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 unsupported without an explicit base_image." + ) manifest = ContainerRuntimeManifestV1( schema_version=1, runtime=RuntimeManifestV1( @@ -1719,6 +1860,7 @@ def plan_container_image( project_root_identity=_directory_identity(project_root.lstat()), invocation_cwd=resolved_invocation_cwd, excluded_paths=excluded, + pip_config_identity=pip_identity, ) @@ -1771,6 +1913,167 @@ def plan_generated_up_auth( 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: + lines.append( + "COPY " + + json.dumps( + [candidate_source, "/opt/agentseek/runtime/agentseek-api-0.3.0.whl"], + 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: + assert artifact.candidate_sha256 is not None + candidate_check = ( + "import hashlib,pathlib;" + "p=pathlib.Path('/opt/agentseek/runtime/agentseek-api-0.3.0.whl');" + f"assert hashlib.sha256(p.read_bytes()).hexdigest()=='{artifact.candidate_sha256}'" + ) + lines.append(_docker_exec_run(("python", "-c", candidate_check))) + runtime_operand = "/opt/agentseek/runtime/agentseek-api-0.3.0.whl[embedded]" + else: + runtime_operand = artifact.requirement + lines.append( + _docker_exec_run( + ( + "python", + "-m", + "pip", + "install", + "--no-cache-dir", + "--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;" + "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();" + f"assert raw==canonical and hashlib.sha256(raw).hexdigest()=='{manifest_sha256}'" + ) + 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;" + f"assert importlib.metadata.version('agentseek-api')=='{artifact.version}';" + "module=pathlib.Path(agentseek_api.cli.__file__).resolve();" + "roots={pathlib.Path(value).resolve() for key,value in sysconfig.get_paths().items() " + "if key in {'purelib','platlib'}};" + "assert roots and any(module.is_relative_to(root) for root in roots);" + "assert sys.version_info[:2]>=(3,12)" + ) + 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", + "-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 @@ -2284,4 +2587,6 @@ def create_deterministic_context_archive( "materialize_build_bundle", "plan_container_image", "plan_generated_up_auth", + "render_build_dockerfile", + "validate_dependency_specification", ] diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 1ae7e44..67376aa 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -4,7 +4,9 @@ import json import math +import os import re +import stat import subprocess import sys from collections.abc import Callable, Mapping @@ -14,10 +16,12 @@ from typing import Protocol from agentseek_api.container_policy import select_compose_payload +from agentseek_api.container_build import ContainerBuildBundle, ContainerBuildPlan from agentseek_api.environment import ContainerPolicyError 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}}]" ) @@ -49,6 +53,16 @@ def __post_init__(self) -> None: 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.""" @@ -215,6 +229,67 @@ def build_docker_query_invocation( ) +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_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.""" + + archive = bundle.archive_bytes() + validate_pip_config_identity(plan) + argv = ["docker", "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.") @@ -394,6 +469,44 @@ def require_buildx_available(result: ProcessResult) -> None: raise DockerRuntimeError("Docker Buildx builder is unavailable.") +def require_supported_buildx( + *, + 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", "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.") + inspect_query = build_docker_query_invocation( + argv=("docker", "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 @@ -431,6 +544,7 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig __all__ = [ + "BuildImageInvocation", "ControlQueryInvocation", "DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS", "DockerRunInvocation", @@ -438,12 +552,14 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "DockerRuntimeError", "LegacyRunnerAdapter", "IMAGE_COMPATIBILITY_FORMAT", + "MINIMUM_BUILDX_VERSION", "MINIMUM_COMPOSE_VERSION", "ProcessInvocation", "ProcessResult", "ProcessTransport", "SubprocessTransport", "build_docker_control_invocation", + "build_image_invocation", "build_compose_invocation", "build_docker_query_invocation", "build_docker_run_invocation", @@ -452,6 +568,8 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "parse_compose_version_result", "parse_image_compatibility_result", "require_buildx_available", + "require_supported_buildx", "require_supported_compose", + "validate_pip_config_identity", "validate_environment_name", ] diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index fb9d5ea..5f217c4 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -1,9 +1,11 @@ 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 @@ -11,6 +13,13 @@ 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: @@ -26,6 +35,54 @@ def make_graph_project(root: Path) -> Path: return project +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) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 6915fe8..afd4220 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -4,6 +4,7 @@ import hashlib import importlib import io +import json import signal import tarfile import tomllib @@ -16,6 +17,7 @@ from agentseek_api import __version__ from agentseek_api.docker_runtime import ( + BuildImageInvocation, ControlQueryInvocation, DockerRunInvocation, ProcessInvocation, @@ -135,6 +137,11 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: self.calls.append(invocation) if invocation.argv == ("docker", "compose", "version", "--short"): return ProcessResult(returncode=0, stdout=b"2.40.3\n") + if invocation.argv == ("docker", "buildx", "version"): + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", + ) return_code = 0 if invocation.argv[:3] == ("docker", "container", "inspect"): return_code = 0 if self.container_exists else 1 @@ -143,6 +150,15 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: return ProcessResult(returncode=return_code) +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 @@ -1277,6 +1293,7 @@ def test_package_exposes_library_and_cli_entrypoints() -> None: 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 @@ -1375,17 +1392,12 @@ def test_dockerfile_command_writes_langgraph_compatible_runtime_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 ( - "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 "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 ( - 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' - in content + '"serve", "--environment-mode", "preloaded-v1", "--host", "0.0.0.0"' in content ) @@ -1410,9 +1422,10 @@ def test_dockerfile_command_prefers_agentseek_json_without_explicit_flag( exit_code = main(["dockerfile", str(dockerfile_path)], cwd=tmp_path) assert exit_code == 0 - content = (dockerfile_path / "context" / "Dockerfile").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( @@ -1464,9 +1477,10 @@ def test_dockerfile_command_honors_base_image_python_and_custom_lines( 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") ) @@ -1510,19 +1524,12 @@ def test_dockerfile_command_translates_manifest_dependencies(tmp_path: Path) -> assert exit_code == 0 content = (dockerfile_path / "context" / "Dockerfile").read_text(encoding="utf-8") + assert '"/deps/agent/sample_project/local_pkg"' in content assert ( - "ENV PYTHONPATH=/deps/agent:/deps/agent/sample_project:/deps/agent/sample_project/local_pkg:/deps/agent/sample_project/reqs" - in content + '"--requirement", "/deps/agent/sample_project/reqs/requirements.txt"' 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 + assert '"httpx"' in content + assert '"."' not in content def test_dockerfile_command_skips_root_install_when_root_is_not_installable( @@ -1553,8 +1560,8 @@ def test_dockerfile_command_skips_root_install_when_root_is_not_installable( assert exit_code == 0 content = (dockerfile_path / "context" / "Dockerfile").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 + 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( @@ -1600,8 +1607,8 @@ def test_dockerfile_command_uses_manifest_project_root_not_invocation_root( assert exit_code == 0 content = (dockerfile_path / "context" / "Dockerfile").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 + 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( @@ -1642,11 +1649,11 @@ def test_dockerfile_command_installs_nearest_ancestor_project_for_nested_manifes assert exit_code == 0 content = (dockerfile_path / "context" / "Dockerfile").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 + 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( @@ -1672,18 +1679,24 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( assert exit_code == 0 assert capture.calls is not None - invocation = capture.calls[0] - assert type(invocation) is ProcessInvocation + assert [call.argv for call in capture.calls[:2]] == [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ] + invocation = capture.calls[2] + assert isinstance(invocation, BuildImageInvocation) assert "AGENTSEEK_GRAPHS" not in invocation.environment assert invocation.argv == ( "docker", + "buildx", "build", + "--load", + "--file", + "Dockerfile", "--platform", "linux/amd64,linux/arm64", - "-t", + "--tag", "agentseek:test", - "-f", - "Dockerfile", "-", ) assert invocation.stdin_bytes is not None @@ -1698,19 +1711,69 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( "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 ( - "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 + 'CMD ["python", "-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", "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", "buildx", "version"), + ("docker", "buildx", "inspect"), + ] + 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: @@ -1729,7 +1792,7 @@ def test_build_excludes_cli_dotenv_even_through_local_dependency_tree( assert exit_code == 0 assert capture.calls is not None - archive_bytes = capture.calls[0].stdin_bytes + 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: @@ -1763,7 +1826,7 @@ def test_candidate_runtime_injection_changes_copied_build_artifact( assert exit_code == 0 assert capture.calls is not None - archive_bytes = capture.calls[0].stdin_bytes + 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}") @@ -1805,14 +1868,16 @@ def test_generated_up_uses_final_auth_selection_and_sanitized_build_stdin( assert exit_code == 0 assert capture.calls is not None - build = capture.calls[0] + build = _captured_image_build(capture) assert build.argv == ( "docker", + "buildx", "build", - "-t", - "agentseek-up:8123", - "-f", + "--load", + "--file", "Dockerfile", + "--tag", + "agentseek-up:8123", "-", ) assert build.stdin_bytes is not None @@ -2642,23 +2707,29 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0].argv == ( + assert [call.argv for call in capture.calls[:2]] == [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ] + assert capture.calls[2].argv == ( "docker", + "buildx", "build", - "-t", - "agentseek-up:8124", - "-f", + "--load", + "--file", "Dockerfile", + "--tag", + "agentseek-up:8124", "-", ) - assert capture.calls[0].stdin_bytes is not None - assert capture.calls[1].argv == ( + assert capture.calls[2].stdin_bytes is not None + assert capture.calls[3].argv == ( "docker", "container", "inspect", "agentseek-up-8124", ) - assert capture.calls[2].argv[:9] == ( + assert capture.calls[4].argv[:9] == ( "docker", "run", "--detach", @@ -2669,8 +2740,8 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-p", "8124:2024", ) - assert capture.calls[2].argv[-1] == "agentseek-up:8124" - for invocation in capture.calls[:2]: + assert capture.calls[4].argv[-1] == "agentseek-up:8124" + for invocation in capture.calls[:4]: assert "METADATA_DB_URL" not in invocation.environment assert "postgresql://postgres:postgres@db/agentseek" not in " ".join( invocation.argv @@ -2727,13 +2798,13 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( assert exit_code == 0 assert capture.calls is not None - assert capture.calls[1].argv == ( + assert capture.calls[3].argv == ( "docker", "container", "inspect", "agentseek-up-8123", ) - assert capture.calls[2].argv[:9] == ( + assert capture.calls[4].argv[:9] == ( "docker", "run", "--detach", @@ -2744,7 +2815,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "-p", "8123:2024", ) - assert capture.calls[2].argv[-1] == "agentseek-up:8123" + assert capture.calls[4].argv[-1] == "agentseek-up:8123" container_env = _application_environment(capture) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/auth.py:backend" @@ -2863,7 +2934,7 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No assert exit_code == 0 assert capture.calls is not None - archive_bytes = capture.calls[0].stdin_bytes + 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") @@ -2905,12 +2976,14 @@ def test_up_command_returns_build_failure_without_running_container( _write_basic_langgraph_config(tmp_path) build_argv = ( "docker", + "buildx", "build", + "--load", + "--file", + "Dockerfile", "--pull", - "-t", + "--tag", "agentseek-up:8125", - "-f", - "Dockerfile", "-", ) capture = _ProcessCapture(return_codes={build_argv: 9}) @@ -2923,7 +2996,11 @@ def test_up_command_returns_build_failure_without_running_container( assert exit_code == 9 assert capture.calls is not None - assert [call.argv for call in capture.calls] == [build_argv] + assert [call.argv for call in capture.calls] == [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + build_argv, + ] def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) -> None: diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index fbf3344..bd11820 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -7,6 +7,7 @@ import tarfile import tempfile import zipfile +from dataclasses import replace from pathlib import Path import pytest @@ -15,6 +16,7 @@ from agentseek_api.container_build import ( PUBLISHED_RUNTIME_ARTIFACT, AuthPayloadPatch, + ContainerBuildError, FinalAuthSelection, InstallActionKind, RuntimeArtifactSource, @@ -26,9 +28,252 @@ 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 make_graph_project +from tests.container_plan_helpers import ( + build_plan_fixture, + make_graph_project, + package_only_build_plan_fixture, + read_archive_member, +) + + +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_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 + + +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.whl[embedded]" + ) def test_bundle_excludes_env_and_records_only_selected_regular_files( diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index fa1d770..b98e4ad 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -2,12 +2,15 @@ import json import subprocess +from dataclasses import replace from pathlib import Path import pytest from agentseek_api.docker_runtime import ( + MINIMUM_BUILDX_VERSION, MINIMUM_COMPOSE_VERSION, + BuildImageInvocation, ControlQueryInvocation, IMAGE_COMPATIBILITY_FORMAT, DockerRuntimeError, @@ -15,6 +18,7 @@ ProcessInvocation, ProcessResult, SubprocessTransport, + build_image_invocation, build_docker_control_invocation, build_compose_invocation, build_docker_query_invocation, @@ -24,16 +28,198 @@ parse_compose_version_result, parse_image_compatibility_result, require_buildx_available, + require_supported_buildx, require_supported_compose, ) +from agentseek_api.container_build import ( + materialize_build_bundle, + 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, ) +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_control={"PATH": "/usr/bin", "DOCKER_BUILDKIT": "0"}, + tag="agentseek:test", + platform="linux/amd64", + pull=True, + ) + assert isinstance(invocation, BuildImageInvocation) + assert invocation.argv == ( + "docker", + "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_control={}) + assert b"type=secret,id=pip_config" not in dockerfile + assert "--secret" not in invocation.argv + assert "None" not in invocation.argv + + +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", "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( + transport=transport, + docker_control={"PATH": "/usr/bin"}, + cwd=tmp_path, + plan=plan, + ) == (0, 12, 0) + assert [call.argv for call in calls] == [ + ("docker", "buildx", "version"), + ("docker", "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(transport=transport, docker_control={}, cwd=tmp_path) + assert all(call.argv[:3] != ("docker", "buildx", "build") for call in 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_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( + transport=transport, + docker_control={}, + cwd=tmp_path, + plan=plan, + ) + assert [call.argv for call in calls] == [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ] + + SPECIAL_VALUES = { "DOLLAR": "${DOCKER_HOST}", "LONE_DOLLAR": "$", diff --git a/uv.lock b/uv.lock index cc075a3..27f6ecb 100644 --- a/uv.lock +++ b/uv.lock @@ -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" }, From 6d527e4a3f410f5356c6ae2b3f9da6d4ef4af479 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 20:30:12 +0800 Subject: [PATCH 14/42] fix: harden preloaded image construction --- src/agentseek_api/cli.py | 60 +--------- src/agentseek_api/container_build.py | 96 +++++++++++++-- src/agentseek_api/docker_runtime.py | 12 +- tests/unit/test_cli.py | 65 +++++++--- tests/unit/test_container_build.py | 141 ++++++++++++++++++++++ tests/unit/test_docker_runtime.py | 173 +++++++++++++++++++++++++++ 6 files changed, 465 insertions(+), 82 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 3ca0d4b..c21f804 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -971,38 +971,6 @@ def build_container_env( 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 _execute_dockerfile_command( args: argparse.Namespace, *, @@ -1015,14 +983,7 @@ def _execute_dockerfile_command( raise CliError( f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." ) - config = _load_cli_config(config_path) - _validate_base_image( - config.base_image - or _default_base_image( - python_version=config.python_version, - image_distro=config.image_distro, - ) - ) + _load_cli_config(config_path) save_path = _resolve_path(args.save_path, cwd=cwd) plan = plan_container_image( config_path=config_path, @@ -1052,14 +1013,7 @@ def _execute_build_command( raise CliError( f"No config file found in '{cwd}'. Expected agentseek.json or langgraph.json." ) - config = _load_cli_config(config_path) - _validate_base_image( - config.base_image - or _default_base_image( - python_version=config.python_version, - image_distro=config.image_distro, - ) - ) + _load_cli_config(config_path) build_plan = plan_container_image( config_path=config_path, dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), @@ -1175,15 +1129,7 @@ def _execute_up_command( "Custom-image auth cannot reference a host file; bake the module into the image and use an importable package reference." ) else: - config = _load_cli_config(config_path) - _validate_base_image( - args.base_image - or config.base_image - or _default_base_image( - python_version=config.python_version, - image_distro=config.image_distro, - ) - ) + _load_cli_config(config_path) generated_plan = plan_container_image( config_path=config_path, dotenv_paths=_planner_dotenv_paths(args.env_file, cwd=cwd), diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 5bbfedd..419537d 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -372,6 +372,7 @@ class ContainerBuildBundle: dockerfile: Path manifest: Path inventory: tuple[BuildInventoryEntry, ...] + plan_fingerprint: str = field(repr=False) def archive_bytes(self) -> bytes: return create_deterministic_context_archive( @@ -379,6 +380,67 @@ def archive_bytes(self) -> bytes: ) +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 @@ -1832,7 +1894,7 @@ def plan_container_image( base = f"python:{raw_python}-slim-{raw_distro}" else: raise ContainerBuildError( - "image_distro is unsupported without an explicit base_image." + "image_distro is not supported without an explicit base_image." ) manifest = ContainerRuntimeManifestV1( schema_version=1, @@ -2002,11 +2064,16 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: lines.extend(plan.dockerfile_lines) if candidate_source is not None: - assert artifact.candidate_sha256 is not None + if artifact.candidate_sha256 is None: + raise ContainerBuildError( + "The candidate runtime artifact selection is invalid." + ) candidate_check = ( "import hashlib,pathlib;" "p=pathlib.Path('/opt/agentseek/runtime/agentseek-api-0.3.0.whl');" - f"assert hashlib.sha256(p.read_bytes()).hexdigest()=='{artifact.candidate_sha256}'" + "raise SystemExit('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 = "/opt/agentseek/runtime/agentseek-api-0.3.0.whl[embedded]" @@ -2020,6 +2087,7 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: "pip", "install", "--no-cache-dir", + "--force-reinstall", "--constraint", "/opt/agentseek/runtime-constraints.txt", runtime_operand, @@ -2033,18 +2101,30 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: "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();" - f"assert raw==canonical and hashlib.sha256(raw).hexdigest()=='{manifest_sha256}'" + "raise SystemExit('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;" - f"assert importlib.metadata.version('agentseek-api')=='{artifact.version}';" + "distribution=importlib.metadata.distribution('agentseek-api');" + "raise SystemExit('runtime distribution version mismatch') if " + f"distribution.version!='{artifact.version}' else None;" "module=pathlib.Path(agentseek_api.cli.__file__).resolve();" + "files=distribution.files;" + "raise SystemExit('runtime distribution file inventory missing') if " + "files is None else None;" + "owned={pathlib.Path(distribution.locate_file(item)).resolve() for item in files};" + "raise SystemExit('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'}};" - "assert roots and any(module.is_relative_to(root) for root in roots);" - "assert sys.version_info[:2]>=(3,12)" + "raise SystemExit('runtime module is outside site packages') if " + "not roots or not any(module.is_relative_to(root) for root in roots) else None;" + "raise SystemExit('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( @@ -2455,6 +2535,7 @@ def write_output(path: Path, data: bytes) -> None: dockerfile=dockerfile, manifest=manifest, inventory=inventory, + plan_fingerprint=container_build_plan_fingerprint(plan), ) except Exception: root_is_original = _directory_identity_matches(root, root_identity) @@ -2581,6 +2662,7 @@ def create_deterministic_context_archive( "StoreTtlManifestV1", "StructuredGraphV1", "candidate_runtime_artifact", + "container_build_plan_fingerprint", "create_deterministic_context_archive", "interpret_host_runtime_policy", "interpret_manifest_runtime_policy", diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 67376aa..37a7196 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -16,7 +16,11 @@ from typing import Protocol from agentseek_api.container_policy import select_compose_payload -from agentseek_api.container_build import ContainerBuildBundle, ContainerBuildPlan +from agentseek_api.container_build import ( + ContainerBuildBundle, + ContainerBuildPlan, + container_build_plan_fingerprint, +) from agentseek_api.environment import ContainerPolicyError DEFAULT_CONTROL_QUERY_TIMEOUT_SECONDS = 10.0 @@ -262,6 +266,8 @@ def build_image_invocation( ) -> 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", "buildx", "build", "--load", "--file", "Dockerfile"] @@ -495,7 +501,9 @@ def require_supported_buildx( 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.") + raise DockerRuntimeError( + f"Docker Buildx {required} or newer is required for BuildKit secrets." + ) inspect_query = build_docker_query_invocation( argv=("docker", "buildx", "inspect"), docker_control=docker_control, diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index afd4220..f29d4ef 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -2196,7 +2196,9 @@ def test_dockerfile_command_rejects_unsupported_image_distro(tmp_path: Path) -> 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" @@ -2211,19 +2213,20 @@ 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"], + ["dockerfile", "--config", str(config_path), str(dockerfile_path)], cwd=tmp_path, - stderr=stderr, ) - 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( +def test_dockerfile_command_allows_explicit_python_runtime_base_image( tmp_path: Path, ) -> None: from agentseek_api.cli import main @@ -2240,6 +2243,33 @@ def test_dockerfile_command_rejects_unknown_non_debian_base_image( """.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( @@ -2249,7 +2279,7 @@ def test_dockerfile_command_rejects_unknown_non_debian_base_image( ) 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( @@ -2943,14 +2973,13 @@ def test_up_command_uses_base_image_override_when_building(tmp_path: Path) -> No assert "FROM python:3.13-slim-bookworm" in dockerfile -def test_up_command_rejects_non_apt_base_image_before_docker_build( +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( [ @@ -2958,14 +2987,18 @@ def test_up_command_rejects_non_apt_base_image_before_docker_build( "--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( diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index bd11820..7369aef 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -4,6 +4,8 @@ import io import json import os +import subprocess +import sys import tarfile import tempfile import zipfile @@ -40,6 +42,32 @@ ) +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_dockerfile_uses_manifest_labels_and_buildkit_pip_secret( tmp_path: Path, ) -> None: @@ -245,6 +273,119 @@ def test_renderer_preserves_plan_fields_and_authoritative_order(tmp_path: Path) 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 for operand in argv) + ) + + assert "--force-reinstall" in runtime_install + + +@pytest.mark.parametrize("candidate", [False, True]) +def test_runtime_verifier_rejects_module_not_owned_by_distribution_under_optimize( + tmp_path: Path, candidate: bool +) -> None: + plan = ( + _candidate_build_plan(tmp_path) if candidate else build_plan_fixture(tmp_path) + ) + script = _generated_python_check( + render_build_dockerfile(plan).decode(), "importlib.metadata" + ) + site_packages = tmp_path / "site-packages" + module = site_packages / "agentseek_api" / "cli.py" + module.parent.mkdir(parents=True) + module.write_text("", encoding="utf-8") + wrapper = "\n".join( + ( + "import agentseek_api.cli,importlib.metadata,pathlib,sysconfig", + f"root=pathlib.Path({str(site_packages)!r})", + f"agentseek_api.cli.__file__={str(module)!r}", + "class D:", + " version='0.3.0'", + " files=(importlib.metadata.PackagePath('agentseek_api/not-cli.py'),)", + " def locate_file(self,item): return root/item", + "importlib.metadata.distribution=lambda name:D()", + "importlib.metadata.version=lambda name:'0.3.0'", + "sysconfig.get_paths=lambda:{'purelib':str(root),'platlib':str(root)}", + f"exec({script!r})", + ) + ) + + completed = subprocess.run( + [sys.executable, "-O", "-c", wrapper], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode != 0 + + +def test_manifest_verifier_rejects_wrong_hash_under_python_optimize( + tmp_path: Path, +) -> None: + script = _generated_python_check( + render_build_dockerfile(build_plan_fixture(tmp_path)).decode(), + "manifest.v1.json", + ) + manifest = tmp_path / "manifest.v1.json" + manifest.write_text("{}\n", encoding="utf-8") + wrapper = ( + "import pathlib;OriginalPath=pathlib.Path;" + f"actual=OriginalPath({str(manifest)!r});" + "pathlib.Path=lambda value: actual if value=='/opt/agentseek/manifest.v1.json' " + "else OriginalPath(value);" + f"exec({script!r})" + ) + + completed = subprocess.run( + [sys.executable, "-O", "-c", wrapper], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode != 0 + + +def test_candidate_hash_verifier_rejects_wrong_bytes_under_python_optimize( + tmp_path: Path, +) -> None: + script = _generated_python_check( + render_build_dockerfile(_candidate_build_plan(tmp_path)).decode(), + "agentseek-api-0.3.0.whl", + ) + candidate = tmp_path / "wrong.whl" + candidate.write_bytes(b"wrong-candidate") + wrapper = ( + "import pathlib;OriginalPath=pathlib.Path;" + f"actual=OriginalPath({str(candidate)!r});" + "pathlib.Path=lambda value: actual if value==" + "'/opt/agentseek/runtime/agentseek-api-0.3.0.whl' else OriginalPath(value);" + f"exec({script!r})" + ) + + completed = subprocess.run( + [sys.executable, "-O", "-c", wrapper], + capture_output=True, + check=False, + text=True, + ) + + assert completed.returncode != 0 + + def test_renderer_json_escapes_install_operands_and_candidate_source( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index b98e4ad..4ad2413 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -1,7 +1,11 @@ from __future__ import annotations +import hashlib import json +import os import subprocess +import uuid +import zipfile from dataclasses import replace from pathlib import Path @@ -32,7 +36,10 @@ 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 @@ -98,6 +105,37 @@ def test_dockerfile_and_build_omit_pip_secret_when_not_configured( 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_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] @@ -175,6 +213,36 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: assert all(call.argv[:3] != ("docker", "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( + 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( @@ -220,6 +288,111 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: ] +@pytest.mark.docker +def test_buildx_secret_mount_consumes_canary_without_disclosure( + tmp_path: Path, +) -> 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 + canary = f"agentseek-buildx-secret-{uuid.uuid4().hex}" + plan.pip_config_file.write_text(canary, encoding="utf-8") + plan = plan_container_image(config_path=plan.config_path) + digest = hashlib.sha256(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;" + "data=pathlib.Path('/run/secrets/pip_config').read_bytes();" + f"raise SystemExit(1) if hashlib.sha256(data).hexdigest()!='{digest}' else None" + ), + ] + ) + + "\n" + ).encode() + bundle = materialize_build_bundle( + plan, + dockerfile_bytes=dockerfile, + output_root=tmp_path / "secret-smoke-bundle", + ) + 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( + 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_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, combined.decode(errors="replace") + history = subprocess.run( + ["docker", "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 + assert canary.encode() not in combined + history.stdout + history.stderr + assert canary.encode() not in invocation.stdin_bytes + assert canary not in invocation.argv + assert canary not in repr(invocation) + finally: + subprocess.run( + ["docker", "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": "$", From 1b62b044a9f76bb54f462670a8c816017e48f228 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 20:37:42 +0800 Subject: [PATCH 15/42] fix: allow successful image verification --- src/agentseek_api/container_build.py | 18 +-- tests/unit/test_container_build.py | 168 ++++++++++++++++----------- tests/unit/test_docker_runtime.py | 35 +++--- 3 files changed, 129 insertions(+), 92 deletions(-) diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 419537d..b6a07b8 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -2069,9 +2069,9 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: "The candidate runtime artifact selection is invalid." ) candidate_check = ( - "import hashlib,pathlib;" + "import hashlib,pathlib,sys;" "p=pathlib.Path('/opt/agentseek/runtime/agentseek-api-0.3.0.whl');" - "raise SystemExit('candidate runtime hash mismatch') if " + "sys.exit('candidate runtime hash mismatch') if " f"hashlib.sha256(p.read_bytes()).hexdigest()!='{artifact.candidate_sha256}' " "else None" ) @@ -2097,11 +2097,11 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: ) lines.append("COPY manifest.v1.json /opt/agentseek/manifest.v1.json") manifest_check = ( - "import hashlib,json,pathlib;" + "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();" - "raise SystemExit('runtime manifest integrity mismatch') if " + "sys.exit('runtime manifest integrity mismatch') if " f"raw!=canonical or hashlib.sha256(raw).hexdigest()!='{manifest_sha256}' " "else None" ) @@ -2110,20 +2110,20 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: runtime_check = ( "import importlib.metadata,pathlib,sys,sysconfig,agentseek_api.cli;" "distribution=importlib.metadata.distribution('agentseek-api');" - "raise SystemExit('runtime distribution version mismatch') if " + "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;" - "raise SystemExit('runtime distribution file inventory missing') if " + "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};" - "raise SystemExit('runtime module is not owned by distribution') if " + "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'}};" - "raise SystemExit('runtime module is outside site packages') if " + "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;" - "raise SystemExit('Python 3.12 or newer is required') if " + "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))) diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 7369aef..77c19e8 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -292,98 +292,126 @@ def test_exact_runtime_install_forces_selected_artifact_replacement( assert "--force-reinstall" in runtime_install -@pytest.mark.parametrize("candidate", [False, True]) -def test_runtime_verifier_rejects_module_not_owned_by_distribution_under_optimize( - tmp_path: Path, candidate: bool -) -> None: - plan = ( - _candidate_build_plan(tmp_path) if candidate else build_plan_fixture(tmp_path) - ) - script = _generated_python_check( - render_build_dockerfile(plan).decode(), "importlib.metadata" - ) - site_packages = tmp_path / "site-packages" - module = site_packages / "agentseek_api" / "cli.py" - module.parent.mkdir(parents=True) - module.write_text("", encoding="utf-8") - wrapper = "\n".join( - ( - "import agentseek_api.cli,importlib.metadata,pathlib,sysconfig", - f"root=pathlib.Path({str(site_packages)!r})", - f"agentseek_api.cli.__file__={str(module)!r}", - "class D:", - " version='0.3.0'", - " files=(importlib.metadata.PackagePath('agentseek_api/not-cli.py'),)", - " def locate_file(self,item): return root/item", - "importlib.metadata.distribution=lambda name:D()", - "importlib.metadata.version=lambda name:'0.3.0'", - "sysconfig.get_paths=lambda:{'purelib':str(root),'platlib':str(root)}", - f"exec({script!r})", - ) - ) - - completed = subprocess.run( - [sys.executable, "-O", "-c", wrapper], +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, ) - assert completed.returncode != 0 +@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.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.whl' else OriginalPath(value)" + ) + + completed = _run_generated_check(script, setup) + + assert (completed.returncode == 0) is valid, completed.stderr -def test_manifest_verifier_rejects_wrong_hash_under_python_optimize( - tmp_path: Path, + +_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 / "manifest.v1.json" - manifest.write_text("{}\n", encoding="utf-8") - wrapper = ( - "import pathlib;OriginalPath=pathlib.Path;" - f"actual=OriginalPath({str(manifest)!r});" - "pathlib.Path=lambda value: actual if value=='/opt/agentseek/manifest.v1.json' " - "else OriginalPath(value);" - f"exec({script!r})" + 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 = subprocess.run( - [sys.executable, "-O", "-c", wrapper], - capture_output=True, - check=False, - text=True, - ) + completed = _run_generated_check(script, setup) - assert completed.returncode != 0 + assert (completed.returncode == 0) is valid, completed.stderr -def test_candidate_hash_verifier_rejects_wrong_bytes_under_python_optimize( - tmp_path: Path, +@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(_candidate_build_plan(tmp_path)).decode(), - "agentseek-api-0.3.0.whl", - ) - candidate = tmp_path / "wrong.whl" - candidate.write_bytes(b"wrong-candidate") - wrapper = ( - "import pathlib;OriginalPath=pathlib.Path;" - f"actual=OriginalPath({str(candidate)!r});" - "pathlib.Path=lambda value: actual if value==" - "'/opt/agentseek/runtime/agentseek-api-0.3.0.whl' else OriginalPath(value);" - f"exec({script!r})" + render_build_dockerfile(build_plan_fixture(tmp_path)).decode(), + "importlib.metadata", ) - - completed = subprocess.run( - [sys.executable, "-O", "-c", wrapper], - capture_output=True, - check=False, - text=True, + 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}", + ) ) - assert completed.returncode != 0 + completed = _run_generated_check(script, setup) + + assert (completed.returncode == 0) is valid, completed.stderr def test_renderer_json_escapes_install_operands_and_candidate_source( diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 4ad2413..567f97f 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -289,17 +289,23 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: @pytest.mark.docker +@pytest.mark.parametrize("valid_secret", [True, False]) def test_buildx_secret_mount_consumes_canary_without_disclosure( - tmp_path: Path, + 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 - canary = f"agentseek-buildx-secret-{uuid.uuid4().hex}" - plan.pip_config_file.write_text(canary, encoding="utf-8") + 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(canary.encode()).hexdigest() + digest = hashlib.sha256(expected_canary.encode()).hexdigest() dockerfile = ( "# syntax=docker/dockerfile:1.7\n" "FROM python:3.12-alpine\n" @@ -309,9 +315,9 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( "python", "-c", ( - "import hashlib,pathlib;" + "import hashlib,pathlib,sys;" "data=pathlib.Path('/run/secrets/pip_config').read_bytes();" - f"raise SystemExit(1) if hashlib.sha256(data).hexdigest()!='{digest}' else None" + f"sys.exit(1) if hashlib.sha256(data).hexdigest()!='{digest}' else None" ), ] ) @@ -320,7 +326,7 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( bundle = materialize_build_bundle( plan, dockerfile_bytes=dockerfile, - output_root=tmp_path / "secret-smoke-bundle", + output_root=tmp_path / f"secret-smoke-bundle-{valid_secret}", ) allowed = { "PATH", @@ -364,7 +370,9 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( timeout=240, ) combined = completed.stdout + completed.stderr - assert completed.returncode == 0, combined.decode(errors="replace") + assert (completed.returncode == 0) is valid_secret, combined.decode( + errors="replace" + ) history = subprocess.run( ["docker", "image", "history", "--no-trunc", tag], cwd=tmp_path, @@ -375,11 +383,12 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( shell=False, timeout=30, ) - assert history.returncode == 0 - assert canary.encode() not in combined + history.stdout + history.stderr - assert canary.encode() not in invocation.stdin_bytes - assert canary not in invocation.argv - assert canary not in repr(invocation) + 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", "image", "rm", "--force", tag], From 4f42371138324f770213cb4c03561d7371757cb0 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 20:54:53 +0800 Subject: [PATCH 16/42] feat: enforce preloaded container runtime contract --- src/agentseek_api/cli.py | 181 +++++++++++-- src/agentseek_api/container_build.py | 171 +++++++++++- src/agentseek_api/docker_runtime.py | 87 +++++++ src/agentseek_api/environment.py | 15 ++ tests/container_plan_helpers.py | 20 ++ tests/unit/test_cli.py | 373 ++++++++++++++++++++++++++- tests/unit/test_container_build.py | 244 +++++++++++++++++- tests/unit/test_docker_runtime.py | 128 +++++++++ 8 files changed, 1173 insertions(+), 46 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index c21f804..d80c4c7 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -1,6 +1,7 @@ from __future__ import annotations import argparse +import importlib.metadata import json import os import signal @@ -28,6 +29,7 @@ ContainerBuildError, FinalAuthSelection, RuntimeArtifactV1, + load_container_runtime_manifest_v1, materialize_build_bundle, plan_container_image, plan_generated_up_auth, @@ -45,6 +47,7 @@ build_docker_query_invocation, build_docker_run_invocation, encode_compose_environment, + inspect_image_contract, require_supported_compose, require_supported_buildx, ) @@ -53,7 +56,10 @@ CommandDerivedAssignment, ContainerPolicyError, EnvironmentPlan, + EnvironmentMode, EnvironmentTarget, + PRELOADED_V1_POLICY, + ResolvedEnvironment, resolve_environment, ) from agentseek_api.process_supervisor import ( @@ -98,6 +104,7 @@ "build_uvicorn_command", "create_parser", "main", + "resolve_runtime_for_mode", "register_subcommands", "run_namespace", ] @@ -455,6 +462,81 @@ def build_runtime_env( 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, @@ -835,8 +917,15 @@ def _terminate_child(_signum, _frame) -> None: 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) + 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, @@ -854,14 +943,15 @@ 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 = _resolve_host_environment_plan( - build_host_environment_plan( - config_path=config_path, + 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, @@ -884,16 +974,30 @@ 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) + 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_worker_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) + 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(), env=env, cwd=str(cwd)) @@ -1117,8 +1221,15 @@ def _execute_up_command( environment_plan, selection=selection ) + compose_path: Path | None = None + if args.docker_compose: + compose_path = _resolve_path(args.docker_compose, cwd=cwd) + if not compose_path.exists(): + raise CliError(f"Docker compose file '{compose_path}' does not exist.") + generated_plan = None generated_dockerfile_bytes: bytes | None = None + custom_image_contract = None if image: if ( final_auth is not None @@ -1128,6 +1239,14 @@ def _execute_up_command( raise CliError( "Custom-image auth cannot reference a host file; bake the module into the image and use an importable package reference." ) + custom_image_contract = inspect_image_contract( + image, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) + application_payload = dict(application_payload) + application_payload["AGENTSEEK_GRAPHS"] = custom_image_contract.manifest_path else: _load_cli_config(config_path) generated_plan = plan_container_image( @@ -1145,13 +1264,9 @@ def _execute_up_command( application_payload["AUTH_MODULE_PATH"] = auth_patch.value generated_dockerfile_bytes = render_build_dockerfile(generated_plan) - compose_path: Path | None = None compose_payload: dict[str, str] = {} encoded_compose: bytes | None = None - if args.docker_compose: - compose_path = _resolve_path(args.docker_compose, cwd=cwd) - if not compose_path.exists(): - raise CliError(f"Docker compose file '{compose_path}' does not exist.") + if compose_path is not None: compose_payload = dict( select_compose_payload( application_payload=application_payload, @@ -1253,7 +1368,11 @@ def _execute_up_command( image=image, docker_control=docker_control, application_payload=application_payload, - container_argv=(), + container_argv=( + () + if custom_image_contract is None + else custom_image_contract.container_argv + ), cwd=cwd, ) run_exit_code = process_transport(run_invocation).returncode @@ -1287,13 +1406,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") diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index b6a07b8..fbb440a 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -860,6 +860,32 @@ def _optional_number( return value +def _validate_preloaded_reference(reference: str, *, location: str) -> None: + if reference == "": + 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(rf"{identifier}(?:\.{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, *, @@ -867,6 +893,7 @@ def _parse_graphs( 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.") @@ -933,6 +960,11 @@ def _parse_graphs( 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 @@ -985,6 +1017,7 @@ def _parse_store( project_root: Path, selected: dict[str, SelectedSource], excluded: frozenset[Path], + preloaded: bool = False, ) -> StoreManifestV1 | None: if raw is None: return None @@ -1022,17 +1055,22 @@ def _parse_store( 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): - 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}" + 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( @@ -1096,6 +1134,7 @@ def _parse_http( project_root: Path, selected: dict[str, SelectedSource], excluded: frozenset[Path], + preloaded: bool = False, ) -> HttpManifestV1 | None: if raw is None: return None @@ -1105,7 +1144,9 @@ def _parse_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 _is_path_reference(app): + if isinstance(app, str) and preloaded: + _validate_preloaded_reference(app, location="http.app") + elif isinstance(app, str) and _is_path_reference(app): located = _module_file(app, base=reference_base) if located is not None: path, symbol = located @@ -1288,6 +1329,110 @@ def _parse_auth(raw: object) -> tuple[AuthPolicyManifestV1 | None, str | None]: 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, + ) + auth, auth_path = _parse_auth(document.get("auth")) + if auth_path is not None: + raise ContainerBuildError("Runtime manifest auth.path is not supported.") + 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 @@ -1907,6 +2052,7 @@ def plan_container_image( http=http, auth=auth, ) + manifest = _parse_container_runtime_manifest_v1_object(manifest.to_json_object()) return ContainerBuildPlan( base_image=base, python_version=raw_python, @@ -2666,6 +2812,7 @@ def create_deterministic_context_archive( "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", diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 37a7196..3645dff 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -22,6 +22,7 @@ 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) @@ -42,6 +43,10 @@ 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, ...] @@ -113,6 +118,12 @@ def __post_init__(self) -> None: 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: ... @@ -551,6 +562,78 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig ) +_PRELOADED_V1_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", +} + + +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, + *, + 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", + "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", @@ -558,6 +641,7 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "DockerRunInvocation", "DockerImageConfig", "DockerRuntimeError", + "ImageContractError", "LegacyRunnerAdapter", "IMAGE_COMPATIBILITY_FORMAT", "MINIMUM_BUILDX_VERSION", @@ -565,6 +649,7 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "ProcessInvocation", "ProcessResult", "ProcessTransport", + "PreloadedImageContract", "SubprocessTransport", "build_docker_control_invocation", "build_image_invocation", @@ -575,6 +660,8 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig "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", diff --git a/src/agentseek_api/environment.py b/src/agentseek_api/environment.py index 0083723..c7f1544 100644 --- a/src/agentseek_api/environment.py +++ b/src/agentseek_api/environment.py @@ -22,6 +22,11 @@ class EnvironmentTarget(StrEnum): COMPOSE_CONTROL_PLANE = "compose-control-plane" +class EnvironmentMode(StrEnum): + RESOLVE = "resolve" + PRELOADED_V1 = "preloaded-v1" + + class NameScope(StrEnum): NONE = "none" ALL = "all" @@ -75,6 +80,16 @@ class ResolutionPolicy: 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[ diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index 5f217c4..cfb06b7 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -35,6 +35,26 @@ def make_graph_project(root: Path) -> Path: 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" diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f29d4ef..f3d9dab 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -11,6 +11,7 @@ import zipfile from dataclasses import dataclass from pathlib import Path +from unittest.mock import Mock import pytest from pydantic import ValidationError @@ -24,6 +25,181 @@ ProcessResult, ) from agentseek_api.services.langgraph_service import LangGraphService +from tests.container_plan_helpers import write_sanitized_manifest + + +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( + ("inherited", "config", "env_file", "match"), + [ + ({}, None, None, "AGENTSEEK_GRAPHS"), + ({"AGENTSEEK_GRAPHS": "relative.json"}, None, None, "absolute"), + ( + {"AGENTSEEK_GRAPHS": "/image/manifest.v1.json"}, + "/other.json", + None, + "config", + ), + ({"AGENTSEEK_GRAPHS": "/image/manifest.v1.json"}, None, ".env", "env-file"), + ], +) +def test_preloaded_mode_rejects_ambiguous_sources_before_loading( + tmp_path: Path, + inherited: dict[str, str], + 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 + + with pytest.raises(CliError, match=match): + resolve_runtime_for_mode( + mode=EnvironmentMode.PRELOADED_V1, + config_path=config, + 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: @@ -130,6 +306,7 @@ 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: @@ -142,6 +319,18 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: returncode=0, stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", ) + if invocation.argv[:3] == ("docker", "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 invocation.argv[:3] == ("docker", "container", "inspect"): return_code = 0 if self.container_exists else 1 @@ -1927,6 +2116,45 @@ def test_up_with_custom_image_rejects_host_file_auth_references( 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 @@ -1956,6 +2184,106 @@ def test_up_with_custom_image_preserves_empty_or_package_auth( 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", + ), + ), + ( + ["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", + "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 run.argv[-len(expected_tail) :] == expected_tail + + +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: from agentseek_api.cli import build_runtime_env @@ -2354,13 +2682,13 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0].argv == ( + assert capture.calls[1].argv == ( "docker", "rm", "-f", "agentseek-up-8123", ) - assert capture.calls[1].argv[:9] == ( + assert capture.calls[2].argv[:9] == ( "docker", "run", "--detach", @@ -2371,9 +2699,9 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) "-p", "8123:2024", ) - assert capture.calls[1].argv[-1] == "agentseek:test" + assert capture.calls[2].argv[-9] == "agentseek:test" container_env = _application_environment(capture) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" + 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" @@ -2413,8 +2741,9 @@ def test_up_keeps_application_values_only_in_final_run_carrier( assert exit_code == 0 assert capture.calls is not None - assert len(capture.calls) == 2 - remove, run = capture.calls + 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 @@ -2446,7 +2775,7 @@ def test_up_container_existence_probe_is_bounded_and_control_only( assert exit_code == 0 assert capture.calls is not None - probe = capture.calls[0] + probe = capture.calls[1] assert isinstance(probe, ControlQueryInvocation) assert probe.timeout_seconds > 0 assert probe.argv == ( @@ -2457,7 +2786,7 @@ def test_up_container_existence_probe_is_bounded_and_control_only( ) assert probe.environment["DOCKER_HOST"] == "unix:///private/docker.sock" assert "OPENAI_API_KEY" not in probe.environment - assert capture.calls[1].environment["OPENAI_API_KEY"] == "application-canary" + assert capture.calls[2].environment["OPENAI_API_KEY"] == "application-canary" def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: @@ -2485,19 +2814,19 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: assert exit_code == 0 assert capture.calls is not None - assert capture.calls[0].argv == ( + assert capture.calls[1].argv == ( "docker", "compose", "version", "--short", ) - assert capture.calls[1].argv == ( + assert capture.calls[2].argv == ( "docker", "rm", "-f", "agentseek-up-8123", ) - compose_invocation = capture.calls[2] + compose_invocation = capture.calls[3] assert compose_invocation.argv[:3] == ( "docker", "compose", @@ -2510,7 +2839,7 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: "-d", "--force-recreate", ) - assert capture.calls[3].argv[-1] == "agentseek:test" + assert capture.calls[4].argv[-9] == "agentseek:test" for invocation in capture.calls: assert "AGENTSEEK_GRAPHS" not in invocation.environment or isinstance( invocation, DockerRunInvocation @@ -2707,6 +3036,14 @@ def test_up_command_rejects_existing_container_before_starting_compose_sidecars( assert exit_code == 2 assert capture.calls is not None assert [call.argv for call in capture.calls] == [ + ( + "docker", + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ), ("docker", "compose", "version", "--short"), ("docker", "container", "inspect", "agentseek-up-8123"), ] @@ -2910,7 +3247,7 @@ def test_up_command_prefers_agentseek_json_without_explicit_flag( assert exit_code == 0 assert capture.calls is not None container_env = _application_environment(capture) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/agentseek.json" + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" def test_up_command_does_not_pass_shell_runtime_env_into_container( @@ -3059,7 +3396,15 @@ def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) assert exit_code == 2 assert capture.calls is not None assert [call.argv for call in capture.calls] == [ - ("docker", "container", "inspect", "agentseek-up-8123") + ( + "docker", + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ), + ("docker", "container", "inspect", "agentseek-up-8123"), ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 77c19e8..84122e1 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -27,6 +27,7 @@ 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, @@ -39,9 +40,247 @@ 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) + + +@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("[") :]) @@ -1150,10 +1389,13 @@ def test_http_and_auth_policy_effect_matches_host_config( 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(manifest) + ) == interpret_manifest_runtime_policy(loaded) def test_selected_source_collision_rules_are_fail_closed(tmp_path: Path) -> None: diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 567f97f..bda8835 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -18,6 +18,7 @@ ControlQueryInvocation, IMAGE_COMPATIBILITY_FORMAT, DockerRuntimeError, + ImageContractError, LegacyRunnerAdapter, ProcessInvocation, ProcessResult, @@ -31,6 +32,8 @@ 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, @@ -1043,3 +1046,128 @@ def test_image_compatibility_parser_rejects_nonexact_private_output( 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", + 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", + "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, transport=transport, docker_control={}, cwd=tmp_path + ) + assert canary not in str(caught.value) From 6b49aad8625e8969fb8b2982f6b0bd279be79c02 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 21:13:21 +0800 Subject: [PATCH 17/42] fix: isolate preloaded runtime startup --- src/agentseek_api/cli.py | 75 +++++--- src/agentseek_api/container_build.py | 15 +- src/agentseek_api/runtime_entrypoint.py | 93 ++++++++++ .../integration/test_cli_runtime_processes.py | 173 ++++++++++++++++++ tests/unit/test_cli.py | 106 ++++++++++- tests/unit/test_container_build.py | 40 ++++ 6 files changed, 470 insertions(+), 32 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index d80c4c7..3faad04 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -4,6 +4,7 @@ import importlib.metadata import json import os +import re import signal import subprocess import sys @@ -697,16 +698,25 @@ def _resolve_application_container_payload( return payload, auth_selection -def _is_host_auth_reference(reference: str) -> bool: +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: + if parts is None or reference.count(":") != 1: return False - module_name, _ = parts - return ( - module_name.endswith(".py") - or module_name.startswith(".") - or "/" in module_name - or "\\" in module_name + 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) ) @@ -714,11 +724,21 @@ 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 build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: - command = [ +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", @@ -732,20 +752,16 @@ 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", ] @@ -930,6 +946,7 @@ def _execute_runtime_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)) @@ -957,6 +974,7 @@ def _execute_dev_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)) @@ -983,7 +1001,12 @@ def _execute_worker_command( cwd=cwd, ).values ) - return runner(build_worker_command(), env=env, cwd=str(cwd)) + 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( @@ -998,7 +1021,13 @@ def _execute_scheduler_command( cwd=cwd, ).values ) - return runner(build_scheduler_command(), env=env, cwd=str(cwd)) + 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]: @@ -1231,11 +1260,7 @@ def _execute_up_command( generated_dockerfile_bytes: bytes | None = None custom_image_contract = None if image: - if ( - final_auth is not None - and final_auth.value - and _is_host_auth_reference(final_auth.value) - ): + 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." ) diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index fbb440a..3d9932d 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -860,8 +860,10 @@ def _optional_number( return value -def _validate_preloaded_reference(reference: str, *, location: str) -> None: - if reference == "": +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." @@ -871,7 +873,7 @@ def _validate_preloaded_reference(reference: str, *, location: str) -> None: identifier = r"[A-Za-z_][A-Za-z0-9_]*" if not re.fullmatch( rf"{identifier}(?:\.{identifier})*", module - ) or not re.fullmatch(rf"{identifier}(?:\.{identifier})*", symbol): + ) or not re.fullmatch(identifier, symbol): raise ContainerBuildError(error) return path = PurePosixPath(module) @@ -1145,7 +1147,7 @@ def _parse_http( 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") + _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: @@ -1405,9 +1407,10 @@ def _parse_container_runtime_manifest_v1_object( excluded=frozenset(), preloaded=True, ) - auth, auth_path = _parse_auth(document.get("auth")) - if auth_path is not None: + 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( diff --git a/src/agentseek_api/runtime_entrypoint.py b/src/agentseek_api/runtime_entrypoint.py index f9ae427..cd5972d 100644 --- a/src/agentseek_api/runtime_entrypoint.py +++ b/src/agentseek_api/runtime_entrypoint.py @@ -1,9 +1,14 @@ from __future__ import annotations import importlib +import importlib.metadata +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 @@ -15,6 +20,83 @@ } +class RuntimeBootstrapError(RuntimeError): + pass + + +def _owned_runtime_locations() -> tuple[frozenset[Path], tuple[Path, ...]]: + try: + distribution = importlib.metadata.distribution("agentseek-api") + except importlib.metadata.PackageNotFoundError as exc: + raise RuntimeBootstrapError from exc + if distribution.version != "0.3.0": + raise RuntimeBootstrapError + + owned_files = frozenset( + distribution.locate_file(item).resolve() + for item in (distribution.files or ()) + if item.parts[:1] == ("agentseek_api",) + ) + 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" / "agentseek_api", + checkout / "agentseek_api", + ) + 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 _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 path in owned_files or any( + path == root or root in path.parents for root in editable_roots + ): + continue + 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 reversed(manifest.dependencies): + if dependency not in sys.path: + sys.path.insert(0, dependency) + + def _format_settings_validation_error(exc: ValidationError) -> str: fields = sorted( { @@ -27,6 +109,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 +123,18 @@ def main(argv: Sequence[str] | None = None) -> int: sys.argv = [target_module, *target_argv] try: try: + if preloaded: + _require_distribution_owned_runtime() + _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/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_cli.py b/tests/unit/test_cli.py index f3d9dab..2b02194 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,8 +6,10 @@ import io import json import signal +import sys import tarfile import tomllib +import types import zipfile from dataclasses import dataclass from pathlib import Path @@ -959,6 +961,97 @@ 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.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", + "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 == ["/deps/agent/application", "trusted-site-packages"] + + def test_dev_command_accepts_langgraph_cli_flags_and_env_file( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -2083,7 +2176,18 @@ def test_generated_up_uses_final_auth_selection_and_sanitized_build_stdin( assert container_env["OPENAI_API_KEY"] == "provider-secret-canary" -@pytest.mark.parametrize("reference", ["auth.py:auth", "/host/auth.py:auth"]) +@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: diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 84122e1..0f6ff19 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -177,6 +177,46 @@ def test_canonical_manifest_loader_rejects_nonimportable_package_references( 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", [ From 13cbad088736717b8eccade5df3f8a3706ad15a3 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 21:25:22 +0800 Subject: [PATCH 18/42] fix: isolate image bootstrap commands --- src/agentseek_api/cli.py | 5 ++ src/agentseek_api/container_build.py | 1 + src/agentseek_api/docker_runtime.py | 12 ++--- src/agentseek_api/runtime_entrypoint.py | 71 +++++++++++++++++++++---- tests/unit/test_cli.py | 63 ++++++++++++++++++++-- tests/unit/test_container_build.py | 35 ++++++++++++ tests/unit/test_docker_runtime.py | 62 ++++++++++++++++++++- 7 files changed, 226 insertions(+), 23 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 3faad04..0505a8b 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -1387,6 +1387,11 @@ def _execute_up_command( "host.docker.internal:host-gateway", "-p", f"{args.port}:{DEFAULT_API_PORT}", + *( + () + if custom_image_contract is None + else ("--entrypoint", custom_image_contract.entrypoint_override) + ), ) run_invocation = build_docker_run_invocation( base_argv=base_argv, diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 3d9932d..60100c8 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -2287,6 +2287,7 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: + json.dumps( [ "python", + "-I", "-m", "agentseek_api.cli", "serve", diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 3645dff..32195e1 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -121,6 +121,7 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class PreloadedImageContract: manifest_path: str + entrypoint_override: str container_argv: tuple[str, ...] @@ -617,20 +618,19 @@ def inspect_image_contract( "--port", str(DEFAULT_API_PORT), ) - if config.entrypoint in (None, ()): - command = ("agentseek-api", *serve) - elif config.entrypoint in ( + if config.entrypoint not in ( + None, + (), ("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, + entrypoint_override="python", + container_argv=("-I", "-m", "agentseek_api.cli", *serve), ) diff --git a/src/agentseek_api/runtime_entrypoint.py b/src/agentseek_api/runtime_entrypoint.py index cd5972d..a7f3c1e 100644 --- a/src/agentseek_api/runtime_entrypoint.py +++ b/src/agentseek_api/runtime_entrypoint.py @@ -2,6 +2,7 @@ import importlib import importlib.metadata +import importlib.util import json import os import runpy @@ -19,23 +20,34 @@ "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_runtime_locations() -> tuple[frozenset[Path], tuple[Path, ...]]: +def _owned_distribution_locations( + distribution_name: str, + package_name: str, + *, + expected_version: str | None, +) -> tuple[frozenset[Path], tuple[Path, ...]]: try: - distribution = importlib.metadata.distribution("agentseek-api") + distribution = importlib.metadata.distribution(distribution_name) except importlib.metadata.PackageNotFoundError as exc: raise RuntimeBootstrapError from exc - if distribution.version != "0.3.0": + 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] == ("agentseek_api",) + if item.parts[:1] == (package_name,) ) editable_roots: tuple[Path, ...] = () try: @@ -49,8 +61,8 @@ def _owned_runtime_locations() -> tuple[frozenset[Path], tuple[Path, ...]]: editable_roots = tuple( candidate.resolve() for candidate in ( - checkout / "src" / "agentseek_api", - checkout / "agentseek_api", + checkout / "src" / package_name, + checkout / package_name, ) if candidate.is_dir() ) @@ -61,6 +73,20 @@ def _owned_runtime_locations() -> tuple[frozenset[Path], tuple[Path, ...]]: 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()): @@ -72,13 +98,35 @@ def _require_distribution_owned_runtime() -> None: if module_file is None: continue path = Path(module_file).resolve() - if path in owned_files or any( - path == root or root in path.parents for root in editable_roots - ): + 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, @@ -92,9 +140,9 @@ def _activate_preloaded_runtime() -> None: manifest = load_container_runtime_manifest_v1(Path(manifest_value)) except ContainerBuildError as exc: raise RuntimeBootstrapError from exc - for dependency in reversed(manifest.dependencies): + for dependency in manifest.dependencies: if dependency not in sys.path: - sys.path.insert(0, dependency) + sys.path.append(dependency) def _format_settings_validation_error(exc: ValidationError) -> str: @@ -125,6 +173,7 @@ def main(argv: Sequence[str] | None = None) -> int: try: if preloaded: _require_distribution_owned_runtime() + _require_trusted_target_module(target_name) _activate_preloaded_runtime() importlib.import_module("agentseek_api.settings") if preloaded: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2b02194..5911c96 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1014,6 +1014,12 @@ def test_preloaded_runtime_bootstrap_orders_trust_manifest_and_settings( "_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", @@ -1028,6 +1034,7 @@ def test_preloaded_runtime_bootstrap_orders_trust_manifest_and_settings( assert runtime_entrypoint.main(["--preloaded-v1", "worker"]) == 0 assert events == [ "trust", + "target:worker", "manifest", "import:agentseek_api.settings", "trust", @@ -1049,7 +1056,41 @@ def test_preloaded_runtime_activates_only_canonical_manifest_roots( _activate_preloaded_runtime() - assert sys.path == ["/deps/agent/application", "trusted-site-packages"] + 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( @@ -1997,7 +2038,7 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( assert "agentseek-api[embedded]==0.3.0" in generated assert "org.agentseek.environment-contract=preloaded-v1" in generated assert ( - 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--environment-mode", ' + '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() @@ -2294,7 +2335,9 @@ def test_up_with_custom_image_preserves_empty_or_package_auth( ( [], ( - "agentseek-api", + "-I", + "-m", + "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -2307,6 +2350,9 @@ def test_up_with_custom_image_preserves_empty_or_package_auth( ( ["python", "-m", "agentseek_api.cli"], ( + "-I", + "-m", + "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -2361,6 +2407,13 @@ def test_up_custom_image_inspects_contract_and_runs_explicit_preloaded_mode( 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" + image_index = run.argv.index("agentseek:test") + override_index = run.argv.index("--entrypoint") + assert run.argv[override_index : override_index + 2] == ( + "--entrypoint", + "python", + ) + assert override_index < image_index assert run.argv[-len(expected_tail) :] == expected_tail @@ -2803,7 +2856,7 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) "-p", "8123:2024", ) - assert capture.calls[2].argv[-9] == "agentseek:test" + 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" @@ -2943,7 +2996,7 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: "-d", "--force-recreate", ) - assert capture.calls[4].argv[-9] == "agentseek:test" + assert "agentseek:test" in capture.calls[4].argv for invocation in capture.calls: assert "AGENTSEEK_GRAPHS" not in invocation.environment or isinstance( invocation, DockerRunInvocation diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index 0f6ff19..ced16bc 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -390,6 +390,41 @@ def test_dockerfile_uses_manifest_labels_and_buildkit_pip_secret( 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: diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index bda8835..cb93b50 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -1077,7 +1077,9 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( [], ( - "agentseek-api", + "-I", + "-m", + "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -1090,6 +1092,9 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( ["agentseek-api"], ( + "-I", + "-m", + "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -1102,6 +1107,9 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( ["python", "-m", "agentseek_api.cli"], ( + "-I", + "-m", + "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -1146,9 +1154,61 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: ) assert ".Config.Env" not in " ".join(calls[0].argv) assert contract.manifest_path == "/opt/agentseek/manifest.v1.json" + assert contract.entrypoint_override == "python" assert contract.container_argv == expected_command +@pytest.mark.parametrize( + "entrypoint", + [[], ["agentseek-api"], ["python", "-m", "agentseek_api.cli"]], +) +def test_custom_image_override_ignores_hostile_workdir_and_pythonpath( + tmp_path: Path, entrypoint: list[str] +) -> None: + contract = inspect_image_contract( + "agentseek:test", + transport=lambda _invocation: ProcessResult( + returncode=0, + stdout=json.dumps( + [_compatible_image_labels(), entrypoint, ["hostile-default"]] + ).encode(), + ), + docker_control={}, + cwd=tmp_path, + ) + module_index = contract.container_argv.index("agentseek_api.cli") + probe_command = [ + contract.entrypoint_override, + *contract.container_argv[: module_index + 1], + "version", + ] + 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", + ) + 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 + + @pytest.mark.parametrize( "entrypoint", ["agentseek-api", ["/bin/sh", "-c"], ["python", "agentseek_api/cli.py"]], From 3947c4fcbe82c3fe5156fced0fa36551d192b9d9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 21:33:46 +0800 Subject: [PATCH 19/42] fix: preserve safe custom image entrypoints --- src/agentseek_api/cli.py | 10 +- src/agentseek_api/docker_runtime.py | 12 +-- tests/unit/test_cli.py | 145 +++++++++++++++++++++++++--- tests/unit/test_docker_runtime.py | 62 +----------- 4 files changed, 144 insertions(+), 85 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 0505a8b..816a968 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -1289,6 +1289,11 @@ def _execute_up_command( application_payload["AUTH_MODULE_PATH"] = auth_patch.value 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: @@ -1387,11 +1392,6 @@ def _execute_up_command( "host.docker.internal:host-gateway", "-p", f"{args.port}:{DEFAULT_API_PORT}", - *( - () - if custom_image_contract is None - else ("--entrypoint", custom_image_contract.entrypoint_override) - ), ) run_invocation = build_docker_run_invocation( base_argv=base_argv, diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 32195e1..3645dff 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -121,7 +121,6 @@ def __post_init__(self) -> None: @dataclass(frozen=True) class PreloadedImageContract: manifest_path: str - entrypoint_override: str container_argv: tuple[str, ...] @@ -618,19 +617,20 @@ def inspect_image_contract( "--port", str(DEFAULT_API_PORT), ) - if config.entrypoint not in ( - None, - (), + 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, - entrypoint_override="python", - container_argv=("-I", "-m", "agentseek_api.cli", *serve), + container_argv=command, ) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 5911c96..0e4636d 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -5,7 +5,9 @@ import importlib import io import json +import os import signal +import subprocess import sys import tarfile import tomllib @@ -2335,9 +2337,19 @@ def test_up_with_custom_image_preserves_empty_or_package_auth( ( [], ( - "-I", - "-m", - "agentseek_api.cli", + "agentseek-api", + "serve", + "--environment-mode", + "preloaded-v1", + "--host", + "0.0.0.0", + "--port", + "2024", + ), + ), + ( + ["agentseek-api"], + ( "serve", "--environment-mode", "preloaded-v1", @@ -2350,9 +2362,6 @@ def test_up_with_custom_image_preserves_empty_or_package_auth( ( ["python", "-m", "agentseek_api.cli"], ( - "-I", - "-m", - "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -2407,16 +2416,126 @@ def test_up_custom_image_inspects_contract_and_runs_explicit_preloaded_mode( 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" - image_index = run.argv.index("agentseek:test") - override_index = run.argv.index("--entrypoint") - assert run.argv[override_index : override_index + 2] == ( - "--entrypoint", - "python", - ) - assert override_index < image_index + 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: diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index cb93b50..bda8835 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -1077,9 +1077,7 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( [], ( - "-I", - "-m", - "agentseek_api.cli", + "agentseek-api", "serve", "--environment-mode", "preloaded-v1", @@ -1092,9 +1090,6 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( ["agentseek-api"], ( - "-I", - "-m", - "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -1107,9 +1102,6 @@ def test_custom_image_requires_every_exact_preloaded_label(label: str) -> None: ( ["python", "-m", "agentseek_api.cli"], ( - "-I", - "-m", - "agentseek_api.cli", "serve", "--environment-mode", "preloaded-v1", @@ -1154,61 +1146,9 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: ) assert ".Config.Env" not in " ".join(calls[0].argv) assert contract.manifest_path == "/opt/agentseek/manifest.v1.json" - assert contract.entrypoint_override == "python" assert contract.container_argv == expected_command -@pytest.mark.parametrize( - "entrypoint", - [[], ["agentseek-api"], ["python", "-m", "agentseek_api.cli"]], -) -def test_custom_image_override_ignores_hostile_workdir_and_pythonpath( - tmp_path: Path, entrypoint: list[str] -) -> None: - contract = inspect_image_contract( - "agentseek:test", - transport=lambda _invocation: ProcessResult( - returncode=0, - stdout=json.dumps( - [_compatible_image_labels(), entrypoint, ["hostile-default"]] - ).encode(), - ), - docker_control={}, - cwd=tmp_path, - ) - module_index = contract.container_argv.index("agentseek_api.cli") - probe_command = [ - contract.entrypoint_override, - *contract.container_argv[: module_index + 1], - "version", - ] - 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", - ) - 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 - - @pytest.mark.parametrize( "entrypoint", ["agentseek-api", ["/bin/sh", "-c"], ["python", "agentseek_api/cli.py"]], From af287a316aa7a56944207870f478b9005066ded9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 21:51:04 +0800 Subject: [PATCH 20/42] fix: fail closed before container side effects --- src/agentseek_api/cli.py | 247 ++++++++++------- tests/unit/test_cli.py | 490 ++++++++++++++++++++++++++++++++- tests/unit/test_secure_temp.py | 5 + 3 files changed, 632 insertions(+), 110 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 816a968..458cfab 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -9,6 +9,7 @@ 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 @@ -1160,7 +1161,15 @@ def _execute_build_command( cwd=cwd, role=None, ) - with private_directory(prefix="agentseek-build-") as output_root: + docker_control = dict(docker_control_environment(environment_plan)) + 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, @@ -1169,20 +1178,17 @@ def _execute_build_command( invocation = build_image_invocation( bundle, plan=build_plan, - docker_control=docker_control_environment(environment_plan), + docker_control=docker_control, tag=args.tag, platform=args.platform, pull=args.pull, ) - try: - require_supported_buildx( - transport=process_transport, - docker_control=docker_control_environment(environment_plan), - cwd=cwd, - plan=build_plan, - ) - except DockerRuntimeError as exc: - raise CliError(str(exc)) from exc + require_supported_buildx( + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + plan=build_plan, + ) return process_transport(invocation).returncode @@ -1264,14 +1270,6 @@ def _execute_up_command( raise CliError( "Custom-image auth cannot reference a host file; bake the module into the image and use an importable package reference." ) - custom_image_contract = inspect_image_contract( - image, - transport=process_transport, - docker_control=docker_control, - cwd=cwd, - ) - application_payload = dict(application_payload) - application_payload["AGENTSEEK_GRAPHS"] = custom_image_contract.manifest_path else: _load_cli_config(config_path) generated_plan = plan_container_image( @@ -1304,22 +1302,43 @@ def _execute_up_command( docker_control=docker_control, ) ) - require_supported_compose( - transport=process_transport, - docker_control=docker_control, - cwd=cwd, - ) - sweep_expired_artifacts( - prefix="agentseek-compose-", - older_than_seconds=24 * 60 * 60, - ) encoded_compose = encode_compose_environment(compose_payload).encode("utf-8") - if not image: - assert generated_plan is not None - assert generated_dockerfile_bytes is not None - image = f"agentseek-up:{args.port}" - with private_directory(prefix="agentseek-build-") as output_root: + with ExitStack() as cleanup: + build_invocation = None + if image: + custom_image_contract = inspect_image_contract( + image, + 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, @@ -1332,87 +1351,113 @@ def _execute_up_command( tag=image, pull=args.pull, ) - try: - require_supported_buildx( - transport=process_transport, - docker_control=docker_control, - cwd=cwd, - plan=generated_plan, + + compose_env_path: Path | None = None + if compose_path is not None: + require_supported_compose( + 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, ) - except DockerRuntimeError as exc: - raise CliError(str(exc)) from exc + ) + + if build_invocation is not None: + assert generated_plan is not None + require_supported_buildx( + 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 + if build_exit_code != 0: + return build_exit_code + custom_image_contract = inspect_image_contract( + image, + transport=process_transport, + docker_control=docker_control, + cwd=cwd, + ) - container_name = _container_name_for_port(args.port) - if args.recreate: - remove_invocation = build_docker_control_invocation( - argv=("docker", "rm", "-f", container_name), - docker_control=docker_control, - cwd=cwd, - ) - process_transport(remove_invocation) - elif _container_exists( - container_name, - 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." - ) + container_name = _container_name_for_port(args.port) + remove_invocation = None + if args.recreate: + remove_invocation = build_docker_control_invocation( + argv=("docker", "rm", "-f", container_name), + docker_control=docker_control, + cwd=cwd, + ) - if compose_path is not None: - assert encoded_compose is not None - with private_artifact( - prefix="agentseek-compose-", - contents=encoded_compose, - ) as env_path: + compose_invocation = None + if compose_path is not None: + assert compose_env_path is not None compose_invocation = build_compose_invocation( compose_file=compose_path, - env_file=env_path, + env_file=compose_env_path, docker_control=docker_control, application_payload=application_payload, selected_names=selection.compose_env, cwd=cwd, recreate=args.recreate, ) - compose_exit_code = process_transport(compose_invocation).returncode - if compose_exit_code != 0: - return compose_exit_code - - base_argv = ( - "docker", - "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, - ) - 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 + + base_argv = ( + "docker", + "run", + "--detach", + "--name", + container_name, + "--add-host", + "host.docker.internal:host-gateway", + "-p", + f"{args.port}:{DEFAULT_API_PORT}", ) - return 0 + 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, + ) + + if remove_invocation is not None: + process_transport(remove_invocation) + elif _container_exists( + container_name, + 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: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 0e4636d..eeb4560 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -13,9 +13,9 @@ import tomllib import types import zipfile -from dataclasses import dataclass +from dataclasses import dataclass, field from pathlib import Path -from unittest.mock import Mock +from unittest.mock import Mock, patch import pytest from pydantic import ValidationError @@ -27,6 +27,7 @@ DockerRunInvocation, ProcessInvocation, ProcessResult, + ProcessTransport, ) from agentseek_api.services.langgraph_service import LangGraphService from tests.container_plan_helpers import write_sanitized_manifest @@ -343,6 +344,95 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: return ProcessResult(returncode=return_code) +@dataclass(frozen=True) +class BoundaryCall: + argv: tuple[str, ...] + 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(argv: tuple[str, ...]) -> bool: + if argv[:3] == ("docker", "image", "inspect"): + return True + if argv == ("docker", "compose", "version", "--short"): + return True + if argv in { + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + }: + return True + return argv[:2] == ("docker", "compose") and "config" in argv[2:] + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + argv = tuple(invocation.argv) + environment_items = tuple(sorted(invocation.environment.items())) + stdin_bytes = invocation.stdin_bytes + self.calls.append( + BoundaryCall( + argv=argv, + 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(argv), + _environment_items=environment_items, + ) + ) + if not isinstance(invocation, ControlQueryInvocation): + return ProcessResult(returncode=0) + if argv == ("docker", "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 argv == ("docker", "buildx", "version"): + return ProcessResult( + returncode=0, + stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", + ) + if argv == ("docker", "buildx", "inspect"): + return ProcessResult( + returncode=1 if self.failure_case == "unavailable_buildkit" else 0 + ) + if argv[:3] == ("docker", "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 argv[:3] == ("docker", "container", "inspect"): + return ProcessResult(returncode=1) + return ProcessResult(returncode=0) + + def _captured_image_build(capture: _ProcessCapture) -> BuildImageInvocation: assert capture.calls is not None return next( @@ -683,6 +773,386 @@ def _write_basic_manifest_config(root: Path) -> Path: return manifest_path +def invoke_failure_case( + command_path: str, + case: str, + *, + tmp_path: Path, + runner: BoundaryRunner, +) -> tuple[int, str, str]: + from agentseek_api.cli import main + + 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": + 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) + + 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() + + +@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 == [] + 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) + runner = BoundaryRunner() + + exit_code = main( + ["up", "--recreate"], + process_transport=runner, + cwd=tmp_path, + ) + + assert exit_code == 0 + argv = [call.argv for call in runner.calls] + assert argv[:5] == [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ( + "docker", + "buildx", + "build", + "--load", + "--file", + "Dockerfile", + "--pull", + "--tag", + "agentseek-up:8123", + "-", + ), + ( + "docker", + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek-up:8123", + ), + ("docker", "rm", "-f", "agentseek-up-8123"), + ] + assert len(argv) == 6 + assert argv[5][:9] == ( + "docker", + "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", + ) + + +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 + + 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 ArtifactBoundaryRunner(BoundaryRunner): + artifact_exists_during_build = False + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + if isinstance(invocation, BuildImageInvocation): + self.artifact_exists_during_build = any( + child.name.startswith("agentseek-compose-") + for child in private_root.iterdir() + ) + return super().__call__(invocation) + + runner = ArtifactBoundaryRunner() + exit_code = main( + [ + "up", + "--docker-compose", + str(compose_path), + "--recreate", + ], + process_transport=runner, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert runner.artifact_exists_during_build is True + 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 @@ -3366,13 +3836,14 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-", ) assert capture.calls[2].stdin_bytes is not None - assert capture.calls[3].argv == ( + assert capture.calls[3].argv[:3] == ("docker", "image", "inspect") + assert capture.calls[4].argv == ( "docker", "container", "inspect", "agentseek-up-8124", ) - assert capture.calls[4].argv[:9] == ( + assert capture.calls[5].argv[:9] == ( "docker", "run", "--detach", @@ -3383,8 +3854,8 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-p", "8124:2024", ) - assert capture.calls[4].argv[-1] == "agentseek-up:8124" - for invocation in capture.calls[:4]: + 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 @@ -3441,13 +3912,14 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( assert exit_code == 0 assert capture.calls is not None - assert capture.calls[3].argv == ( + assert capture.calls[3].argv[:3] == ("docker", "image", "inspect") + assert capture.calls[4].argv == ( "docker", "container", "inspect", "agentseek-up-8123", ) - assert capture.calls[4].argv[:9] == ( + assert capture.calls[5].argv[:9] == ( "docker", "run", "--detach", @@ -3458,7 +3930,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( "-p", "8123:2024", ) - assert capture.calls[4].argv[-1] == "agentseek-up:8123" + assert "agentseek-up:8123" in capture.calls[5].argv container_env = _application_environment(capture) assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" assert container_env["AUTH_MODULE_PATH"] == "/deps/agent/auth.py:backend" diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index 7def47a..c30bd2c 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -281,11 +281,15 @@ def test_sweep_removes_only_owned_private_old_regular_artifacts( 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( @@ -299,6 +303,7 @@ def test_sweep_removes_only_owned_private_old_regular_artifacts( assert not old_private.exists() assert old_public.exists() assert recent.exists() + assert other_product.exists() assert symlink.is_symlink() From ccc1ffebd04202bad4030aa8f60c0bfcf9b9aa9b Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 22:03:26 +0800 Subject: [PATCH 21/42] fix: sweep expired build directories safely --- src/agentseek_api/secure_temp.py | 32 +++++----- tests/unit/test_cli.py | 102 ++++++++++++++++++++++++++++++- tests/unit/test_secure_temp.py | 84 +++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 16 deletions(-) diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 309bfe4..93aa911 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -1001,22 +1001,26 @@ def sweep_expired_artifacts( or candidate.resolve(strict=True).parent != root.resolve(strict=True) ): continue - if os.name != "nt" and ( - metadata.st_uid != _current_uid() - or ( - stat.S_ISREG(metadata.st_mode) - and ( - stat.S_IMODE(metadata.st_mode) != _PRIVATE_FILE_MODE - or metadata.st_mode & (stat.S_IRWXG | stat.S_IRWXO) - ) + 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 ) - ): - continue - if ( # pragma: no cover - native Windows only - os.name == "nt" and stat.S_ISDIR(metadata.st_mode) - ): + 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=True + candidate, + metadata, + verify_windows_tree=os.name == "nt", ) elif not stat.S_ISREG(metadata.st_mode): continue diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index eeb4560..0526c1f 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -347,6 +347,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: @dataclass(frozen=True) class BoundaryCall: argv: tuple[str, ...] + invocation_type: type[ProcessInvocation] environment_names: frozenset[str] cwd: Path stdin_sha256: str | None @@ -361,7 +362,10 @@ class BoundaryRunner(ProcessTransport): calls: list[BoundaryCall] = field(default_factory=list) @staticmethod - def _is_read_only(argv: tuple[str, ...]) -> bool: + def _is_read_only(invocation: ProcessInvocation) -> bool: + if not isinstance(invocation, ControlQueryInvocation): + return False + argv = tuple(invocation.argv) if argv[:3] == ("docker", "image", "inspect"): return True if argv == ("docker", "compose", "version", "--short"): @@ -380,6 +384,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: self.calls.append( BoundaryCall( argv=argv, + invocation_type=type(invocation), environment_names=frozenset(invocation.environment), cwd=Path(invocation.cwd), stdin_sha256=( @@ -388,7 +393,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: 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(argv), + is_side_effecting=not self._is_read_only(invocation), _environment_items=environment_items, ) ) @@ -433,6 +438,55 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: return ProcessResult(returncode=0) +@pytest.mark.parametrize( + "argv", + [ + ("docker", "compose", "version", "--short"), + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ( + "docker", + "image", + "inspect", + "--format", + "fixture", + "agentseek:test", + ), + ("docker", "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", "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( @@ -938,6 +992,50 @@ def test_container_plan_failure_starts_no_workload( "unsafe_temp", }: assert runner.calls == [] + expected_control_calls = { + "unsupported_compose_version": [("docker", "compose", "version", "--short")], + "unavailable_buildkit": [ + ("docker", "buildx", "version"), + ("docker", "buildx", "inspect"), + ], + "missing_contract_label": [ + ( + "docker", + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + ], + "missing_manifest_label": [ + ( + "docker", + "image", + "inspect", + "--format", + "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", + "agentseek:test", + ) + ], + "incompatible_entrypoint": [ + ( + "docker", + "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 diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index c30bd2c..d5bda1a 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -349,6 +349,90 @@ def substitute_then_move(path: Path) -> Path: ) +@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"), [ From 85305e79c793147316c4629988c11e9fbf2157f6 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 22:27:51 +0800 Subject: [PATCH 22/42] test: prove container environment boundaries --- .github/workflows/ci.yml | 24 +- CHANGELOG.md | 14 + README.md | 44 +- README.zh-CN.md | 38 +- scripts/test-cli-docker.sh | 322 ++++++--- scripts/test_container_env_boundary.py | 683 ++++++++++++++++++ tests/unit/test_ci_workflow.py | 53 ++ .../test_container_boundary_acceptance.py | 97 +++ 8 files changed, 1167 insertions(+), 108 deletions(-) mode change 100644 => 100755 scripts/test-cli-docker.sh create mode 100755 scripts/test_container_env_boundary.py create mode 100644 tests/unit/test_container_boundary_acceptance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cd8136f..5bdbf99 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,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 +132,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 +155,18 @@ 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: CLI Docker smoke run: make test-cli-docker diff --git a/CHANGELOG.md b/CHANGELOG.md index c9b1e4b..595b864 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,20 @@ Notable changes to AgentSeek API are documented in this file. - 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 dependent Train B template/catalog and AgentSeek follow-ups are + 0.1.4; those consumer changes remain separate release gates. ## 0.2.3 - 2026-08-17 diff --git a/README.md b/README.md index 3e2606d..c8ba0fd 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,39 @@ 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. 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 +planned dependent Train B template/catalog and AgentSeek follow-ups are 0.1.4. 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..509c09d 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,34 @@ 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 与 AgentSeek 版本计划为 0.1.4。 CLI 层会尽量容忍 LangGraph 在端点级别使用的配置键,例如 `http` 与 `api_version`。Store 配置会被 HTTP Store API 以及注入的 LangGraph diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh old mode 100644 new mode 100755 index 3998042..cb9970d --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -3,97 +3,232 @@ 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))')" 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 + docker image rm -f "$IMAGE_TAG" >/dev/null 2>&1 || true + rm -rf -- "$TMP_DIR" } print_logs() { docker logs "$APP_CONTAINER" || true - docker logs "$APP_CONTAINER_AUTOBUILD" || true - docker logs "$DB_CONTAINER" || true - docker logs "$PG_CONTAINER" || true } trap cleanup EXIT -mkdir -p "$TMP_DIR" +mkdir -m 700 -p "$PROJECT_DIR" "$CANDIDATE_DIR" -cat >"$TMP_DIR/up.env" <<'EOF' +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() + + +@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"}, + "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_CONTEXT" "$BUILD_SENTINEL" <<'PY' +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() +dockerfile = (bundle / "Dockerfile").read_text(encoding="utf-8") +positions = [ + dockerfile.index("packaging==25.0"), + dockerfile.index("/tmp/custom-boundary"), + dockerfile.index("agentseek-api-0.3.0.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 bundle.rglob("*"): + if path.is_file() and sentinel in path.read_bytes(): + raise SystemExit("Build context sentinel isolation failed") +PY + +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 +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 ! uv run agentseek-api up \ - --config "$CONFIG_PATH" \ + --config "$PROJECT_DIR/launch.json" \ --image "$IMAGE_TAG" \ --port 8123 \ - --env-file "$TMP_DIR/up.env" \ - --recreate; then + --recreate >"$TMP_DIR/up.log" 2>&1; then print_logs exit 1 fi @@ -107,66 +242,45 @@ 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" +uv run python - "$IMAGE_ARCHIVE" "$HISTORY_FILE" "$BUILD_SENTINEL" <<'PY' +from pathlib import Path +import sys -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 +sentinel = sys.argv[3].encode() +if sentinel in Path(sys.argv[1]).read_bytes() or sentinel in Path(sys.argv[2]).read_bytes(): + raise SystemExit("Image layer or history sentinel isolation failed") +PY -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_container_env_boundary.py b/scripts/test_container_env_boundary.py new file mode 100755 index 0000000..5e6d2aa --- /dev/null +++ b/scripts/test_container_env_boundary.py @@ -0,0 +1,683 @@ +#!/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 +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 + + +_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_]*$") + + +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 + if isinstance(invocation, BuildImageInvocation): + return "build" + if isinstance(invocation, DockerRunInvocation): + return "docker-run" + if argv[:3] == ("docker", "rm", "-f"): + return "remove" + if argv[:2] == ("docker", "compose"): + return "compose" + return "inspect" + + +def _parse_environment(output: bytes) -> Mapping[str, str]: + try: + text = output.decode("utf-8", errors="strict") + except UnicodeDecodeError as exc: + raise BoundaryFailure("Compose environment output was not UTF-8") from exc + parsed: dict[str, str] = {} + for line in text.splitlines(): + if not line: + continue + name, separator, value = line.partition("=") + if not separator or not _ENVIRONMENT_NAME.fullmatch(name): + raise BoundaryFailure("Compose environment output was malformed") + parsed[name] = value + return MappingProxyType(parsed) + + +def _probe_script(names: tuple[str, ...]) -> str: + encoded_names = json.dumps(names, ensure_ascii=True, separators=(",", ":")) + return ( + "import json,os,pathlib;" + "p=pathlib.Path('/result/environment.json');" + 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( + *, + image: str, + application: Mapping[str, str], + docker_environment: Mapping[str, str], + cwd: Path, + result_path: Path, +) -> Mapping[str, str]: + names = tuple(sorted(application)) + argv = [ + "docker", + "run", + "--rm", + "--name", + f"agentseek-boundary-probe-{secrets.token_hex(6)}", + ] + 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((image, "python", "-c", _probe_script(names))) + result = _safe_process( + tuple(argv), + cwd=cwd, + environment={**docker_environment, **application}, + ) + if result.returncode != 0: + raise BoundaryFailure("synthetic Docker carrier probe 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) + 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 + + def _record(self, invocation: ProcessInvocation) -> None: + 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] + environment_result = _safe_process( + (*prefix, "config", "--environment"), + cwd=invocation.cwd, + environment=invocation.environment, + timeout=30, + ) + if environment_result.returncode != 0: + raise BoundaryFailure("Compose environment render failed") + self.compose_environment = _parse_environment(environment_result.stdout) + 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) + observed = rendered["services"]["probe"]["environment"][ + "PROJECT_DOTENV_CANARY" + ] + except (KeyError, TypeError, json.JSONDecodeError) as exc: + raise BoundaryFailure("Compose document boundary was malformed") from exc + if observed != "unset" or self.compose_dotenv_canary in ( + self.compose_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( + ("docker", "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( + ("docker", "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") + self.image_archive_and_history = archive_bytes + b"\n" + history.stdout + + def __call__(self, invocation: ProcessInvocation) -> ProcessResult: + self._record(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) + return result + if isinstance(invocation, DockerRunInvocation): + if self.image is None: + raise BoundaryFailure("direct-run image boundary was missing") + 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( + image=self.image, + application=application, + docker_environment=docker_environment, + cwd=invocation.cwd, + result_path=self.result_path, + ) + return ProcessResult(returncode=0) + if invocation.argv[:2] == ("docker", "compose") and "up" in invocation.argv: + return self._render_compose(invocation) + timeout = ( + invocation.timeout_seconds + if isinstance(invocation, ControlQueryInvocation) + else None + ) + return _safe_process( + invocation.argv, + cwd=invocation.cwd, + environment=invocation.environment, + stdin=invocation.stdin_bytes, + timeout=timeout, + ) + + +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("graph = object()\n", encoding="utf-8") + application_dotenv = root / "application.env" + application_dotenv.write_text(f"{allowed_name}={allowed_value}\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( + *, 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( + 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 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, + ) + try: + os.environ[disallowed_name] = disallowed_value + exit_code = cli_main( + ( + "up", + "--config", + str(config), + "--docker-compose", + str(compose), + "--port", + _PORT, + "--recreate", + ), + process_transport=transport, + cwd=project, + runtime_artifact=artifact, + ) + if exit_code != 0: + raise BoundaryFailure("agentseek-api up boundary failed") + if transport.image is None: + raise BoundaryFailure( + "built image boundary evidence was missing" + ) + first_run = next( + item + for item in transport.invocations + if item.kind == "docker-run" + ) + controls = { + name: value + for name, value in first_run.environment.items() + if name not in transport.application_environment + } + _prove_value_domain_carrier( + 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: + if had_previous: + assert previous is not None + os.environ[disallowed_name] = previous + else: + os.environ.pop(disallowed_name, None) + if transport.image is not None: + first = ( + transport.invocations[0] if transport.invocations else None + ) + environment = {} if first is None else first.environment + _safe_process( + ("docker", "image", "rm", "--force", transport.image), + cwd=project, + environment=environment, + timeout=30, + ) + + +def _require(condition: bool, boundary: str) -> None: + if not condition: + raise BoundaryFailure(f"{boundary} boundary failed") + + +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: + print(f"container boundary verification failed: {exc}", file=sys.stderr) + return 1 + except Exception: + print( + "container boundary verification failed: real-runtime boundary failed", + file=sys.stderr, + ) + return 1 + print(_SUCCESS) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/unit/test_ci_workflow.py b/tests/unit/test_ci_workflow.py index 0ee5ec0..0a001b2 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,54 @@ 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_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 diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py new file mode 100644 index 0000000..fcad147 --- /dev/null +++ b/tests/unit/test_container_boundary_acceptance.py @@ -0,0 +1,97 @@ +from __future__ import annotations + +import contextlib +import importlib.util +import io +import re +import sys +from pathlib import Path + +import pytest + + +SCRIPT = Path("scripts/test_container_env_boundary.py") + + +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 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" + + +@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)) From e36a2bd11c8d9efd7e67d998ed88a52549d93f46 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 22:49:08 +0800 Subject: [PATCH 23/42] fix: close container proof archive and cleanup gaps --- .github/workflows/ci.yml | 4 +- CHANGELOG.md | 5 +- README.md | 5 +- README.zh-CN.md | 5 +- scripts/container_image_archive.py | 270 ++++++++++++++++++ scripts/test-cli-docker.sh | 103 ++++++- scripts/test_cli_config_autodiscovery.py | 71 ++++- scripts/test_container_env_boundary.py | 60 +++- tests/unit/test_ci_workflow.py | 16 ++ .../test_container_boundary_acceptance.py | 121 ++++++++ tests/unit/test_container_image_archive.py | 248 ++++++++++++++++ 11 files changed, 864 insertions(+), 44 deletions(-) create mode 100644 scripts/container_image_archive.py create mode 100644 tests/unit/test_container_image_archive.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5bdbf99..9434028 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,9 +78,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: >- diff --git a/CHANGELOG.md b/CHANGELOG.md index 595b864..008239c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,8 +39,9 @@ Notable changes to AgentSeek API are documented in this file. `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 dependent Train B template/catalog and AgentSeek follow-ups are - 0.1.4; those consumer changes remain separate release gates. + 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 diff --git a/README.md b/README.md index c8ba0fd..5ca4c2e 100644 --- a/README.md +++ b/README.md @@ -421,7 +421,7 @@ Useful config fields: ### Container migration for 0.3.0 The `preloaded-v1` contract is a breaking, fail-closed container boundary. The -host resolves application configuration once. Generated images receive only +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; @@ -445,7 +445,8 @@ 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 -planned dependent Train B template/catalog and AgentSeek follow-ups are 0.1.4. +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 509c09d..f9a8624 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -386,7 +386,7 @@ Redis 实例同时运行。 ### 0.3.0 容器迁移 `preloaded-v1` 是不兼容旧行为、失败即关闭的容器边界。宿主机只解析一次 -应用配置。生成的镜像只接收显式选择的运行时 payload;宿主机环境、dotenv +应用配置。从生成镜像启动的容器只接收显式选择的运行时 payload;宿主机环境、dotenv 文件、包仓库凭证和未选择的 Compose 值都不会进入构建上下文或镜像层。 `--pass-env` 与 `--compose-pass-env` 只应接收可信输入:它们授权变量跨越指定 运行时边界,并不允许变量进入镜像构建。 @@ -406,7 +406,8 @@ Redis 实例同时运行。 manifest、已安装 distribution、entrypoint 与标签必须一致。系统不提供旧镜像 回退:传给 `up --image` 前必须完成迁移与校验;否则应继续用旧 launcher 配合 旧镜像。Train A 已达成版本为 API 0.2.3、templates 0.1.3、AgentSeek 0.1.3; -后续 Train B 的 template/catalog 与 AgentSeek 版本计划为 0.1.4。 +后续 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/scripts/container_image_archive.py b/scripts/container_image_archive.py new file mode 100644 index 0000000..3825108 --- /dev/null +++ b/scripts/container_image_archive.py @@ -0,0 +1,270 @@ +#!/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 _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: + if not _safe_name(member.name): + raise ImageArchiveError(f"{boundary} member path was unsafe") + if member.name in names: + raise ImageArchiveError(f"{boundary} contained duplicate members") + names.add(member.name) + if member.isfile(): + stream = archive.extractfile(member) + if stream is None: + raise ImageArchiveError(f"{boundary} member was unreadable") + files[member.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") + references = [config_reference, *layer_references] + if any(not isinstance(reference, str) for reference in references) or len( + set(references) + ) != len(references): + raise ImageArchiveError("Docker save references were duplicated or invalid") + config = _require_file(files, config_reference, "Docker save config") + _json_object(config, "Docker save config") + _scan_forbidden(config, forbidden) + for reference in 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_bytes, _ = _oci_blob( + files, manifest.get("config"), _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") + digests: set[str] = set() + for descriptor in layers: + if not isinstance(descriptor, dict) or descriptor.get("digest") in digests: + raise ImageArchiveError("OCI layer references were duplicated or invalid") + digest = descriptor.get("digest") + if isinstance(digest, str): + digests.add(digest) + 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 index cb9970d..da86c5d 100755 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -17,11 +17,52 @@ 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 image rm -f "$IMAGE_TAG" >/dev/null 2>&1 || true - rm -rf -- "$TMP_DIR" + 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() { @@ -151,13 +192,33 @@ if [[ ! -d "$BUNDLE_CONTEXT" || ! -s "$BUNDLE_CONTEXT/Dockerfile" ]]; then exit 1 fi -uv run python - "$BUNDLE_CONTEXT" "$BUILD_SENTINEL" <<'PY' +uv run python - "$BUNDLE_DIR" "$BUILD_SENTINEL" <<'PY' +import hashlib +import json from pathlib import Path import sys bundle = Path(sys.argv[1]) sentinel = sys.argv[2].encode() -dockerfile = (bundle / "Dockerfile").read_text(encoding="utf-8") +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"), @@ -168,11 +229,20 @@ positions = [ ] if positions != sorted(positions) or len(set(positions)) != len(positions): raise SystemExit("Dockerfile install and verification order failed") -for path in bundle.rglob("*"): +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 @@ -224,6 +294,15 @@ 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 ! uv run agentseek-api up \ --config "$PROJECT_DIR/launch.json" \ --image "$IMAGE_TAG" \ @@ -272,14 +351,10 @@ 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" -uv run python - "$IMAGE_ARCHIVE" "$HISTORY_FILE" "$BUILD_SENTINEL" <<'PY' -from pathlib import Path -import sys - -sentinel = sys.argv[3].encode() -if sentinel in Path(sys.argv[1]).read_bytes() or sentinel in Path(sys.argv[2]).read_bytes(): - raise SystemExit("Image layer or history sentinel isolation failed") -PY +AGENTSEEK_IMAGE_SCAN_SENTINEL="$BUILD_SENTINEL" \ + uv run python scripts/container_image_archive.py \ + --archive "$IMAGE_ARCHIVE" \ + --history "$HISTORY_FILE" printf 'candidate wheel sha256: %s\n' "$CANDIDATE_SHA256" printf 'runtime version: %s\n' "$RUNTIME_VERSION" diff --git a/scripts/test_cli_config_autodiscovery.py b/scripts/test_cli_config_autodiscovery.py index ce0522e..b4d3135 100644 --- a/scripts/test_cli_config_autodiscovery.py +++ b/scripts/test_cli_config_autodiscovery.py @@ -1,13 +1,50 @@ from __future__ import annotations +import argparse +import hashlib +import json import subprocess import sys import tempfile from pathlib import Path -def main() -> int: - with tempfile.TemporaryDirectory(prefix="agentseek-cli-autodiscovery-") as tmp_dir_text: +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 +67,30 @@ 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 + raise SystemExit("agentseek-api dockerfile bundle generation failed") + + _dockerfile, manifest = _verify_bundle(output_path) + if args.config is None: + assert manifest["graphs"] == {"agentseek": "chat.graph:graph"} + assert "langgraph" not in json.dumps(manifest["graphs"]) + else: + assert manifest.get("graphs") return 0 diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 5e6d2aa..358b139 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -28,6 +28,11 @@ ) 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" @@ -152,11 +157,14 @@ def _parse_environment(output: bytes) -> Mapping[str, str]: return MappingProxyType(parsed) -def _probe_script(names: tuple[str, ...]) -> str: +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;" - "p=pathlib.Path('/result/environment.json');" + 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');" @@ -173,12 +181,13 @@ def _run_probe( result_path: Path, ) -> Mapping[str, str]: names = tuple(sorted(application)) + container_name = f"agentseek-boundary-probe-{secrets.token_hex(6)}" argv = [ "docker", "run", "--rm", "--name", - f"agentseek-boundary-probe-{secrets.token_hex(6)}", + container_name, ] if os.name != "nt": argv.extend(("--user", f"{os.getuid()}:{os.getgid()}")) @@ -190,7 +199,7 @@ def _run_probe( ) for name in names: argv.extend(("-e", name)) - argv.extend((image, "python", "-c", _probe_script(names))) + argv.extend((image, "python", "-c", _probe_script(names, result_path.name))) result = _safe_process( tuple(argv), cwd=cwd, @@ -198,6 +207,14 @@ def _run_probe( ) if result.returncode != 0: raise BoundaryFailure("synthetic Docker carrier probe failed") + remaining = _safe_process( + ("docker", "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: @@ -217,6 +234,7 @@ def _run_probe( 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 @@ -309,6 +327,12 @@ def _capture_image(self, invocation: BuildImageInvocation) -> None: ) 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: @@ -470,6 +494,25 @@ def _prove_value_domain_carrier( raise BoundaryFailure("direct Docker carrier value-domain boundary failed") +def _remove_owned_image( + *, image: str, cwd: Path, environment: Mapping[str, str] +) -> None: + removed = _safe_process( + ("docker", "image", "rm", "--force", image), + cwd=cwd, + environment=environment, + timeout=30, + ) + remaining = _safe_process( + ("docker", "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 collect_boundary_evidence( *, disallowed_name: str, @@ -511,6 +554,10 @@ def collect_boundary_evidence( 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 @@ -572,11 +619,10 @@ def collect_boundary_evidence( transport.invocations[0] if transport.invocations else None ) environment = {} if first is None else first.environment - _safe_process( - ("docker", "image", "rm", "--force", transport.image), + _remove_owned_image( + image=transport.image, cwd=project, environment=environment, - timeout=30, ) diff --git a/tests/unit/test_ci_workflow.py b/tests/unit/test_ci_workflow.py index 0a001b2..ce14a1f 100644 --- a/tests/unit/test_ci_workflow.py +++ b/tests/unit/test_ci_workflow.py @@ -76,3 +76,19 @@ def test_cli_compatibility_runs_platform_container_selection_and_artifact_tests( 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_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index fcad147..1b8cea7 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -3,12 +3,17 @@ import contextlib import importlib.util import io +import json +import os import re +import subprocess import sys from pathlib import Path import pytest +from agentseek_api.docker_runtime import ProcessResult + SCRIPT = Path("scripts/test_container_env_boundary.py") @@ -79,6 +84,101 @@ def test_success_output_is_one_value_free_line() -> None: assert output.getvalue() == "container boundary verification passed\n" +@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", "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( + 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,) + assert calls[1][:3] == ("docker", "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( + 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( + 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") @@ -95,3 +195,24 @@ def test_container_migration_docs_cover_the_public_boundary(name: str) -> None: } 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 diff --git a/tests/unit/test_container_image_archive.py b/tests/unit/test_container_image_archive.py new file mode 100644 index 0000000..1ebfae6 --- /dev/null +++ b/tests/unit/test_container_image_archive.py @@ -0,0 +1,248 @@ +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 _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":[]}') -> 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], + }, + 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_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_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", + ) From c637f0e02444c8fa82477b40756f4edc23c4e693 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:15:46 +0800 Subject: [PATCH 24/42] fix: make hosted container proof executable --- .github/workflows/ci.yml | 11 ++- scripts/test_cli_config_autodiscovery.py | 19 +++++ scripts/test_container_env_boundary.py | 22 +++++- tests/unit/test_ci_workflow.py | 17 +++++ .../test_container_boundary_acceptance.py | 72 +++++++++++++++++++ 5 files changed, 139 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9434028..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 }}) @@ -165,6 +171,9 @@ jobs: - 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/scripts/test_cli_config_autodiscovery.py b/scripts/test_cli_config_autodiscovery.py index b4d3135..85e0de4 100644 --- a/scripts/test_cli_config_autodiscovery.py +++ b/scripts/test_cli_config_autodiscovery.py @@ -3,12 +3,30 @@ import argparse import hashlib import json +import os +import re import subprocess import sys import tempfile from pathlib import Path +_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" @@ -83,6 +101,7 @@ def main(argv: list[str] | None = None) -> int: text=True, ) if completed.returncode != 0: + _report_github_failure(completed.stderr) raise SystemExit("agentseek-api dockerfile bundle generation failed") _dockerfile, manifest = _verify_bundle(output_path) diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 358b139..b35f660 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -199,7 +199,16 @@ def _run_probe( ) for name in names: argv.extend(("-e", name)) - argv.extend((image, "python", "-c", _probe_script(names, result_path.name))) + argv.extend( + ( + "--entrypoint", + "python", + image, + "-I", + "-c", + _probe_script(names, result_path.name), + ) + ) result = _safe_process( tuple(argv), cwd=cwd, @@ -631,6 +640,16 @@ def _require(condition: bool, boundary: str) -> None: 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 _verify_evidence(evidence: BoundaryEvidence) -> None: _require( evidence.disallowed_value not in evidence.build_environment.values(), @@ -713,6 +732,7 @@ def main(*, evidence_collector: EvidenceCollector = collect_boundary_evidence) - ) _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: diff --git a/tests/unit/test_ci_workflow.py b/tests/unit/test_ci_workflow.py index ce14a1f..f6cc3ed 100644 --- a/tests/unit/test_ci_workflow.py +++ b/tests/unit/test_ci_workflow.py @@ -57,6 +57,23 @@ def test_cli_docker_runtime_has_independent_current_and_floor_compose_legs() -> 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") diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index 1b8cea7..dd091c1 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -9,6 +9,7 @@ import subprocess import sys from pathlib import Path +from types import SimpleNamespace import pytest @@ -16,6 +17,7 @@ SCRIPT = Path("scripts/test_container_env_boundary.py") +AUTODISCOVERY_SCRIPT = Path("scripts/test_cli_config_autodiscovery.py") def _load_script(): @@ -31,6 +33,17 @@ def _load_script(): 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 test_evidence_objects_hide_all_values_from_repr() -> None: module = _load_script() value = "private-boundary-value" @@ -84,6 +97,27 @@ def test_success_output_is_one_value_free_line() -> None: 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", + ] + + @pytest.mark.parametrize( "application", [ @@ -137,6 +171,14 @@ def fake_process(argv, *, cwd, environment, stdin=None, timeout=None): 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", "container", "inspect") @@ -216,3 +258,33 @@ def test_cli_config_autodiscovery_executes_the_bundle_contract() -> None: ) assert completed.returncode == 0, completed.stderr + + +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 From 5958c13c0cd2c3b509639d9c99a0de6e9077ff5f Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:20:02 +0800 Subject: [PATCH 25/42] test: expose hosted container proof failures --- scripts/test_cli_config_autodiscovery.py | 18 +++-- scripts/test_container_env_boundary.py | 75 ++++++++++++++++++- .../test_container_boundary_acceptance.py | 43 +++++++++++ 3 files changed, 128 insertions(+), 8 deletions(-) diff --git a/scripts/test_cli_config_autodiscovery.py b/scripts/test_cli_config_autodiscovery.py index 85e0de4..b911ebc 100644 --- a/scripts/test_cli_config_autodiscovery.py +++ b/scripts/test_cli_config_autodiscovery.py @@ -104,12 +104,18 @@ def main(argv: list[str] | None = None) -> int: _report_github_failure(completed.stderr) raise SystemExit("agentseek-api dockerfile bundle generation failed") - _dockerfile, manifest = _verify_bundle(output_path) - if args.config is None: - assert manifest["graphs"] == {"agentseek": "chat.graph:graph"} - assert "langgraph" not in json.dumps(manifest["graphs"]) - else: - assert manifest.get("graphs") + 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 index b35f660..5dae941 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -38,6 +38,7 @@ _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): @@ -157,6 +158,40 @@ def _parse_environment(output: bytes) -> Mapping[str, str]: return MappingProxyType(parsed) +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 + if argv[:3] == ("docker", "compose", "version"): + return "Compose capability query" + if argv[:3] == ("docker", "buildx", "version"): + return "Buildx version query" + if argv[:3] == ("docker", "buildx", "inspect"): + return "Buildx availability query" + if argv[:3] == ("docker", "image", "inspect"): + return "image contract query" + if argv[:3] == ("docker", "rm", "-f"): + return "container cleanup" + if argv[:2] == ("docker", "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") @@ -257,6 +292,8 @@ class _EvidenceTransport: default_factory=lambda: MappingProxyType({}), repr=False ) image: str | None = None + last_stage: str = "planning" + last_failure_detail: str = field(default="", repr=False) def _record(self, invocation: ProcessInvocation) -> None: digest = ( @@ -346,6 +383,7 @@ def _capture_image(self, invocation: BuildImageInvocation) -> None: 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 @@ -357,6 +395,18 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: ) 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: @@ -385,13 +435,26 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: if isinstance(invocation, ControlQueryInvocation) else None ) - return _safe_process( + 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): @@ -586,7 +649,15 @@ def collect_boundary_evidence( runtime_artifact=artifact, ) if exit_code != 0: - raise BoundaryFailure("agentseek-api up boundary failed") + 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" diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index dd091c1..aa25564 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -118,6 +118,31 @@ def fail(**_kwargs): ] +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", [ @@ -288,3 +313,21 @@ def test_cli_config_autodiscovery_emits_value_free_ci_failure_annotation( 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() From 74690e3ac84b345b16ffb756c940cfb9d7bc7e78 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:25:36 +0800 Subject: [PATCH 26/42] fix: preserve candidate wheel and bundle bytes --- src/agentseek_api/container_build.py | 18 ++++++++++------ tests/unit/test_container_build.py | 31 ++++++++++++++++++++++++---- 2 files changed, 39 insertions(+), 10 deletions(-) diff --git a/src/agentseek_api/container_build.py b/src/agentseek_api/container_build.py index 60100c8..1cd539a 100644 --- a/src/agentseek_api/container_build.py +++ b/src/agentseek_api/container_build.py @@ -38,6 +38,7 @@ 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"}) @@ -638,7 +639,7 @@ def _pip_config_source(path: Path) -> tuple[Path, tuple[int, int, int, int]]: 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 = os.O_RDONLY + flags = _source_file_flags() if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW descriptor = os.open(raw, flags) @@ -2198,10 +2199,11 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: 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, "/opt/agentseek/runtime/agentseek-api-0.3.0.whl"], + [candidate_source, candidate_runtime_path], ensure_ascii=False, ) ) @@ -2219,13 +2221,13 @@ def render_build_dockerfile(plan: ContainerBuildPlan) -> bytes: ) candidate_check = ( "import hashlib,pathlib,sys;" - "p=pathlib.Path('/opt/agentseek/runtime/agentseek-api-0.3.0.whl');" + 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 = "/opt/agentseek/runtime/agentseek-api-0.3.0.whl[embedded]" + runtime_operand = f"{candidate_runtime_path}[embedded]" else: runtime_operand = artifact.requirement lines.append( @@ -2424,12 +2426,16 @@ def _opened_output_parent( def _output_file_flags() -> int: - flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + 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) @@ -2453,7 +2459,7 @@ def _read_regular_source( 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 = os.O_RDONLY + flags = _source_file_flags() if hasattr(os, "O_NOFOLLOW"): flags |= os.O_NOFOLLOW fd = os.open(path, flags) diff --git a/tests/unit/test_container_build.py b/tests/unit/test_container_build.py index ced16bc..8a2e2f8 100644 --- a/tests/unit/test_container_build.py +++ b/tests/unit/test_container_build.py @@ -347,6 +347,27 @@ def _candidate_build_plan(root: Path): ) +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: @@ -600,7 +621,7 @@ def test_exact_runtime_install_forces_selected_artifact_replacement( argv for argv in commands if argv[:4] == ["python", "-m", "pip", "install"] - and any("agentseek-api" in operand for operand in argv) + and any("agentseek-api" in operand.replace("_", "-") for operand in argv) ) assert "--force-reinstall" in runtime_install @@ -621,7 +642,8 @@ def test_candidate_hash_verifier_has_success_and_failure_paths_under_optimize( ) -> None: plan = _candidate_build_plan(tmp_path) script = _generated_python_check( - render_build_dockerfile(plan).decode(), "agentseek-api-0.3.0.whl" + 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" @@ -635,7 +657,8 @@ def test_candidate_hash_verifier_has_success_and_failure_paths_under_optimize( "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.whl' else OriginalPath(value)" + "'/opt/agentseek/runtime/agentseek_api-0.3.0-py3-none-any.whl' " + "else OriginalPath(value)" ) completed = _run_generated_check(script, setup) @@ -755,7 +778,7 @@ def test_renderer_json_escapes_install_operands_and_candidate_source( 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.whl[embedded]" + "agentseek_api-0.3.0-py3-none-any.whl[embedded]" ) From b6c8cb68d407be7ab25d457738966cd1caa17066 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:36:22 +0800 Subject: [PATCH 27/42] fix: close hosted container portability gaps --- scripts/test_container_env_boundary.py | 11 +---- src/agentseek_api/secure_temp.py | 2 +- tests/unit/test_cli.py | 30 +++++++------ .../test_container_boundary_acceptance.py | 43 +++++++++++++++++++ tests/unit/test_secure_temp.py | 31 +++++++++++++ 5 files changed, 94 insertions(+), 23 deletions(-) diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 5dae941..35cfe50 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -662,16 +662,7 @@ def collect_boundary_evidence( raise BoundaryFailure( "built image boundary evidence was missing" ) - first_run = next( - item - for item in transport.invocations - if item.kind == "docker-run" - ) - controls = { - name: value - for name, value in first_run.environment.items() - if name not in transport.application_environment - } + controls = dict(transport.build_environment) _prove_value_domain_carrier( image=transport.image, docker_environment=controls, diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 93aa911..74a3e05 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -817,7 +817,7 @@ def private_artifact( 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 + 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 diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 0526c1f..79192b7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -82,22 +82,17 @@ def test_runtime_commands_accept_explicit_preloaded_environment_mode( @pytest.mark.parametrize( - ("inherited", "config", "env_file", "match"), + ("manifest_value", "config", "env_file", "match"), [ - ({}, None, None, "AGENTSEEK_GRAPHS"), - ({"AGENTSEEK_GRAPHS": "relative.json"}, None, None, "absolute"), - ( - {"AGENTSEEK_GRAPHS": "/image/manifest.v1.json"}, - "/other.json", - None, - "config", - ), - ({"AGENTSEEK_GRAPHS": "/image/manifest.v1.json"}, None, ".env", "env-file"), + (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, - inherited: dict[str, str], + manifest_value: str | None, config: str | None, env_file: str | None, match: str, @@ -105,10 +100,19 @@ def test_preloaded_mode_rejects_ambiguous_sources_before_loading( 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, + config_path=config_path, env_file=env_file, inherited=inherited, cwd=tmp_path, @@ -902,6 +906,8 @@ def invoke_failure_case( 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"] diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index aa25564..491d48c 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -118,6 +118,49 @@ def fail(**_kwargs): ] +def test_generated_compose_flow_uses_build_controls_for_direct_probe( + monkeypatch: pytest.MonkeyPatch, +) -> None: + module = _load_script() + observed_controls: list[dict[str, 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): + process_transport.image = "agentseek:test" + 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"}] + + def test_process_failure_tail_is_bounded_and_value_free() -> None: module = _load_script() forbidden = "abc" diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index d5bda1a..569d7f9 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -603,6 +603,10 @@ def test_windows_sweep_removes_private_directory_with_inherited_descendants( old = now - 48 * 60 * 60 os.utime(path, (old, old)) + expected = path.lstat() + assert secure_temp._quarantined_directory_matches(path, expected) + assert secure_temp._verify_windows_private_tree(path) + removed = sweep_expired_artifacts( tmp_root=tmp_path, prefix="agentseek-build-", @@ -616,6 +620,33 @@ def test_windows_sweep_removes_private_directory_with_inherited_descendants( 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, From 624420dc14f9834e46a08eb558982ce3954353a9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:40:40 +0800 Subject: [PATCH 28/42] test: expose hosted boundary failure locations --- scripts/test_container_env_boundary.py | 23 +++++++++++++++---- .../test_container_boundary_acceptance.py | 21 +++++++++++++++++ tests/unit/test_secure_temp.py | 2 ++ 3 files changed, 41 insertions(+), 5 deletions(-) diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 35cfe50..af47547 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -11,6 +11,7 @@ import stat import subprocess import sys +import traceback from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path @@ -712,6 +713,19 @@ def _report_github_failure(message: str) -> None: ) +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(), @@ -797,11 +811,10 @@ def main(*, evidence_collector: EvidenceCollector = collect_boundary_evidence) - _report_github_failure(str(exc)) print(f"container boundary verification failed: {exc}", file=sys.stderr) return 1 - except Exception: - print( - "container boundary verification failed: real-runtime boundary failed", - file=sys.stderr, - ) + 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 diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index 491d48c..8141cf0 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -118,6 +118,27 @@ def fail(**_kwargs): ] +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: diff --git a/tests/unit/test_secure_temp.py b/tests/unit/test_secure_temp.py index 569d7f9..d92af48 100644 --- a/tests/unit/test_secure_temp.py +++ b/tests/unit/test_secure_temp.py @@ -605,6 +605,8 @@ def test_windows_sweep_removes_private_directory_with_inherited_descendants( 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( From 281816050cd28b52f59956fe8531ea020a8be2a9 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:46:33 +0800 Subject: [PATCH 29/42] fix: verify hosted image and Windows trees --- scripts/container_image_archive.py | 13 ++++--- src/agentseek_api/secure_temp.py | 5 ++- tests/unit/test_container_image_archive.py | 45 ++++++++++++++++++++++ 3 files changed, 56 insertions(+), 7 deletions(-) diff --git a/scripts/container_image_archive.py b/scripts/container_image_archive.py index 3825108..67cc3ce 100644 --- a/scripts/container_image_archive.py +++ b/scripts/container_image_archive.py @@ -150,15 +150,16 @@ def _scan_docker_save(files: Mapping[str, bytes], forbidden: bytes) -> None: layer_references = entry.get("Layers") if not isinstance(layer_references, list) or not layer_references: raise ImageArchiveError("Docker save layer references were invalid") - references = [config_reference, *layer_references] - if any(not isinstance(reference, str) for reference in references) or len( - set(references) - ) != len(references): - raise ImageArchiveError("Docker save references were duplicated or 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 layer_references: + for reference in dict.fromkeys(layer_references): _scan_layer(_require_file(files, reference, "Docker save layer"), forbidden) diff --git a/src/agentseek_api/secure_temp.py b/src/agentseek_api/secure_temp.py index 74a3e05..54f916b 100644 --- a/src/agentseek_api/secure_temp.py +++ b/src/agentseek_api/secure_temp.py @@ -603,7 +603,10 @@ def _verify_windows_dacl( # pragma: no cover - native Windows only try: user_sid = _current_user_sid() system_sid = _well_known_system_sid() - if not _sid_matches(owner, user_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() diff --git a/tests/unit/test_container_image_archive.py b/tests/unit/test_container_image_archive.py index 1ebfae6..d65d8f1 100644 --- a/tests/unit/test_container_image_archive.py +++ b/tests/unit/test_container_image_archive.py @@ -116,6 +116,51 @@ def test_scanner_accepts_every_referenced_layer_and_no_trunc_history() -> None: ) +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() From b8e924fcb490b6594ead617dee8ba2ca3476a487 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:50:29 +0800 Subject: [PATCH 30/42] fix: canonicalize real Docker layer members --- scripts/container_image_archive.py | 23 +++++++++++--- tests/unit/test_container_image_archive.py | 36 ++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/scripts/container_image_archive.py b/scripts/container_image_archive.py index 67cc3ce..eba3fab 100644 --- a/scripts/container_image_archive.py +++ b/scripts/container_image_archive.py @@ -46,6 +46,18 @@ def _safe_name(name: str) -> bool: ) +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) @@ -62,16 +74,19 @@ def _tar_files(payload: bytes, boundary: str) -> dict[str, bytes]: try: with tarfile.open(fileobj=io.BytesIO(payload), mode="r:") as archive: for member in archive: - if not _safe_name(member.name): + 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 member.name in names: + if name == ".": + continue + if name in names: raise ImageArchiveError(f"{boundary} contained duplicate members") - names.add(member.name) + names.add(name) if member.isfile(): stream = archive.extractfile(member) if stream is None: raise ImageArchiveError(f"{boundary} member was unreadable") - files[member.name] = stream.read() + files[name] = stream.read() except ImageArchiveError: raise except (tarfile.TarError, OSError, EOFError) as exc: diff --git a/tests/unit/test_container_image_archive.py b/tests/unit/test_container_image_archive.py index d65d8f1..ae30529 100644 --- a/tests/unit/test_container_image_archive.py +++ b/tests/unit/test_container_image_archive.py @@ -38,6 +38,20 @@ def _tar(entries: list[tuple[str, bytes]]) -> bytes: 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], @@ -116,6 +130,28 @@ def test_scanner_accepts_every_referenced_layer_and_no_trunc_history() -> None: ) +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( From 50a433f4378af26dc37dc5c8215bb6c4b28df660 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Thu, 20 Aug 2026 23:54:00 +0800 Subject: [PATCH 31/42] fix: scan reused OCI image layers --- scripts/container_image_archive.py | 22 ++++++++---- tests/unit/test_container_image_archive.py | 39 ++++++++++++++++++++-- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/scripts/container_image_archive.py b/scripts/container_image_archive.py index eba3fab..eeb1b74 100644 --- a/scripts/container_image_archive.py +++ b/scripts/container_image_archive.py @@ -220,21 +220,31 @@ def _scan_oci(files: Mapping[str, bytes], forbidden: bytes) -> None: 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, manifest.get("config"), _OCI_CONFIG_MEDIA_TYPES, "OCI config" + 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") - digests: set[str] = set() + 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) or descriptor.get("digest") in digests: - raise ImageArchiveError("OCI layer references were duplicated or invalid") + if not isinstance(descriptor, dict): + raise ImageArchiveError("OCI layer references were invalid") digest = descriptor.get("digest") - if isinstance(digest, str): - digests.add(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" ) diff --git a/tests/unit/test_container_image_archive.py b/tests/unit/test_container_image_archive.py index ae30529..7dfbbae 100644 --- a/tests/unit/test_container_image_archive.py +++ b/tests/unit/test_container_image_archive.py @@ -84,14 +84,21 @@ def _descriptor(payload: bytes, media_type: str) -> dict[str, object]: } -def _oci_archive(*, layer: bytes, config: bytes = b'{"history":[]}') -> bytes: +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], + "layers": [layer_descriptor, layer_descriptor] + if repeat_layer + else [layer_descriptor], }, separators=(",", ":"), ).encode() @@ -207,6 +214,34 @@ def test_scanner_accepts_an_oci_index_and_verifies_referenced_blobs() -> None: ) +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")]))) From a719401fb0e03639569f10eb21d4825c5d5c46bc Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 00:01:25 +0800 Subject: [PATCH 32/42] fix: support Compose floor proof execution --- scripts/test_container_env_boundary.py | 48 ++++------ tests/container_plan_helpers.py | 18 +--- .../test_container_boundary_acceptance.py | 68 +++++++++++++- tests/unit/test_docker_runtime.py | 88 +++++++++++++++++++ 4 files changed, 176 insertions(+), 46 deletions(-) diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index af47547..0031833 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -143,22 +143,6 @@ def _classify(invocation: ProcessInvocation) -> str: return "inspect" -def _parse_environment(output: bytes) -> Mapping[str, str]: - try: - text = output.decode("utf-8", errors="strict") - except UnicodeDecodeError as exc: - raise BoundaryFailure("Compose environment output was not UTF-8") from exc - parsed: dict[str, str] = {} - for line in text.splitlines(): - if not line: - continue - name, separator, value = line.partition("=") - if not separator or not _ENVIRONMENT_NAME.fullmatch(name): - raise BoundaryFailure("Compose environment output was malformed") - parsed[name] = value - return MappingProxyType(parsed) - - def _value_free_process_tail( result: ProcessResult, *, redactions: tuple[bytes, ...] ) -> str: @@ -317,15 +301,6 @@ def _render_compose(self, invocation: ProcessInvocation) -> ProcessResult: except ValueError as exc: raise BoundaryFailure("Compose invocation classification failed") from exc prefix = invocation.argv[:command_index] - environment_result = _safe_process( - (*prefix, "config", "--environment"), - cwd=invocation.cwd, - environment=invocation.environment, - timeout=30, - ) - if environment_result.returncode != 0: - raise BoundaryFailure("Compose environment render failed") - self.compose_environment = _parse_environment(environment_result.stdout) rendered_result = _safe_process( (*prefix, "config", "--format", "json"), cwd=invocation.cwd, @@ -336,13 +311,24 @@ def _render_compose(self, invocation: ProcessInvocation) -> ProcessResult: raise BoundaryFailure("Compose document render failed") try: rendered = json.loads(rendered_result.stdout) - observed = rendered["services"]["probe"]["environment"][ - "PROJECT_DOTENV_CANARY" - ] - except (KeyError, TypeError, json.JSONDecodeError) as exc: + 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 - if observed != "unset" or self.compose_dotenv_canary in ( - self.compose_environment.values() + 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) diff --git a/tests/container_plan_helpers.py b/tests/container_plan_helpers.py index cfb06b7..7ff3d64 100644 --- a/tests/container_plan_helpers.py +++ b/tests/container_plan_helpers.py @@ -198,17 +198,6 @@ def docker_compose_available(*, cwd: Path) -> bool: ) -def _parse_compose_environment(output: bytes) -> dict[str, str]: - text = output.decode("utf-8", errors="strict") - matches = list( - re.finditer( - r"(?ms)^([A-Za-z_][A-Za-z0-9_]*)=(.*?)(?=^[A-Za-z_][A-Za-z0-9_]*=|\Z)", - text, - ) - ) - return {match.group(1): match.group(2).removesuffix("\n") for match in matches} - - def decode_with_supported_compose( encoded: str, *, @@ -226,6 +215,8 @@ def decode_with_supported_compose( 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" @@ -258,12 +249,10 @@ def decode_with_supported_compose( "-f", str(compose_path), ] - substitution = _parse_compose_environment( - _run_private([*base, "config", "--environment"], cwd=tmp_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: @@ -273,6 +262,7 @@ def decode_with_supported_compose( 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] = {} diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index 8141cf0..db3f8ed 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -13,7 +13,7 @@ import pytest -from agentseek_api.docker_runtime import ProcessResult +from agentseek_api.docker_runtime import ProcessInvocation, ProcessResult SCRIPT = Path("scripts/test_container_env_boundary.py") @@ -182,6 +182,72 @@ def fake_probe(*, docker_environment, **_kwargs): assert observed_controls == [{"PATH": "docker-control-only"}] +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", + "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", + "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" diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index bda8835..7a8fbfa 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -11,6 +11,8 @@ import pytest +import tests.container_plan_helpers as container_plan_helpers + from agentseek_api.docker_runtime import ( MINIMUM_BUILDX_VERSION, MINIMUM_COMPOSE_VERSION, @@ -484,6 +486,92 @@ def test_compose_encoder_round_trips_real_container_without_second_interpolation 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, From 07a3b0ccaefeed1d2f7c047880b7ffd835e117ec Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 00:05:38 +0800 Subject: [PATCH 33/42] fix: align CLI smoke with candidate wheel name --- scripts/test-cli-docker.sh | 2 +- tests/unit/test_container_boundary_acceptance.py | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index da86c5d..073ef6d 100755 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -222,7 +222,7 @@ if ( positions = [ dockerfile.index("packaging==25.0"), dockerfile.index("/tmp/custom-boundary"), - dockerfile.index("agentseek-api-0.3.0.whl[embedded]"), + 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"), diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index db3f8ed..c7123e1 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -18,6 +18,7 @@ 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") def _load_script(): @@ -415,6 +416,13 @@ def test_cli_config_autodiscovery_executes_the_bundle_contract() -> None: 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_config_autodiscovery_emits_value_free_ci_failure_annotation( monkeypatch: pytest.MonkeyPatch, ) -> None: From 40b2401d1c7226721866f232981ca447abb4ff65 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 00:22:23 +0800 Subject: [PATCH 34/42] test: expose value-free hosted startup failure --- scripts/test-cli-docker.sh | 13 +++++- scripts/value_free_log_tail.py | 46 +++++++++++++++++++ .../test_container_boundary_acceptance.py | 37 +++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 scripts/value_free_log_tail.py diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index 073ef6d..9de6580 100755 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -66,7 +66,17 @@ cleanup() { } print_logs() { - docker logs "$APP_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 @@ -308,6 +318,7 @@ if ! uv run agentseek-api up \ --image "$IMAGE_TAG" \ --port 8123 \ --recreate >"$TMP_DIR/up.log" 2>&1; then + print_up_log print_logs exit 1 fi 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/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index c7123e1..a4dfc39 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -19,6 +19,7 @@ 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") def _load_script(): @@ -45,6 +46,17 @@ def _load_autodiscovery_script(): 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" @@ -423,6 +435,31 @@ def test_cli_docker_smoke_asserts_the_canonical_candidate_filename() -> None: assert 'dockerfile.index("agentseek-api-0.3.0.whl[embedded]")' not 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: From 519ed41c1b69614c87c53f8156b0f4021f22299f Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 00:26:28 +0800 Subject: [PATCH 35/42] fix: launch smoke from generated project --- scripts/test-cli-docker.sh | 13 ++++++++----- tests/unit/test_container_boundary_acceptance.py | 7 +++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index 9de6580..658c127 100755 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -313,11 +313,14 @@ if [[ -n "$EXISTING_CONTAINER" ]]; then exit 1 fi CONTAINER_OWNED=1 -if ! uv run agentseek-api up \ - --config "$PROJECT_DIR/launch.json" \ - --image "$IMAGE_TAG" \ - --port 8123 \ - --recreate >"$TMP_DIR/up.log" 2>&1; then +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 diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index a4dfc39..7359a69 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -435,6 +435,13 @@ def test_cli_docker_smoke_asserts_the_canonical_candidate_filename() -> None: 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_failure_tail_is_bounded_and_redacts_application_values( tmp_path: Path, ) -> None: From d4035833395db5c0d6bedf252de7c640aa4d81cc Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 00:30:50 +0800 Subject: [PATCH 36/42] fix: activate baked auth in CLI smoke --- scripts/test-cli-docker.sh | 1 + tests/unit/test_container_boundary_acceptance.py | 6 ++++++ 2 files changed, 7 insertions(+) diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index 658c127..70e262d 100755 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -162,6 +162,7 @@ cat >"$PROJECT_DIR/launch.json" <<'JSON' { "dependencies": [], "graphs": {"external_hello": "./graph.py:build_graph"}, + "auth": {"path": "auth_backend:HeaderAuthBackend"}, "env": "application.env" } JSON diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index 7359a69..2f9da0f 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -442,6 +442,12 @@ def test_cli_docker_smoke_launches_from_the_generated_project() -> None: 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: From 4d0a8cfa38f2aff5baab69aff1f607211b8c8495 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:14:47 +0800 Subject: [PATCH 37/42] fix: bind generated container runtime execution --- scripts/test_container_env_boundary.py | 170 +++++++++++--- src/agentseek_api/cli.py | 40 +++- src/agentseek_api/docker_runtime.py | 64 +++++- tests/unit/test_cli.py | 213 ++++++++++++------ .../test_container_boundary_acceptance.py | 95 +++++++- tests/unit/test_docker_runtime.py | 187 +++++++++++---- 6 files changed, 611 insertions(+), 158 deletions(-) diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 0031833..4ff1fd2 100755 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -131,14 +131,14 @@ def _safe_process( def _classify(invocation: ProcessInvocation) -> str: - argv = invocation.argv + argv = invocation.argv[1:] if isinstance(invocation, BuildImageInvocation): return "build" if isinstance(invocation, DockerRunInvocation): return "docker-run" - if argv[:3] == ("docker", "rm", "-f"): + if argv[:2] == ("rm", "-f"): return "remove" - if argv[:2] == ("docker", "compose"): + if argv[:1] == ("compose",): return "compose" return "inspect" @@ -161,18 +161,18 @@ def _invocation_stage(invocation: ProcessInvocation) -> str: return "candidate image build" if isinstance(invocation, DockerRunInvocation): return "direct carrier probe" - argv = invocation.argv - if argv[:3] == ("docker", "compose", "version"): + argv = invocation.argv[1:] + if argv[:2] == ("compose", "version"): return "Compose capability query" - if argv[:3] == ("docker", "buildx", "version"): + if argv[:2] == ("buildx", "version"): return "Buildx version query" - if argv[:3] == ("docker", "buildx", "inspect"): + if argv[:2] == ("buildx", "inspect"): return "Buildx availability query" - if argv[:3] == ("docker", "image", "inspect"): + if argv[:2] == ("image", "inspect"): return "image contract query" - if argv[:3] == ("docker", "rm", "-f"): + if argv[:2] == ("rm", "-f"): return "container cleanup" - if argv[:2] == ("docker", "compose"): + if argv[:1] == ("compose",): return "Compose render" return "Docker control query" @@ -194,6 +194,7 @@ def _probe_script(names: tuple[str, ...], result_name: str) -> str: def _run_probe( *, + docker_executable: str, image: str, application: Mapping[str, str], docker_environment: Mapping[str, str], @@ -203,7 +204,7 @@ def _run_probe( names = tuple(sorted(application)) container_name = f"agentseek-boundary-probe-{secrets.token_hex(6)}" argv = [ - "docker", + docker_executable, "run", "--rm", "--name", @@ -237,7 +238,7 @@ def _run_probe( if result.returncode != 0: raise BoundaryFailure("synthetic Docker carrier probe failed") remaining = _safe_process( - ("docker", "container", "inspect", container_name), + (docker_executable, "container", "inspect", container_name), cwd=cwd, environment=docker_environment, timeout=30, @@ -277,10 +278,21 @@ class _EvidenceTransport: 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 @@ -345,7 +357,14 @@ def _capture_image(self, invocation: BuildImageInvocation) -> None: tmp_root=invocation.cwd.parent, ) as image_archive: save = _safe_process( - ("docker", "image", "save", "--output", str(image_archive), self.image), + ( + invocation.argv[0], + "image", + "save", + "--output", + str(image_archive), + self.image, + ), cwd=invocation.cwd, environment=invocation.environment, ) @@ -353,7 +372,14 @@ def _capture_image(self, invocation: BuildImageInvocation) -> None: raise BoundaryFailure("built image export failed") archive_bytes = image_archive.read_bytes() history = _safe_process( - ("docker", "history", "--no-trunc", "--format", "{{json .}}", self.image), + ( + invocation.argv[0], + "history", + "--no-trunc", + "--format", + "{{json .}}", + self.image, + ), cwd=invocation.cwd, environment=invocation.environment, timeout=30, @@ -398,6 +424,13 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: 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 @@ -408,14 +441,20 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: 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 ProcessResult(returncode=0) - if invocation.argv[:2] == ("docker", "compose") and "up" in invocation.argv: + 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 @@ -490,9 +529,25 @@ def _write_project( package = root / "chat" package.mkdir() (package / "__init__.py").write_text("", encoding="utf-8") - (package / "graph.py").write_text("graph = object()\n", 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", encoding="utf-8") + 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( @@ -523,7 +578,12 @@ def _write_project( def _prove_value_domain_carrier( - *, image: str, docker_environment: Mapping[str, str], cwd: Path, result_path: Path + *, + docker_executable: str, + image: str, + docker_environment: Mapping[str, str], + cwd: Path, + result_path: Path, ) -> None: values = MappingProxyType( { @@ -543,6 +603,7 @@ def _prove_value_domain_carrier( result_path.write_bytes(b"") result_path.chmod(0o600) observed = _run_probe( + docker_executable=docker_executable, image=image, application=values, docker_environment=docker_environment, @@ -554,16 +615,20 @@ def _prove_value_domain_carrier( def _remove_owned_image( - *, image: str, cwd: Path, environment: Mapping[str, str] + *, + docker_executable: str, + image: str, + cwd: Path, + environment: Mapping[str, str], ) -> None: removed = _safe_process( - ("docker", "image", "rm", "--force", image), + (docker_executable, "image", "rm", "--force", image), cwd=cwd, environment=environment, timeout=30, ) remaining = _safe_process( - ("docker", "image", "inspect", image), + (docker_executable, "image", "inspect", image), cwd=cwd, environment=environment, timeout=30, @@ -572,6 +637,29 @@ def _remove_owned_image( 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, @@ -630,6 +718,7 @@ def collect_boundary_evidence( "--port", _PORT, "--recreate", + "--wait", ), process_transport=transport, cwd=project, @@ -651,6 +740,7 @@ def collect_boundary_evidence( ) controls = dict(transport.build_environment) _prove_value_domain_carrier( + docker_executable=transport.require_docker_executable(), image=transport.image, docker_environment=controls, cwd=project, @@ -667,20 +757,40 @@ def collect_boundary_evidence( 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: - first = ( - transport.invocations[0] if transport.invocations else None - ) - environment = {} if first is None else first.environment - _remove_owned_image( - image=transport.image, - cwd=project, - environment=environment, + 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." ) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 458cfab..3c24a71 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -41,6 +41,7 @@ from agentseek_api.docker_runtime import ( DockerRuntimeError, LegacyRunnerAdapter, + PRELOADED_MANIFEST_PATH, ProcessTransport, SubprocessTransport, build_compose_invocation, @@ -52,6 +53,7 @@ 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 ( @@ -1162,6 +1164,7 @@ def _execute_build_command( role=None, ) 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-", @@ -1178,12 +1181,14 @@ def _execute_build_command( 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, @@ -1213,12 +1218,13 @@ def _wait_for_http_ready(url: str, *, timeout_seconds: float) -> None: def _container_exists( name: str, *, + docker_executable: str, process_transport: ProcessTransport, docker_control: dict[str, str], cwd: Path, ) -> bool: invocation = build_docker_query_invocation( - argv=("docker", "container", "inspect", name), + argv=(docker_executable, "container", "inspect", name), docker_control=docker_control, cwd=cwd, ) @@ -1252,6 +1258,7 @@ def _execute_up_command( 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 ) @@ -1285,6 +1292,7 @@ def _execute_up_command( 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) @@ -1309,6 +1317,7 @@ def _execute_up_command( if image: custom_image_contract = inspect_image_contract( image, + docker_executable=docker_executable, transport=process_transport, docker_control=docker_control, cwd=cwd, @@ -1347,6 +1356,7 @@ def _execute_up_command( build_invocation = build_image_invocation( bundle, plan=generated_plan, + docker_executable=docker_executable, docker_control=docker_control, tag=image, pull=args.pull, @@ -1355,6 +1365,7 @@ def _execute_up_command( 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, @@ -1374,6 +1385,7 @@ def _execute_up_command( 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, @@ -1384,16 +1396,36 @@ def _execute_up_command( 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: remove_invocation = build_docker_control_invocation( - argv=("docker", "rm", "-f", container_name), + argv=(docker_executable, "rm", "-f", container_name), docker_control=docker_control, cwd=cwd, ) @@ -1402,6 +1434,7 @@ def _execute_up_command( 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, @@ -1412,7 +1445,7 @@ def _execute_up_command( ) base_argv = ( - "docker", + docker_executable, "run", "--detach", "--name", @@ -1439,6 +1472,7 @@ def _execute_up_command( process_transport(remove_invocation) elif _container_exists( container_name, + docker_executable=docker_executable, process_transport=process_transport, docker_control=docker_control, cwd=cwd, diff --git a/src/agentseek_api/docker_runtime.py b/src/agentseek_api/docker_runtime.py index 3645dff..b70017d 100644 --- a/src/agentseek_api/docker_runtime.py +++ b/src/agentseek_api/docker_runtime.py @@ -6,6 +6,7 @@ import math import os import re +import shutil import stat import subprocess import sys @@ -30,6 +31,7 @@ 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})$") @@ -56,6 +58,7 @@ class ProcessInvocation: def __post_init__(self) -> None: object.__setattr__(self, "argv", tuple(self.argv)) + _validate_argv(self.argv) object.__setattr__( self, "environment", MappingProxyType(dict(self.environment)) ) @@ -136,6 +139,46 @@ def _validate_argv(argv: tuple[str, ...]) -> None: 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( @@ -270,6 +313,7 @@ def build_image_invocation( bundle: ContainerBuildBundle, *, plan: ContainerBuildPlan, + docker_executable: str, docker_control: Mapping[str, str], tag: str | None = None, platform: str | None = None, @@ -281,7 +325,7 @@ def build_image_invocation( raise DockerRuntimeError("The build bundle does not match the supplied plan.") archive = bundle.archive_bytes() validate_pip_config_identity(plan) - argv = ["docker", "buildx", "build", "--load", "--file", "Dockerfile"] + argv = [docker_executable, "buildx", "build", "--load", "--file", "Dockerfile"] if platform: argv.extend(("--platform", platform)) if pull: @@ -338,6 +382,7 @@ def encode_compose_environment(values: Mapping[str, str]) -> str: def build_compose_invocation( *, + docker_executable: str, compose_file: Path, env_file: Path, docker_control: Mapping[str, str], @@ -356,7 +401,7 @@ def build_compose_invocation( platform=platform, ) argv = [ - "docker", + docker_executable, "compose", "--env-file", str(env_file), @@ -374,6 +419,7 @@ def build_compose_invocation( def require_supported_compose( *, + docker_executable: str, transport: ProcessTransport, docker_control: Mapping[str, str], cwd: Path, @@ -381,7 +427,7 @@ def require_supported_compose( """Reject old or unavailable Compose before private artifacts are created.""" query = build_docker_query_invocation( - argv=("docker", "compose", "version", "--short"), + argv=(docker_executable, "compose", "version", "--short"), docker_control=docker_control, cwd=cwd, ) @@ -488,6 +534,7 @@ def require_buildx_available(result: ProcessResult) -> None: def require_supported_buildx( *, + docker_executable: str, transport: ProcessTransport, docker_control: Mapping[str, str], cwd: Path, @@ -496,7 +543,7 @@ def require_supported_buildx( """Require a usable Buildx plugin before any side-effecting build call.""" version_query = build_docker_query_invocation( - argv=("docker", "buildx", "version"), + argv=(docker_executable, "buildx", "version"), docker_control=docker_control, cwd=cwd, ) @@ -516,7 +563,7 @@ def require_supported_buildx( f"Docker Buildx {required} or newer is required for BuildKit secrets." ) inspect_query = build_docker_query_invocation( - argv=("docker", "buildx", "inspect"), + argv=(docker_executable, "buildx", "inspect"), docker_control=docker_control, cwd=cwd, ) @@ -564,7 +611,7 @@ def parse_image_compatibility_result(result: ProcessResult) -> DockerImageConfig _PRELOADED_V1_LABELS = { "org.agentseek.environment-contract": "preloaded-v1", - "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", + "org.agentseek.runtime-manifest": PRELOADED_MANIFEST_PATH, "org.agentseek.runtime-distribution": "agentseek-api", "org.agentseek.runtime-version": "0.3.0", } @@ -581,6 +628,7 @@ def require_preloaded_v1(labels: Mapping[str, str]) -> str: def inspect_image_contract( image: str, *, + docker_executable: str, transport: ProcessTransport, docker_control: Mapping[str, str], cwd: Path, @@ -589,7 +637,7 @@ def inspect_image_contract( query = build_docker_query_invocation( argv=( - "docker", + docker_executable, "image", "inspect", "--format", @@ -646,6 +694,7 @@ def inspect_image_contract( "IMAGE_COMPATIBILITY_FORMAT", "MINIMUM_BUILDX_VERSION", "MINIMUM_COMPOSE_VERSION", + "PRELOADED_MANIFEST_PATH", "ProcessInvocation", "ProcessResult", "ProcessTransport", @@ -665,6 +714,7 @@ def inspect_image_contract( "require_buildx_available", "require_supported_buildx", "require_supported_compose", + "resolve_docker_executable", "validate_pip_config_identity", "validate_environment_name", ] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 79192b7..0f6a442 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -6,6 +6,7 @@ import io import json import os +import shutil import signal import subprocess import sys @@ -33,6 +34,23 @@ 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: @@ -305,7 +323,7 @@ def __call__( 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 @@ -321,14 +339,15 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: if self.calls is None: self.calls = [] self.calls.append(invocation) - if invocation.argv == ("docker", "compose", "version", "--short"): + docker_args = invocation.argv[1:] + if docker_args == ("compose", "version", "--short"): return ProcessResult(returncode=0, stdout=b"2.40.3\n") - if invocation.argv == ("docker", "buildx", "version"): + if docker_args == ("buildx", "version"): return ProcessResult( returncode=0, stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", ) - if invocation.argv[:3] == ("docker", "image", "inspect"): + if docker_args[:2] == ("image", "inspect"): selected = self.image_config or ( { "org.agentseek.environment-contract": "preloaded-v1", @@ -341,7 +360,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: ) return ProcessResult(returncode=0, stdout=json.dumps(selected).encode()) return_code = 0 - if invocation.argv[:3] == ("docker", "container", "inspect"): + 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) @@ -369,20 +388,21 @@ class BoundaryRunner(ProcessTransport): def _is_read_only(invocation: ProcessInvocation) -> bool: if not isinstance(invocation, ControlQueryInvocation): return False - argv = tuple(invocation.argv) - if argv[:3] == ("docker", "image", "inspect"): + argv = tuple(invocation.argv[1:]) + if argv[:2] == ("image", "inspect"): return True - if argv == ("docker", "compose", "version", "--short"): + if argv == ("compose", "version", "--short"): return True if argv in { - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + ("buildx", "version"), + ("buildx", "inspect"), }: return True - return argv[:2] == ("docker", "compose") and "config" in argv[2:] + 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( @@ -403,23 +423,23 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: ) if not isinstance(invocation, ControlQueryInvocation): return ProcessResult(returncode=0) - if argv == ("docker", "compose", "version", "--short"): + 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 argv == ("docker", "buildx", "version"): + if docker_args == ("buildx", "version"): return ProcessResult( returncode=0, stdout=b"github.com/docker/buildx v0.14.0 deadbeef\n", ) - if argv == ("docker", "buildx", "inspect"): + if docker_args == ("buildx", "inspect"): return ProcessResult( returncode=1 if self.failure_case == "unavailable_buildkit" else 0 ) - if argv[:3] == ("docker", "image", "inspect"): + if docker_args[:2] == ("image", "inspect"): labels = { "org.agentseek.environment-contract": "preloaded-v1", "org.agentseek.runtime-manifest": "/opt/agentseek/manifest.v1.json", @@ -437,7 +457,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: returncode=0, stdout=json.dumps([labels, entrypoint, []]).encode("utf-8"), ) - if argv[:3] == ("docker", "container", "inspect"): + if docker_args[:2] == ("container", "inspect"): return ProcessResult(returncode=1) return ProcessResult(returncode=0) @@ -445,18 +465,18 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: @pytest.mark.parametrize( "argv", [ - ("docker", "compose", "version", "--short"), - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "compose", "version", "--short"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", "fixture", "agentseek:test", ), - ("docker", "compose", "-f", "compose.yaml", "config"), + (DOCKER_EXECUTABLE, "compose", "-f", "compose.yaml", "config"), ], ) def test_boundary_runner_requires_control_query_type_for_read_only_call( @@ -480,7 +500,7 @@ def test_boundary_runner_treats_container_inspect_as_workload_boundary( runner( ControlQueryInvocation( - argv=("docker", "container", "inspect", "agentseek-up-8123"), + argv=(DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), environment={}, cwd=tmp_path, stdin_bytes=None, @@ -999,14 +1019,16 @@ def test_container_plan_failure_starts_no_workload( }: assert runner.calls == [] expected_control_calls = { - "unsupported_compose_version": [("docker", "compose", "version", "--short")], + "unsupported_compose_version": [ + (DOCKER_EXECUTABLE, "compose", "version", "--short") + ], "unavailable_buildkit": [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ], "missing_contract_label": [ ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1016,7 +1038,7 @@ def test_container_plan_failure_starts_no_workload( ], "missing_manifest_label": [ ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1026,7 +1048,7 @@ def test_container_plan_failure_starts_no_workload( ], "incompatible_entrypoint": [ ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1062,10 +1084,10 @@ def test_generated_up_builds_then_inspects_before_any_workload(tmp_path: Path) - assert exit_code == 0 argv = [call.argv for call in runner.calls] assert argv[:5] == [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ( - "docker", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -1077,18 +1099,18 @@ def test_generated_up_builds_then_inspects_before_any_workload(tmp_path: Path) - "-", ), ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", "agentseek-up:8123", ), - ("docker", "rm", "-f", "agentseek-up-8123"), + (DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123"), ] assert len(argv) == 6 assert argv[5][:9] == ( - "docker", + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -1110,6 +1132,37 @@ def test_generated_up_builds_then_inspects_before_any_workload(tmp_path: Path) - ) +def test_up_resolves_docker_once_from_selected_path_and_freezes_absolute_argv( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("PATH", "/selected/docker/bin") + calls: list[tuple[str, str | None]] = [] + + def fake_which(executable: str, *, path: str | None = None) -> str: + calls.append((executable, path)) + return "/selected/docker/bin/docker" + + monkeypatch.setattr(shutil, "which", fake_which) + runner = BoundaryRunner() + + exit_code = main( + ["up", "--image", "agentseek:test", "--recreate"], + process_transport=runner, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert calls == [("docker", "/selected/docker/bin")] + assert runner.calls + assert {invocation.argv[0] for invocation in runner.calls} == { + "/selected/docker/bin/docker" + } + + def test_generated_up_materializes_compose_artifact_before_build_and_cleans_it( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -1123,20 +1176,24 @@ def test_generated_up_materializes_compose_artifact_before_build_and_cleans_it( 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"] + 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): - self.artifact_exists_during_build = any( - child.name.startswith("agentseek-compose-") + artifacts = [ + child for child in private_root.iterdir() - ) + if child.name.startswith("agentseek-compose-") + ] + self.artifact_exists_during_build = bool(artifacts) + self.artifact_contents_during_build = artifacts[0].read_bytes() return super().__call__(invocation) runner = ArtifactBoundaryRunner() @@ -1153,6 +1210,9 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: assert exit_code == 0 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()) == [] @@ -2579,14 +2639,14 @@ def test_build_command_plans_docker_build_from_generated_dockerfile( assert exit_code == 0 assert capture.calls is not None assert [call.argv for call in capture.calls[:2]] == [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (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", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -2624,7 +2684,9 @@ def test_build_command_rejects_unavailable_buildx_before_build(tmp_path: Path) - from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - capture = _ProcessCapture(return_codes={("docker", "buildx", "inspect"): 1}) + capture = _ProcessCapture( + return_codes={(DOCKER_EXECUTABLE, "buildx", "inspect"): 1} + ) stderr = io.StringIO() exit_code = main( @@ -2637,8 +2699,8 @@ def test_build_command_rejects_unavailable_buildx_before_build(tmp_path: Path) - assert exit_code == 2 assert capture.calls is not None assert [call.argv for call in capture.calls] == [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ] assert "builder is unavailable" in stderr.getvalue() @@ -2769,7 +2831,7 @@ def test_generated_up_uses_final_auth_selection_and_sanitized_build_stdin( assert capture.calls is not None build = _captured_image_build(capture) assert build.argv == ( - "docker", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -2980,7 +3042,7 @@ def test_up_custom_image_inspects_contract_and_runs_explicit_preloaded_mode( assert capture.calls is not None inspect = capture.calls[0] assert inspect.argv == ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -3533,13 +3595,13 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) assert exit_code == 0 assert capture.calls is not None assert capture.calls[1].argv == ( - "docker", + DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123", ) assert capture.calls[2].argv[:9] == ( - "docker", + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -3629,7 +3691,7 @@ def test_up_container_existence_probe_is_bounded_and_control_only( assert isinstance(probe, ControlQueryInvocation) assert probe.timeout_seconds > 0 assert probe.argv == ( - "docker", + DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123", @@ -3665,20 +3727,20 @@ def test_up_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: assert exit_code == 0 assert capture.calls is not None assert capture.calls[1].argv == ( - "docker", + DOCKER_EXECUTABLE, "compose", "version", "--short", ) assert capture.calls[2].argv == ( - "docker", + DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123", ) compose_invocation = capture.calls[3] assert compose_invocation.argv[:3] == ( - "docker", + DOCKER_EXECUTABLE, "compose", "--env-file", ) @@ -3722,7 +3784,7 @@ class ContentCapture(_ProcessCapture): compose_contents: bytes | None = None def __call__(self, invocation: ProcessInvocation) -> ProcessResult: - if invocation.argv[:3] == ("docker", "compose", "--env-file"): + if invocation.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file"): self.compose_contents = Path(invocation.argv[3]).read_bytes() return super().__call__(invocation) @@ -3748,7 +3810,7 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: compose = next( call for call in capture.calls - if call.argv[:3] == ("docker", "compose", "--env-file") + if call.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file") ) artifact = Path(compose.argv[3]) assert not artifact.exists() @@ -3770,7 +3832,7 @@ def test_up_compose_artifact_is_removed_after_compose_failure(tmp_path: Path) -> class FailureCapture(_ProcessCapture): def __call__(self, invocation: ProcessInvocation) -> ProcessResult: result = super().__call__(invocation) - if invocation.argv[:3] == ("docker", "compose", "--env-file"): + if invocation.argv[:3] == (DOCKER_EXECUTABLE, "compose", "--env-file"): artifact = Path(invocation.argv[3]) assert artifact.exists() artifact_paths.append(artifact) @@ -3887,15 +3949,15 @@ def test_up_command_rejects_existing_container_before_starting_compose_sidecars( assert capture.calls is not None assert [call.argv for call in capture.calls] == [ ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", "agentseek:test", ), - ("docker", "compose", "version", "--short"), - ("docker", "container", "inspect", "agentseek-up-8123"), + (DOCKER_EXECUTABLE, "compose", "version", "--short"), + (DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() @@ -3925,11 +3987,11 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( assert exit_code == 0 assert capture.calls is not None assert [call.argv for call in capture.calls[:2]] == [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ] assert capture.calls[2].argv == ( - "docker", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -3940,15 +4002,15 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( "-", ) assert capture.calls[2].stdin_bytes is not None - assert capture.calls[3].argv[:3] == ("docker", "image", "inspect") + assert capture.calls[3].argv[:3] == (DOCKER_EXECUTABLE, "image", "inspect") assert capture.calls[4].argv == ( - "docker", + DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8124", ) assert capture.calls[5].argv[:9] == ( - "docker", + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -3965,7 +4027,7 @@ def test_up_command_builds_image_when_missing_and_passes_postgres_uri( invocation.argv ) container_env = _application_environment(capture) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" + assert container_env["AGENTSEEK_GRAPHS"] == "/opt/agentseek/manifest.v1.json" assert ( container_env["METADATA_DB_URL"] == "postgresql://postgres:postgres@db/agentseek" @@ -4016,15 +4078,15 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( assert exit_code == 0 assert capture.calls is not None - assert capture.calls[3].argv[:3] == ("docker", "image", "inspect") + assert capture.calls[3].argv[:3] == (DOCKER_EXECUTABLE, "image", "inspect") assert capture.calls[4].argv == ( - "docker", + DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123", ) assert capture.calls[5].argv[:9] == ( - "docker", + DOCKER_EXECUTABLE, "run", "--detach", "--name", @@ -4036,7 +4098,7 @@ def test_up_command_passes_config_auth_env_and_containerizes_file_paths( ) assert "agentseek-up:8123" in capture.calls[5].argv container_env = _application_environment(capture) - assert container_env["AGENTSEEK_GRAPHS"] == "/deps/agent/langgraph.json" + 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" @@ -4197,7 +4259,7 @@ def test_up_command_returns_build_failure_without_running_container( _write_basic_langgraph_config(tmp_path) build_argv = ( - "docker", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -4219,8 +4281,8 @@ def test_up_command_returns_build_failure_without_running_container( assert exit_code == 9 assert capture.calls is not None assert [call.argv for call in capture.calls] == [ - ("docker", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), build_argv, ] @@ -4249,14 +4311,14 @@ def test_up_command_rejects_existing_container_without_recreate(tmp_path: Path) assert capture.calls is not None assert [call.argv for call in capture.calls] == [ ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", "[{{json .Config.Labels}},{{json .Config.Entrypoint}},{{json .Config.Cmd}}]", "agentseek:test", ), - ("docker", "container", "inspect", "agentseek-up-8123"), + (DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123"), ] assert "already exists" in stderr.getvalue() assert "--recreate" in stderr.getvalue() @@ -4269,6 +4331,7 @@ def test_container_exists_uses_a_private_bounded_query(tmp_path: Path) -> None: exists = cli_module._container_exists( "agentseek-up-8123", + docker_executable=DOCKER_EXECUTABLE, process_transport=capture, docker_control={}, cwd=tmp_path, @@ -4280,7 +4343,7 @@ def test_container_exists_uses_a_private_bounded_query(tmp_path: Path) -> None: assert isinstance(invocation, ControlQueryInvocation) assert invocation.timeout_seconds > 0 assert invocation.argv == ( - "docker", + DOCKER_EXECUTABLE, "container", "inspect", "agentseek-up-8123", diff --git a/tests/unit/test_container_boundary_acceptance.py b/tests/unit/test_container_boundary_acceptance.py index 2f9da0f..40558a0 100644 --- a/tests/unit/test_container_boundary_acceptance.py +++ b/tests/unit/test_container_boundary_acceptance.py @@ -20,6 +20,9 @@ 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(): @@ -157,12 +160,15 @@ def test_generated_compose_flow_uses_build_controls_for_direct_probe( ) -> 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): + 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 = {} @@ -193,6 +199,82 @@ def fake_probe(*, docker_environment, **_kwargs): 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( @@ -229,7 +311,7 @@ def fake_process(argv, **_kwargs): monkeypatch.setattr(module, "_safe_process", fake_process) invocation = ProcessInvocation( argv=( - "docker", + DOCKER_EXECUTABLE, "compose", "--env-file", "private.env", @@ -244,7 +326,7 @@ def fake_process(argv, **_kwargs): assert transport._render_compose(invocation).returncode == 0 assert calls == [ ( - "docker", + DOCKER_EXECUTABLE, "compose", "--env-file", "private.env", @@ -311,7 +393,7 @@ def test_probe_writes_and_reads_the_exact_private_result_basename( def fake_process(argv, *, cwd, environment, stdin=None, timeout=None): del cwd, stdin, timeout calls.append(argv) - if argv[:3] == ("docker", "container", "inspect"): + if argv[:3] == (DOCKER_EXECUTABLE, "container", "inspect"): return ProcessResult(returncode=1) script = argv[-1].replace("/result/", f"{result_path.parent}/") completed = subprocess.run( @@ -329,6 +411,7 @@ def fake_process(argv, *, cwd, environment, stdin=None, timeout=None): 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"]}, @@ -347,7 +430,7 @@ def fake_process(argv, *, cwd, environment, stdin=None, timeout=None): "synthetic:test", ) assert run_argv[-3:-1] == ("-I", "-c") - assert calls[1][:3] == ("docker", "container", "inspect") + assert calls[1][:3] == (DOCKER_EXECUTABLE, "container", "inspect") def test_probe_fails_if_auto_remove_leaves_the_owned_container( @@ -366,6 +449,7 @@ def test_probe_fails_if_auto_remove_leaves_the_owned_container( with pytest.raises(module.BoundaryFailure, match="cleanup"): module._run_probe( + docker_executable=DOCKER_EXECUTABLE, image="synthetic:test", application={}, docker_environment={"PATH": os.environ["PATH"]}, @@ -383,6 +467,7 @@ def test_owned_image_cleanup_requires_removal_and_absence( 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"]}, diff --git a/tests/unit/test_docker_runtime.py b/tests/unit/test_docker_runtime.py index 7a8fbfa..720dd84 100644 --- a/tests/unit/test_docker_runtime.py +++ b/tests/unit/test_docker_runtime.py @@ -3,7 +3,9 @@ import hashlib import json import os +import shutil import subprocess +import sys import uuid import zipfile from dataclasses import replace @@ -56,6 +58,77 @@ ) +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( @@ -66,6 +139,7 @@ def test_build_image_invocation_uses_stdin_buildx_and_secret(tmp_path: Path) -> 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", @@ -73,7 +147,7 @@ def test_build_image_invocation_uses_stdin_buildx_and_secret(tmp_path: Path) -> ) assert isinstance(invocation, BuildImageInvocation) assert invocation.argv == ( - "docker", + DOCKER_EXECUTABLE, "buildx", "build", "--load", @@ -104,7 +178,12 @@ def test_dockerfile_and_build_omit_pip_secret_when_not_configured( dockerfile_bytes=dockerfile, output_root=tmp_path / "bundle-no-pip-secret", ) - invocation = build_image_invocation(bundle, plan=plan, docker_control={}) + 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 @@ -138,7 +217,12 @@ def test_build_image_invocation_rejects_bundle_plan_mismatch( 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_control={}) + 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: @@ -152,7 +236,7 @@ def test_require_supported_buildx_uses_two_bounded_queries(tmp_path: Path) -> No def transport(invocation: ProcessInvocation) -> ProcessResult: calls.append(invocation) - if invocation.argv == ("docker", "buildx", "version"): + if invocation.argv == (DOCKER_EXECUTABLE, "buildx", "version"): return ProcessResult( returncode=0, stdout=b"github.com/docker/buildx v0.12.0 deadbeef\n", @@ -161,14 +245,15 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: 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", "buildx", "version"), - ("docker", "buildx", "inspect"), + (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) @@ -214,8 +299,15 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: return version_result if len(calls) == 1 else inspect_result with pytest.raises(DockerRuntimeError, match=match): - require_supported_buildx(transport=transport, docker_control={}, cwd=tmp_path) - assert all(call.argv[:3] != ("docker", "buildx", "build") for call in calls) + 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( @@ -236,6 +328,7 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: with pytest.raises(DockerRuntimeError, match="secret") as caught: require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, transport=transport, docker_control={}, cwd=tmp_path, @@ -260,7 +353,12 @@ def test_pip_config_swap_before_build_invocation_is_rejected(tmp_path: Path) -> 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_control={}) + 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: @@ -282,14 +380,15 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: 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", "buildx", "version"), - ("docker", "buildx", "inspect"), + (DOCKER_EXECUTABLE, "buildx", "version"), + (DOCKER_EXECUTABLE, "buildx", "inspect"), ] @@ -349,6 +448,7 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( } transport = SubprocessTransport() require_supported_buildx( + docker_executable=DOCKER_EXECUTABLE, transport=transport, docker_control=docker_control, cwd=tmp_path, @@ -358,6 +458,7 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( invocation = build_image_invocation( bundle, plan=plan, + docker_executable=DOCKER_EXECUTABLE, docker_control=docker_control, tag=tag, ) @@ -379,7 +480,7 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( errors="replace" ) history = subprocess.run( - ["docker", "image", "history", "--no-trunc", tag], + [DOCKER_EXECUTABLE, "image", "history", "--no-trunc", tag], cwd=tmp_path, env=docker_control, stdout=subprocess.PIPE, @@ -396,7 +497,7 @@ def test_buildx_secret_mount_consumes_canary_without_disclosure( assert secret not in repr(invocation) finally: subprocess.run( - ["docker", "image", "rm", "--force", tag], + [DOCKER_EXECUTABLE, "image", "rm", "--force", tag], cwd=tmp_path, env=docker_control, stdout=subprocess.PIPE, @@ -590,6 +691,7 @@ def test_compose_invocation_is_explicit_control_only_and_value_redacted( 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"}, @@ -600,7 +702,7 @@ def test_compose_invocation_is_explicit_control_only_and_value_redacted( ) assert invocation.argv == ( - "docker", + DOCKER_EXECUTABLE, "compose", "--env-file", str(env_file), @@ -638,6 +740,7 @@ def test_compose_invocation_rejects_missing_names_and_control_collisions( ) -> 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, @@ -658,6 +761,7 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: 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, @@ -665,7 +769,7 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: assert len(calls) == 1 query = calls[0] assert isinstance(query, ControlQueryInvocation) - assert query.argv == ("docker", "compose", "version", "--short") + assert query.argv == (DOCKER_EXECUTABLE, "compose", "version", "--short") assert dict(query.environment) == {"PATH": "/usr/bin"} assert query.timeout_seconds > 0 @@ -678,6 +782,7 @@ def transport(_invocation: ProcessInvocation) -> ProcessResult: with pytest.raises(DockerRuntimeError) as exc_info: require_supported_compose( + docker_executable=DOCKER_EXECUTABLE, transport=transport, docker_control={}, cwd=tmp_path, @@ -693,6 +798,7 @@ def transport(_invocation: ProcessInvocation) -> ProcessResult: with pytest.raises(DockerRuntimeError, match="2.24.0 or newer"): require_supported_compose( + docker_executable=DOCKER_EXECUTABLE, transport=transport, docker_control={}, cwd=tmp_path, @@ -703,7 +809,7 @@ 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", "run", "--rm"), + base_argv=(DOCKER_EXECUTABLE, "run", "--rm"), image="agentseek:test", docker_control={"PATH": "/usr/bin"}, application_payload={"OPENAI_API_KEY": "sk-$#=雪", "EMPTY": ""}, @@ -716,7 +822,7 @@ def test_docker_run_uses_names_in_argv_and_values_only_in_carrier( ) assert invocation.argv == ( - "docker", + DOCKER_EXECUTABLE, "run", "--rm", "-e", @@ -739,7 +845,7 @@ def test_docker_run_preserves_physical_newline_only_in_carrier( value = "first line\nsecond line" invocation = build_docker_run_invocation( - base_argv=("docker", "run", "--rm"), + base_argv=(DOCKER_EXECUTABLE, "run", "--rm"), image="agentseek:test", docker_control={}, application_payload={"MULTILINE": value}, @@ -750,7 +856,7 @@ def test_docker_run_preserves_physical_newline_only_in_carrier( assert invocation.environment["MULTILINE"] == value assert value not in " ".join(invocation.argv) assert invocation.argv == ( - "docker", + DOCKER_EXECUTABLE, "run", "--rm", "-e", @@ -767,7 +873,7 @@ def test_windows_docker_run_rejects_casefolded_cross_map_collision( match="Application payload collides with Docker control keys", ): build_docker_run_invocation( - base_argv=("docker", "run"), + base_argv=(DOCKER_EXECUTABLE, "run"), image="agentseek:test", docker_control={"Path": "control"}, application_payload={"PATH": "application"}, @@ -800,7 +906,7 @@ def test_windows_docker_run_rejects_casefolded_duplicates_within_map( ) -> None: with pytest.raises(ContainerPolicyError) as exc_info: build_docker_run_invocation( - base_argv=("docker", "run"), + base_argv=(DOCKER_EXECUTABLE, "run"), image="agentseek:test", docker_control=docker_control, application_payload=application_payload, @@ -814,7 +920,7 @@ def test_windows_docker_run_rejects_casefolded_duplicates_within_map( def test_linux_docker_run_keeps_case_sensitive_name_semantics(tmp_path: Path) -> None: invocation = build_docker_run_invocation( - base_argv=("docker", "run"), + base_argv=(DOCKER_EXECUTABLE, "run"), image="agentseek:test", docker_control={"Path": "control"}, application_payload={"PATH": "application"}, @@ -833,7 +939,7 @@ def test_nul_collision_is_rejected_before_value_free_collision_diagnostic( with pytest.raises(ContainerPolicyError) as exc_info: build_docker_run_invocation( - base_argv=("docker", "run"), + base_argv=(DOCKER_EXECUTABLE, "run"), image="agentseek:test", docker_control={name: "control"}, application_payload={name: "application"}, @@ -849,7 +955,7 @@ def test_nul_collision_is_rejected_before_value_free_collision_diagnostic( def test_non_run_docker_invocation_has_only_docker_control(tmp_path: Path) -> None: invocation = build_docker_control_invocation( - argv=("docker", "image", "inspect", "agentseek:test"), + argv=(DOCKER_EXECUTABLE, "image", "inspect", "agentseek:test"), docker_control={"PATH": "/usr/bin"}, cwd=tmp_path, ) @@ -875,7 +981,7 @@ def test_docker_run_rejects_collisions_and_nul( ) -> None: with pytest.raises(ContainerPolicyError): build_docker_run_invocation( - base_argv=("docker", "run"), + base_argv=(DOCKER_EXECUTABLE, "run"), image="agentseek:test", docker_control=docker_control, application_payload=application_payload, @@ -888,7 +994,7 @@ def test_query_invocation_is_bounded_and_redacted(tmp_path: Path) -> None: canary = "baked-env-canary" invocation = build_docker_query_invocation( argv=( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -918,7 +1024,7 @@ def test_query_timeout_is_value_free( ) -> None: def timeout(*args: object, **kwargs: object) -> subprocess.CompletedProcess[bytes]: raise subprocess.TimeoutExpired( - cmd=["docker", "image", "inspect", "secret-image-name"], + cmd=[DOCKER_EXECUTABLE, "image", "inspect", "secret-image-name"], timeout=1.0, output=b"private-output", stderr=b"private-error", @@ -926,7 +1032,7 @@ def timeout(*args: object, **kwargs: object) -> subprocess.CompletedProcess[byte monkeypatch.setattr(subprocess, "run", timeout) invocation = build_docker_query_invocation( - argv=("docker", "image", "inspect", "secret-image-name"), + argv=(DOCKER_EXECUTABLE, "image", "inspect", "secret-image-name"), docker_control={}, cwd=tmp_path, timeout_seconds=1.0, @@ -960,13 +1066,13 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) query = build_docker_query_invocation( - argv=("docker", "compose", "version", "--short"), + 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", "rm", "-f", "agentseek-up-8123"), + argv=(DOCKER_EXECUTABLE, "rm", "-f", "agentseek-up-8123"), docker_control={"PATH": "/usr/bin"}, cwd=tmp_path, ) @@ -1002,22 +1108,22 @@ def runner( adapter = LegacyRunnerAdapter(runner) control = build_docker_control_invocation( - argv=("docker", "rm", "-f", "test"), docker_control={}, cwd=tmp_path + argv=(DOCKER_EXECUTABLE, "rm", "-f", "test"), docker_control={}, cwd=tmp_path ) query = build_docker_query_invocation( - argv=("docker", "image", "inspect", "test"), + argv=(DOCKER_EXECUTABLE, "image", "inspect", "test"), docker_control={}, cwd=tmp_path, ) stdin_invocation = ProcessInvocation( - argv=("docker", "build", "-"), + argv=(DOCKER_EXECUTABLE, "build", "-"), environment={}, cwd=tmp_path, stdin_bytes=b"archive", ) assert adapter(control).returncode == 7 - assert calls == [(["docker", "rm", "-f", "test"], {}, str(tmp_path))] + 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) @@ -1073,7 +1179,7 @@ def fake_run( argv: list[str], **kwargs: object ) -> subprocess.CompletedProcess[bytes]: assert argv == [ - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1093,7 +1199,7 @@ def fake_run( monkeypatch.setattr(subprocess, "run", fake_run) invocation = build_docker_query_invocation( argv=( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1217,6 +1323,7 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: contract = inspect_image_contract( "agentseek:test", + docker_executable=DOCKER_EXECUTABLE, transport=transport, docker_control={"DOCKER_HOST": "unix:///docker.sock"}, cwd=tmp_path, @@ -1225,7 +1332,7 @@ def transport(invocation: ProcessInvocation) -> ProcessResult: assert len(calls) == 1 assert isinstance(calls[0], ControlQueryInvocation) assert calls[0].argv == ( - "docker", + DOCKER_EXECUTABLE, "image", "inspect", "--format", @@ -1256,6 +1363,10 @@ def transport(_invocation: ProcessInvocation) -> ProcessResult: with pytest.raises(ImageContractError) as caught: inspect_image_contract( - canary, transport=transport, docker_control={}, cwd=tmp_path + canary, + docker_executable=DOCKER_EXECUTABLE, + transport=transport, + docker_control={}, + cwd=tmp_path, ) assert canary not in str(caught.value) From a59478ce8455093dab80f15c953c16cc6a7b7e73 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:18:50 +0800 Subject: [PATCH 38/42] test: keep Docker boundary assertions platform-native --- tests/unit/test_cli.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 0f6a442..2eb28f8 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1139,12 +1139,15 @@ def test_up_resolves_docker_once_from_selected_path_and_freezes_absolute_argv( from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) - monkeypatch.setenv("PATH", "/selected/docker/bin") + 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 "/selected/docker/bin/docker" + return DOCKER_EXECUTABLE monkeypatch.setattr(shutil, "which", fake_which) runner = BoundaryRunner() @@ -1156,11 +1159,9 @@ def fake_which(executable: str, *, path: str | None = None) -> str: ) assert exit_code == 0 - assert calls == [("docker", "/selected/docker/bin")] + assert calls == [("docker", selected_path)] assert runner.calls - assert {invocation.argv[0] for invocation in runner.calls} == { - "/selected/docker/bin/docker" - } + assert {invocation.argv[0] for invocation in runner.calls} == {DOCKER_EXECUTABLE} def test_generated_up_materializes_compose_artifact_before_build_and_cleans_it( @@ -1193,7 +1194,14 @@ def __call__(self, invocation: ProcessInvocation) -> ProcessResult: if child.name.startswith("agentseek-compose-") ] self.artifact_exists_during_build = bool(artifacts) - self.artifact_contents_during_build = artifacts[0].read_bytes() + 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() From 92c7af36d648075e3dc7d13206804549e64dff55 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:38:27 +0800 Subject: [PATCH 39/42] ci: build Redis runtime from candidate wheel --- scripts/test-redis-runtime.sh | 42 ++++++++++++++++++++++++- tests/unit/test_redis_runtime_script.py | 15 ++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/scripts/test-redis-runtime.sh b/scripts/test-redis-runtime.sh index cfb6557..633656b 100644 --- a/scripts/test-redis-runtime.sh +++ b/scripts/test-redis-runtime.sh @@ -19,6 +19,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 +35,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" } @@ -343,7 +347,43 @@ docker network create "$NETWORK_NAME" >/dev/null mkdir -p "$STATE_DIR" SHUTDOWN_STATE_FILE="$(mktemp "$STATE_DIR/shutdown-state.XXXXXX")" -uv run agentseek-api build --config "$CONFIG_PATH" -t "$IMAGE_TAG" +CANDIDATE_DIR="$(mktemp -d "${TMPDIR:-/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 diff --git a/tests/unit/test_redis_runtime_script.py b/tests/unit/test_redis_runtime_script.py index 0975adb..8c8982e 100644 --- a/tests/unit/test_redis_runtime_script.py +++ b/tests/unit/test_redis_runtime_script.py @@ -1,6 +1,17 @@ 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 '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_runs_live_queue_ownership_tests() -> None: script = Path("scripts/test-redis-runtime.sh").read_text() @@ -56,7 +67,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 From cea82030c69f46b5003ea6adedf97664d24d80f1 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:43:17 +0800 Subject: [PATCH 40/42] fix: keep Redis candidate inside build root --- scripts/test-redis-runtime.sh | 4 ++-- tests/unit/test_redis_runtime_script.py | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/scripts/test-redis-runtime.sh b/scripts/test-redis-runtime.sh index 633656b..aab7ca3 100644 --- a/scripts/test-redis-runtime.sh +++ b/scripts/test-redis-runtime.sh @@ -344,10 +344,10 @@ 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")" -CANDIDATE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/agentseek-redis-candidate.XXXXXX")" +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 diff --git a/tests/unit/test_redis_runtime_script.py b/tests/unit/test_redis_runtime_script.py index 8c8982e..11c3746 100644 --- a/tests/unit/test_redis_runtime_script.py +++ b/tests/unit/test_redis_runtime_script.py @@ -5,6 +5,8 @@ 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 From c0c043df55c8a58926a5deef1f801b0bc4965e2a Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:51:15 +0800 Subject: [PATCH 41/42] fix: launch Redis proof in preloaded mode --- scripts/test-redis-runtime.sh | 4 +++- tests/unit/test_redis_runtime_script.py | 10 ++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/scripts/test-redis-runtime.sh b/scripts/test-redis-runtime.sh index aab7ca3..d38be7f 100644 --- a/scripts/test-redis-runtime.sh +++ b/scripts/test-redis-runtime.sh @@ -275,6 +275,7 @@ 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 SEEKDB_URL="${SEEKDB_URL}" \ -e OCEANBASE_HOST="${BACKEND_CONTAINER}" \ -e OCEANBASE_PORT="${OCEANBASE_PORT}" \ @@ -282,7 +283,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=$? @@ -415,6 +416,7 @@ 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 SEEKDB_URL="${SEEKDB_URL}" \ -e OCEANBASE_HOST="${BACKEND_CONTAINER}" \ -e OCEANBASE_PORT="${OCEANBASE_PORT}" \ diff --git a/tests/unit/test_redis_runtime_script.py b/tests/unit/test_redis_runtime_script.py index 11c3746..52a97a4 100644 --- a/tests/unit/test_redis_runtime_script.py +++ b/tests/unit/test_redis_runtime_script.py @@ -14,6 +14,16 @@ def test_redis_runtime_builds_image_from_exact_candidate_wheel() -> None: 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 ( + "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() From 1548c1d08d228a97a9136384afbef48fef967df3 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Fri, 21 Aug 2026 01:57:40 +0800 Subject: [PATCH 42/42] fix: carry auth into Redis proof containers --- scripts/test-redis-runtime.sh | 3 +++ tests/unit/test_redis_runtime_script.py | 5 +++++ 2 files changed, 8 insertions(+) diff --git a/scripts/test-redis-runtime.sh b/scripts/test-redis-runtime.sh index d38be7f..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}" @@ -276,6 +277,7 @@ start_worker() { -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}" \ @@ -417,6 +419,7 @@ docker run -d \ -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/tests/unit/test_redis_runtime_script.py b/tests/unit/test_redis_runtime_script.py index 52a97a4..ba21561 100644 --- a/tests/unit/test_redis_runtime_script.py +++ b/tests/unit/test_redis_runtime_script.py @@ -18,6 +18,11 @@ 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