From 0285033e535377db1c28fcd8cdebad324a34411d Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Tue, 11 Aug 2026 20:47:06 +0800 Subject: [PATCH 01/28] fix: preserve shell precedence for runtime config --- src/agentseek_api/cli.py | 23 ++++++++++------------ tests/unit/test_cli.py | 42 ++++++++++++++++++++++++++++------------ 2 files changed, 40 insertions(+), 25 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 0530d64..0fcd62d 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import TextIO +from pydantic_settings.sources.providers.dotenv import dotenv_values + from agentseek_api import __version__ from agentseek_api.settings import DEFAULT_API_PORT @@ -147,18 +149,8 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None def _parse_env_file(env_file: Path) -> dict[str, str]: - values: dict[str, str] = {} - for line_number, raw_line in enumerate(env_file.read_text(encoding="utf-8").splitlines(), start=1): - line = raw_line.strip() - if not line or line.startswith("#"): - continue - if line.startswith("export "): - line = line[len("export ") :].strip() - if "=" not in line: - raise CliError(f"Env file '{env_file}' has an invalid line {line_number}: '{raw_line}'.") - key, value = line.split("=", maxsplit=1) - values[key.strip()] = value.strip().strip("\"'") - return values + values = dotenv_values(env_file) + return {key: value for key, value in values.items() if value is not None} def _resolve_path_from_config(path_text: str, *, config_path: Path) -> Path: @@ -277,7 +269,8 @@ def build_runtime_env( cwd: Path, base_env: dict[str, str] | None = None, ) -> dict[str, str]: - env = dict(os.environ if base_env is None else base_env) + shell_env = dict(os.environ if base_env is None else base_env) + env: dict[str, str] = {} config: CliConfig | None = _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: @@ -290,6 +283,10 @@ def build_runtime_env( if not resolved_env_file.exists(): raise CliError(f"Env file '{resolved_env_file}' does not exist.") env.update(_parse_env_file(resolved_env_file)) + # The launching shell is the highest-precedence source. This is important + # for agentseek dev, whose child environment may also be described by a + # langgraph.json env file. + env.update(shell_env) if config_path is not None: env["AGENTSEEK_GRAPHS"] = str(config_path) return env diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 71a5de9..8c284cf 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -836,33 +836,51 @@ def test_build_command_plans_docker_build_from_generated_dockerfile(tmp_path: Pa assert 'CMD ["python", "-m", "agentseek_api.cli", "serve", "--host", "0.0.0.0", "--port", "2024"]' in generated -def test_build_runtime_env_rejects_invalid_env_lines(tmp_path: Path) -> None: - from agentseek_api.cli import build_runtime_env - - env_file = tmp_path / ".env" - env_file.write_text("BROKEN_LINE\n", encoding="utf-8") - - with pytest.raises(RuntimeError, match="invalid line 1"): - build_runtime_env(config_path=None, env_file=str(env_file), cwd=tmp_path, base_env={}) - - def test_build_runtime_env_parses_exported_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( - "# comment\nexport TOKEN='quoted-value'\nPLAIN=value\n", + '# comment\nexport TOKEN="quoted # value\nnext"\nPLAIN=value # inline comment\n', encoding="utf-8", ) env = build_runtime_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path, base_env={}) - assert env["TOKEN"] == "quoted-value" + assert env["TOKEN"] == "quoted # value\nnext" assert env["PLAIN"] == "value" assert env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) +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) + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path.write_text( + """ +{ + "graphs": {"chat": "chat.graph:graph"}, + "env": "./config.env" +} +""".strip(), + encoding="utf-8", + ) + cli_env = tmp_path / "override.env" + cli_env.write_text("TOKEN=from-cli-file\n", encoding="utf-8") + + env = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={"TOKEN": "from-shell"}, + ) + + assert env["TOKEN"] == "from-shell" + + def test_build_runtime_env_rejects_invalid_config_env_shape(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env From ebc80c74f3db54e8ba32dd291a94dd8a956763fc Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 10:22:03 +0800 Subject: [PATCH 02/28] fix: isolate container dotenv expansion and CLI tests --- pyproject.toml | 1 + src/agentseek_api/cli.py | 12 +++++++----- tests/unit/test_cli.py | 39 ++++++++++++++++++++++++++++++++++++--- uv.lock | 2 ++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 188459c..f4c7dc4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pymysql>=1.1.0", "langchain>=0.3.9", "mcp>=1.27.1,<2", + "python-dotenv>=1.0", "scalar-fastapi>=1.0.3", ] diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 0fcd62d..b5d21a0 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -14,7 +14,7 @@ from pathlib import Path from typing import TextIO -from pydantic_settings.sources.providers.dotenv import dotenv_values +from dotenv import dotenv_values from agentseek_api import __version__ from agentseek_api.settings import DEFAULT_API_PORT @@ -148,8 +148,8 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -def _parse_env_file(env_file: Path) -> dict[str, str]: - values = dotenv_values(env_file) +def _parse_env_file(env_file: Path, *, interpolate: bool = True) -> dict[str, str]: + values = dotenv_values(env_file, interpolate=interpolate) return {key: value for key, value in values.items() if value is not None} @@ -268,13 +268,14 @@ def build_runtime_env( env_file: str | None, cwd: Path, base_env: dict[str, str] | None = None, + interpolate_env_file: bool = True, ) -> dict[str, str]: shell_env = dict(os.environ if base_env is None else base_env) env: dict[str, str] = {} config: CliConfig | None = _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: - env.update(_parse_env_file(config.env_file)) + env.update(_parse_env_file(config.env_file, interpolate=interpolate_env_file)) env.update(config.env_mapping) if config.auth_path: env["AUTH_MODULE_PATH"] = config.auth_path @@ -282,7 +283,7 @@ def build_runtime_env( resolved_env_file = _resolve_path(env_file, cwd=cwd) if not resolved_env_file.exists(): raise CliError(f"Env file '{resolved_env_file}' does not exist.") - env.update(_parse_env_file(resolved_env_file)) + env.update(_parse_env_file(resolved_env_file, interpolate=interpolate_env_file)) # The launching shell is the highest-precedence source. This is important # for agentseek dev, whose child environment may also be described by a # langgraph.json env file. @@ -625,6 +626,7 @@ def build_container_env(*, config_path: Path, env_file: str | None, cwd: Path) - env_file=env_file, cwd=cwd, base_env=_ambient_container_env(), + interpolate_env_file=False, ) env["AGENTSEEK_GRAPHS"] = _container_config_path(config_path=config_path, cwd=cwd) auth_module_path = env.get("AUTH_MODULE_PATH") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 8c284cf..0c96163 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -12,6 +12,12 @@ from agentseek_api.services.langgraph_service import LangGraphService +def test_python_dotenv_dependency_is_available() -> None: + from dotenv import dotenv_values + + assert callable(dotenv_values) + + @dataclass class _RunCapture: calls: list[list[str]] | None = None @@ -223,9 +229,12 @@ def fake_scheduler_main() -> int: assert cli_module.os.environ.get(sentinel_key) == "before" -def test_dev_command_accepts_langgraph_cli_flags_and_env_file(tmp_path: Path) -> None: +def test_dev_command_accepts_langgraph_cli_flags_and_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + monkeypatch.delenv("AUTH_MODULE_PATH", raising=False) config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / ".env" env_file.write_text("AUTH_MODULE_PATH=test.module:backend\n", encoding="utf-8") @@ -255,9 +264,13 @@ def test_dev_command_accepts_langgraph_cli_flags_and_env_file(tmp_path: Path) -> assert capture.env["AUTH_MODULE_PATH"] == "test.module:backend" -def test_dev_command_loads_config_env_mapping_and_auth_path(tmp_path: Path) -> None: +def test_dev_command_loads_config_env_mapping_and_auth_path( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + for key in ("OPENAI_API_KEY", "FEATURE_FLAG", "AUTH_MODULE_PATH"): + monkeypatch.delenv(key, raising=False) package_dir = tmp_path / "chat" package_dir.mkdir() (package_dir / "__init__.py").write_text("", encoding="utf-8") @@ -292,9 +305,13 @@ def test_dev_command_loads_config_env_mapping_and_auth_path(tmp_path: Path) -> N assert capture.env["AUTH_MODULE_PATH"] == f"{(tmp_path / 'auth.py').resolve()}:auth" -def test_dev_command_merges_config_env_file_before_cli_env_file(tmp_path: Path) -> None: +def test_dev_command_merges_config_env_file_before_cli_env_file( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: from agentseek_api.cli import main + for key in ("TOKEN", "SHARED"): + monkeypatch.delenv(key, raising=False) config_path = _write_basic_langgraph_config(tmp_path) config_env = tmp_path / "config.env" config_env.write_text("TOKEN=from-config\nSHARED=config\n", encoding="utf-8") @@ -881,6 +898,22 @@ def test_build_runtime_env_shell_values_override_config_and_cli_dotenv(tmp_path: assert env["TOKEN"] == "from-shell" +def test_build_container_env_does_not_interpolate_disallowed_host_values( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import build_container_env + + monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / ".env" + env_file.write_text("OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", encoding="utf-8") + + env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) + + assert env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" + assert "PR69_DISALLOWED_SECRET" not in env + + def test_build_runtime_env_rejects_invalid_config_env_shape(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env diff --git a/uv.lock b/uv.lock index 5d02a21..5424e81 100644 --- a/uv.lock +++ b/uv.lock @@ -49,6 +49,7 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "pymysql" }, + { name = "python-dotenv" }, { name = "redis" }, { name = "scalar-fastapi" }, { name = "sqlalchemy" }, @@ -92,6 +93,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, { name = "pymysql", specifier = ">=1.1.0" }, + { name = "python-dotenv", specifier = ">=1.0" }, { name = "redis", specifier = ">=5.0.0" }, { name = "scalar-fastapi", specifier = ">=1.0.3" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, From e7dcbf8f9dbfdbdb544899fb9c43150762307db4 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 10:25:44 +0800 Subject: [PATCH 03/28] test: verify minimum deps and container env boundary --- .github/workflows/ci.yml | 6 +++ scripts/test_container_env_boundary.py | 61 ++++++++++++++++++++++++ scripts/test_minimum_cli_dependencies.py | 29 +++++++++++ 3 files changed, 96 insertions(+) create mode 100644 scripts/test_container_env_boundary.py create mode 100644 scripts/test_minimum_cli_dependencies.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa631b..06926da 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -93,6 +93,9 @@ jobs: - name: CLI config and Docker planning tests run: uv run pytest tests/unit/test_cli.py tests/unit/test_graph_manifest.py -q + - name: Minimum CLI dependency compatibility + run: uv run python scripts/test_minimum_cli_dependencies.py + embedded-seekdb-smoke: name: Embedded SeekDB Smoke runs-on: ubuntu-latest @@ -135,6 +138,9 @@ jobs: - name: CLI Docker smoke run: make test-cli-docker + - name: Container environment boundary smoke + run: uv run python scripts/test_container_env_boundary.py + sample-graphs: name: Sample Graphs runs-on: ubuntu-latest diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py new file mode 100644 index 0000000..7aaae2d --- /dev/null +++ b/scripts/test_container_env_boundary.py @@ -0,0 +1,61 @@ +"""Verify the container handoff does not expand secrets from the host shell.""" + +from __future__ import annotations + +import os +import subprocess +import tempfile +from pathlib import Path + + +def main() -> None: + from agentseek_api.cli import build_container_env + + with tempfile.TemporaryDirectory(prefix="agentseek-container-env-") as directory: + root = Path(directory) + package = root / "chat" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") + config = root / "langgraph.json" + config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") + env_file = root / ".env" + env_file.write_text("OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", encoding="utf-8") + + previous = os.environ.get("PR69_DISALLOWED_SECRET") + os.environ["PR69_DISALLOWED_SECRET"] = "host-sensitive-value" + try: + container_env = build_container_env(config_path=config, env_file=str(env_file), cwd=root) + finally: + if previous is None: + os.environ.pop("PR69_DISALLOWED_SECRET", None) + else: + os.environ["PR69_DISALLOWED_SECRET"] = previous + + assert container_env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" + assert "PR69_DISALLOWED_SECRET" not in container_env + result = subprocess.run( + [ + "docker", + "run", + "--rm", + "-e", + f"OPENAI_API_KEY={container_env['OPENAI_API_KEY']}", + "python:3.12-slim", + "python", + "-c", + "import os; print(os.environ['OPENAI_API_KEY'])", + ], + check=False, + capture_output=True, + text=True, + ) + if result.returncode != 0: + raise RuntimeError(f"Docker container smoke failed: {result.stderr.strip()}") + assert result.stdout.strip() == "${PR69_DISALLOWED_SECRET}" + assert "host-sensitive-value" not in result.stdout + print(result.stdout.strip()) + + +if __name__ == "__main__": + main() diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py new file mode 100644 index 0000000..9b33f92 --- /dev/null +++ b/scripts/test_minimum_cli_dependencies.py @@ -0,0 +1,29 @@ +"""Verify the CLI imports and runs with the declared minimum dotenv stack.""" + +from __future__ import annotations + +import subprocess +import sys +import tempfile +from pathlib import Path + + +def main() -> None: + repository = Path(__file__).resolve().parents[1] + with tempfile.TemporaryDirectory(prefix="agentseek-minimum-") as directory: + environment = Path(directory) / ".venv" + subprocess.run(["uv", "venv", "--python", sys.executable, str(environment)], check=True) + python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run( + ["uv", "pip", "install", "--python", str(python), "pydantic-settings==2.4.0", "python-dotenv>=1.0"], + check=True, + ) + subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-deps", "-e", str(repository)], check=True) + subprocess.run( + [str(python), "-c", "from agentseek_api.cli import main; raise SystemExit(main(['version']))"], + check=True, + ) + + +if __name__ == "__main__": + main() From ceab7e79a7ec8cce6ed6780506d88d2dcaa25d29 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 10:35:30 +0800 Subject: [PATCH 04/28] test: cover runtime dotenv compatibility boundaries --- scripts/test-cli-docker.sh | 31 ++++++++++++++++++++++++ scripts/test_minimum_cli_dependencies.py | 9 +++---- tests/unit/test_cli.py | 13 ++++++++++ 3 files changed, 48 insertions(+), 5 deletions(-) diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index 3998042..d2f1132 100644 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -8,12 +8,14 @@ IMAGE_TAG="${IMAGE_TAG:-agentseek-api-cli-smoke:latest}" DB_CONTAINER="${DB_CONTAINER:-agentseek-cli-mysql}" APP_CONTAINER="${APP_CONTAINER:-agentseek-up-8123}" APP_CONTAINER_AUTOBUILD="${APP_CONTAINER_AUTOBUILD:-agentseek-up-8124}" +APP_CONTAINER_SECURITY="${APP_CONTAINER_SECURITY:-agentseek-up-8125}" PG_CONTAINER="${PG_CONTAINER:-agentseek-cli-postgres}" TMP_DIR="${TMP_DIR:-$ROOT_DIR/.tmp/cli-docker}" 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 "$APP_CONTAINER_SECURITY" >/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 } @@ -21,6 +23,7 @@ cleanup() { print_logs() { docker logs "$APP_CONTAINER" || true docker logs "$APP_CONTAINER_AUTOBUILD" || true + docker logs "$APP_CONTAINER_SECURITY" || true docker logs "$DB_CONTAINER" || true docker logs "$PG_CONTAINER" || true } @@ -116,6 +119,34 @@ if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8123 exit 1 fi +cat >"$TMP_DIR/disallowed.env" <<'EOF' +OPENAI_API_KEY=${PR69_DISALLOWED_SECRET} +EOF + +if ! env -u OPENAI_API_KEY PR69_DISALLOWED_SECRET=host-sensitive-value uv run agentseek-api up \ + --config "$CONFIG_PATH" \ + --image "$IMAGE_TAG" \ + --port 8125 \ + --env-file "$TMP_DIR/disallowed.env" \ + --recreate; then + print_logs + exit 1 +fi + +for _ in $(seq 1 60); do + if docker inspect "$APP_CONTAINER_SECURITY" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then + break + fi + sleep 1 +done + +CONTAINER_SECRET="$(docker exec "$APP_CONTAINER_SECURITY" python -c 'import os; print(os.environ["OPENAI_API_KEY"])')" +if [[ "$CONTAINER_SECRET" != '${PR69_DISALLOWED_SECRET}' || "$CONTAINER_SECRET" == 'host-sensitive-value' ]]; then + print_logs + echo "Container environment expanded a host-only secret." >&2 + exit 1 +fi + DUPLICATE_STDERR="$TMP_DIR/up-duplicate.stderr" set +e uv run agentseek-api up \ diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index 9b33f92..9d4337c 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -1,4 +1,4 @@ -"""Verify the CLI imports and runs with the declared minimum dotenv stack.""" +"""Verify the installed CLI runs with the declared minimum dotenv stack.""" from __future__ import annotations @@ -19,10 +19,9 @@ def main() -> None: check=True, ) subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-deps", "-e", str(repository)], check=True) - subprocess.run( - [str(python), "-c", "from agentseek_api.cli import main; raise SystemExit(main(['version']))"], - check=True, - ) + cli = environment / ("Scripts/agentseek-api.exe" if sys.platform == "win32" else "bin/agentseek-api") + result = subprocess.run([str(cli), "version"], check=True, capture_output=True, text=True) + assert result.stdout.strip() == "agentseek-api 0.2.1" if __name__ == "__main__": diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 0c96163..71130d7 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -870,6 +870,19 @@ def test_build_runtime_env_parses_exported_values(tmp_path: Path) -> None: assert env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) +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={}) + + 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: from agentseek_api.cli import build_runtime_env From ee24e5de3b1baf80393197d3a97185df5fe182ff Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 10:56:28 +0800 Subject: [PATCH 05/28] docs: add runtime migration handoff --- AGENTSEEK_HANDOFF_2026-08-12.md | 57 +++++++++++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 AGENTSEEK_HANDOFF_2026-08-12.md diff --git a/AGENTSEEK_HANDOFF_2026-08-12.md b/AGENTSEEK_HANDOFF_2026-08-12.md new file mode 100644 index 0000000..3b0a788 --- /dev/null +++ b/AGENTSEEK_HANDOFF_2026-08-12.md @@ -0,0 +1,57 @@ +# AgentSeek API 重启交接记录 + +## 当前仓库状态 + +- 仓库:`agentseek-api` +- 分支:`fix/runtime-dotenv-precedence` +- 最近提交:`ceab7e7 test: cover runtime dotenv compatibility boundaries` +- 工作区在写入本文件前是干净的。 +- 本次改动尚未推送;目标是 fork 远程的同名分支。 + +## 本次修复内容 + +1. 使用稳定的 `python-dotenv` 公共接口,兼容声明的最低 `pydantic-settings==2.4.0`。 +2. dotenv 插值只在允许的运行时环境中进行,避免宿主机未允许的变量被带入容器。 +3. 保持配置文件、CLI dotenv 和启动 shell 的优先级:shell > CLI dotenv > 配置 dotenv。 +4. 增加最低依赖环境下真实 `agentseek-api` console script 的验证。 +5. 增加真实 `agentseek-api up` Docker 路径的容器环境边界回归测试。 +6. 增加无值 dotenv 行的行为测试:忽略该行但继续读取后续合法配置。 + +## 已完成的验证 + +- `tests/unit/test_cli.py`:66 passed +- Ruff:通过 +- 最低依赖组合:`pydantic-settings==2.4.0` + `python-dotenv>=1.0`,真实执行 `agentseek-api version`:通过 +- 独立容器边界测试:通过;容器内保留 `${PR69_DISALLOWED_SECRET}` 字面量,没有展开宿主机值。 +- 完整 `make test-cli-docker`:已启动真实 `agentseek-api up` 流程,但两次拉取 Docker Hub 的 `python:3.12-slim` 都返回 `502 Bad Gateway`,未进入业务断言。 + +## Docker/OrbStack 状态 + +- OrbStack 曾成功恢复并报告 Docker `29.4.0`。 +- 中断镜像拉取后 OrbStack 可能再次处于 stopped 状态;重启命令: + + ```bash + orbctl start + ``` + +- 本地测试可先清理代理变量,再使用国内镜像拉取并打成本地标签: + + ```bash + env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ + -u http_proxy -u https_proxy -u all_proxy \ + docker pull docker.m.daocloud.io/library/python:3.12-slim + docker tag docker.m.daocloud.io/library/python:3.12-slim python:3.12-slim + ``` + +- 完整测试命令: + + ```bash + make test-cli-docker + ``` + +## 推送/PR 注意事项 + +- 不要提交密钥、`.env` 文件或生成项目。 +- 本次新增交接文件仅记录工作状态,不包含 API key。 +- 推送后 CI 应执行最低依赖、CLI 兼容性和 Docker runtime 测试。 +- 如果 Docker Hub 仍返回 502,应记录为外部镜像仓库失败,不要修改生产镜像默认地址来绕过本地问题。 From 3237c3237d0a97a5d1d851f21eba79f7bd7cb291 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 11:11:45 +0800 Subject: [PATCH 06/28] chore: remove local handoff document --- AGENTSEEK_HANDOFF_2026-08-12.md | 57 --------------------------------- 1 file changed, 57 deletions(-) delete mode 100644 AGENTSEEK_HANDOFF_2026-08-12.md diff --git a/AGENTSEEK_HANDOFF_2026-08-12.md b/AGENTSEEK_HANDOFF_2026-08-12.md deleted file mode 100644 index 3b0a788..0000000 --- a/AGENTSEEK_HANDOFF_2026-08-12.md +++ /dev/null @@ -1,57 +0,0 @@ -# AgentSeek API 重启交接记录 - -## 当前仓库状态 - -- 仓库:`agentseek-api` -- 分支:`fix/runtime-dotenv-precedence` -- 最近提交:`ceab7e7 test: cover runtime dotenv compatibility boundaries` -- 工作区在写入本文件前是干净的。 -- 本次改动尚未推送;目标是 fork 远程的同名分支。 - -## 本次修复内容 - -1. 使用稳定的 `python-dotenv` 公共接口,兼容声明的最低 `pydantic-settings==2.4.0`。 -2. dotenv 插值只在允许的运行时环境中进行,避免宿主机未允许的变量被带入容器。 -3. 保持配置文件、CLI dotenv 和启动 shell 的优先级:shell > CLI dotenv > 配置 dotenv。 -4. 增加最低依赖环境下真实 `agentseek-api` console script 的验证。 -5. 增加真实 `agentseek-api up` Docker 路径的容器环境边界回归测试。 -6. 增加无值 dotenv 行的行为测试:忽略该行但继续读取后续合法配置。 - -## 已完成的验证 - -- `tests/unit/test_cli.py`:66 passed -- Ruff:通过 -- 最低依赖组合:`pydantic-settings==2.4.0` + `python-dotenv>=1.0`,真实执行 `agentseek-api version`:通过 -- 独立容器边界测试:通过;容器内保留 `${PR69_DISALLOWED_SECRET}` 字面量,没有展开宿主机值。 -- 完整 `make test-cli-docker`:已启动真实 `agentseek-api up` 流程,但两次拉取 Docker Hub 的 `python:3.12-slim` 都返回 `502 Bad Gateway`,未进入业务断言。 - -## Docker/OrbStack 状态 - -- OrbStack 曾成功恢复并报告 Docker `29.4.0`。 -- 中断镜像拉取后 OrbStack 可能再次处于 stopped 状态;重启命令: - - ```bash - orbctl start - ``` - -- 本地测试可先清理代理变量,再使用国内镜像拉取并打成本地标签: - - ```bash - env -u HTTP_PROXY -u HTTPS_PROXY -u ALL_PROXY \ - -u http_proxy -u https_proxy -u all_proxy \ - docker pull docker.m.daocloud.io/library/python:3.12-slim - docker tag docker.m.daocloud.io/library/python:3.12-slim python:3.12-slim - ``` - -- 完整测试命令: - - ```bash - make test-cli-docker - ``` - -## 推送/PR 注意事项 - -- 不要提交密钥、`.env` 文件或生成项目。 -- 本次新增交接文件仅记录工作状态,不包含 API key。 -- 推送后 CI 应执行最低依赖、CLI 兼容性和 Docker runtime 测试。 -- 如果 Docker Hub 仍返回 502,应记录为外部镜像仓库失败,不要修改生产镜像默认地址来绕过本地问题。 From 63fcf6d3a753e8db7eec4bc67e1db311af95ca14 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 14:19:19 +0800 Subject: [PATCH 07/28] fix: safely interpolate container dotenv values --- scripts/test_minimum_cli_dependencies.py | 11 +++++- src/agentseek_api/cli.py | 45 ++++++++++++++++++++---- tests/unit/test_cli.py | 44 ++++++++++++++++++++++- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index 9d4337c..8ec521b 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -15,7 +15,16 @@ def main() -> None: subprocess.run(["uv", "venv", "--python", sys.executable, str(environment)], check=True) python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") subprocess.run( - ["uv", "pip", "install", "--python", str(python), "pydantic-settings==2.4.0", "python-dotenv>=1.0"], + [ + "uv", + "pip", + "install", + "--python", + str(python), + "pydantic-settings==2.4.0", + "pydantic==2.8.0", + "python-dotenv==1.0.0", + ], check=True, ) subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-deps", "-e", str(repository)], check=True) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index b5d21a0..5dd25a4 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -3,6 +3,7 @@ import argparse import json import os +import re import signal import subprocess import sys @@ -148,9 +149,34 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -def _parse_env_file(env_file: Path, *, interpolate: bool = True) -> dict[str, str]: - values = dotenv_values(env_file, interpolate=interpolate) - return {key: value for key, value in values.items() if value is not None} +_ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)") + + +def _resolve_env_references(value: str, *, context: dict[str, str]) -> str: + def replace(match: re.Match[str]) -> str: + key = match.group(1) or match.group(2) + return context.get(key, match.group(0)) + + return _ENV_REFERENCE.sub(replace, value) + + +def _parse_env_file(env_file: Path, *, base_env: dict[str, str] | None = None) -> dict[str, str]: + """Parse dotenv syntax and resolve only previously selected values. + + python-dotenv supplies the standards-compliant parser. Interpolation is + deliberately performed here so callers can provide a restricted context + for container handoff instead of exposing the full host environment. + """ + raw_values = dotenv_values(env_file, interpolate=False) + context = dict(base_env or {}) + values: dict[str, str] = {} + for key, value in raw_values.items(): + if value is None: + continue + resolved = _resolve_env_references(value, context=context) + values[key] = resolved + context[key] = resolved + return values def _resolve_path_from_config(path_text: str, *, config_path: Path) -> Path: @@ -268,22 +294,28 @@ def build_runtime_env( env_file: str | None, cwd: Path, base_env: dict[str, str] | None = None, - interpolate_env_file: bool = True, ) -> dict[str, str]: shell_env = dict(os.environ if base_env is None else base_env) env: dict[str, str] = {} + interpolation_context = dict(shell_env) config: CliConfig | None = _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: - env.update(_parse_env_file(config.env_file, interpolate=interpolate_env_file)) + parsed_config_env = _parse_env_file(config.env_file, base_env=interpolation_context) + env.update(parsed_config_env) + interpolation_context.update(parsed_config_env) env.update(config.env_mapping) + interpolation_context.update(config.env_mapping) if config.auth_path: env["AUTH_MODULE_PATH"] = config.auth_path + interpolation_context["AUTH_MODULE_PATH"] = config.auth_path if env_file: resolved_env_file = _resolve_path(env_file, cwd=cwd) if not resolved_env_file.exists(): raise CliError(f"Env file '{resolved_env_file}' does not exist.") - env.update(_parse_env_file(resolved_env_file, interpolate=interpolate_env_file)) + parsed_cli_env = _parse_env_file(resolved_env_file, base_env=interpolation_context) + env.update(parsed_cli_env) + interpolation_context.update(parsed_cli_env) # The launching shell is the highest-precedence source. This is important # for agentseek dev, whose child environment may also be described by a # langgraph.json env file. @@ -626,7 +658,6 @@ def build_container_env(*, config_path: Path, env_file: str | None, cwd: Path) - env_file=env_file, cwd=cwd, base_env=_ambient_container_env(), - interpolate_env_file=False, ) env["AGENTSEEK_GRAPHS"] = _container_config_path(config_path=config_path, cwd=cwd) auth_module_path = env.get("AUTH_MODULE_PATH") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 71130d7..b008d6a 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1135,7 +1135,10 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) config_path = _write_basic_langgraph_config(tmp_path) env_file = tmp_path / "docker.env" env_file.write_text( - "METADATA_DB_URL=sqlite+aiosqlite:////tmp/agentseek.db\nOCEANBASE_HOST=host.docker.internal\n", + "METADATA_DB_URL=sqlite+aiosqlite:////tmp/agentseek.db\n" + "OCEANBASE_HOST=host.docker.internal\n" + "API_ORIGIN=https://api.example.test\n" + "OPENAI_BASE_URL=${API_ORIGIN}/v1\n", encoding="utf-8", ) capture = _RunCapture() @@ -1176,6 +1179,7 @@ def test_up_command_plans_docker_run_with_recreate_and_env_file(tmp_path: Path) 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_command_supports_docker_compose_sidecars(tmp_path: Path) -> None: @@ -1402,6 +1406,44 @@ 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_resolves_same_file_references_without_expanding_disallowed_host_values( + 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( + "API_ORIGIN=https://api.example.test\n" + "OPENAI_BASE_URL=${API_ORIGIN}/v1\n" + "OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", + encoding="utf-8", + ) + monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") + capture = _RunCapture() + + exit_code = main( + [ + "up", + "--config", + str(config_path), + "--image", + "agentseek:test", + "--env-file", + str(env_file), + ], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + container_env = _docker_env_from_run_command(capture.calls[1]) + assert container_env["OPENAI_BASE_URL"] == "https://api.example.test/v1" + assert container_env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" + assert "PR69_DISALLOWED_SECRET" not in container_env + + def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: From 2b491b1ccbbb2112ac60197b14ffbf1e89398ed7 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Wed, 12 Aug 2026 14:30:14 +0800 Subject: [PATCH 08/28] fix: preserve dotenv default expansion semantics --- src/agentseek_api/cli.py | 13 +++++++--- tests/unit/test_cli.py | 52 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 5dd25a4..6375443 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -149,13 +149,20 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -_ENV_REFERENCE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)") +_ENV_REFERENCE = re.compile( + r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}" +) def _resolve_env_references(value: str, *, context: dict[str, str]) -> str: def replace(match: re.Match[str]) -> str: - key = match.group(1) or match.group(2) - return context.get(key, match.group(0)) + key = match.group(1) + default = match.group(2) + if key in context: + return context[key] + if default is not None: + return default + return match.group(0) return _ENV_REFERENCE.sub(replace, value) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index b008d6a..dd00fe3 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -344,6 +344,33 @@ def test_dev_command_merges_config_env_file_before_cli_env_file( assert capture.env["SHARED"] == "override" +def test_dev_command_preserves_dotenv_default_and_bare_variable_syntax( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import main + + monkeypatch.delenv("API_ORIGIN", raising=False) + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / "defaults.env" + env_file.write_text( + "OPENAI_BASE_URL=${API_ORIGIN:-https://default.example.test}/v1\n" + "BARE_REFERENCE=$API_ORIGIN\n", + encoding="utf-8", + ) + capture = _RunCapture() + + exit_code = main( + ["dev", "--config", str(config_path), "--env-file", str(env_file), "--no-reload"], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.env is not None + assert capture.env["OPENAI_BASE_URL"] == "https://default.example.test/v1" + assert capture.env["BARE_REFERENCE"] == "$API_ORIGIN" + + def test_dev_command_rejects_unsupported_langgraph_flags(tmp_path: Path) -> None: from agentseek_api.cli import main @@ -1444,6 +1471,31 @@ def test_up_command_resolves_same_file_references_without_expanding_disallowed_h assert "PR69_DISALLOWED_SECRET" not in container_env +def test_up_command_preserves_dotenv_default_and_bare_variable_syntax(tmp_path: Path) -> 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_BASE_URL=${MISSING_API_ORIGIN:-https://default.example.test}/v1\n" + "BARE_REFERENCE=$MISSING_API_ORIGIN\n", + encoding="utf-8", + ) + capture = _RunCapture() + + exit_code = main( + ["up", "--config", str(config_path), "--image", "agentseek:test", "--env-file", str(env_file)], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.calls is not None + container_env = _docker_env_from_run_command(capture.calls[1]) + assert container_env["OPENAI_BASE_URL"] == "https://default.example.test/v1" + assert container_env["BARE_REFERENCE"] == "$MISSING_API_ORIGIN" + + def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: From cb84614bf6c24fc3e44e3504b533552e98e192e2 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Thu, 13 Aug 2026 10:43:06 +0800 Subject: [PATCH 09/28] fix: align dotenv interpolation contracts --- pyproject.toml | 2 +- scripts/dotenv_conformance.py | 162 +++++++++++++++++++ scripts/test_container_env_boundary.py | 158 ++++++++++++++----- scripts/test_minimum_cli_dependencies.py | 17 ++ src/agentseek_api/cli.py | 181 +++++++++++++-------- tests/unit/test_cli.py | 193 +++++++++++++++++++++++ uv.lock | 2 +- 7 files changed, 613 insertions(+), 102 deletions(-) create mode 100644 scripts/dotenv_conformance.py diff --git a/pyproject.toml b/pyproject.toml index f4c7dc4..e3c94f1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,7 +25,7 @@ dependencies = [ "pymysql>=1.1.0", "langchain>=0.3.9", "mcp>=1.27.1,<2", - "python-dotenv>=1.0", + "python-dotenv>=1.0,<1.3", "scalar-fastapi>=1.0.3", ] diff --git a/scripts/dotenv_conformance.py b/scripts/dotenv_conformance.py new file mode 100644 index 0000000..44bae91 --- /dev/null +++ b/scripts/dotenv_conformance.py @@ -0,0 +1,162 @@ +"""Shared dotenv interpolation cases for supported-version and handoff tests.""" + +from __future__ import annotations + +import importlib.metadata +import os +import tempfile +from pathlib import Path + +DOTENV_CONFORMANCE_CASES = ( + { + "name": "missing", + "contents": "CONF_MISSING=prefix-${PR69_MISSING}-suffix\n", + "ambient": {}, + "container_expected": {"CONF_MISSING": "prefix-${PR69_MISSING}-suffix"}, + "container_absent": (), + }, + { + "name": "duplicate-order", + "contents": ( + "CONF_ORIGIN=https://first.example\n" + "CONF_ORDERED=${CONF_ORIGIN}/v1\n" + "CONF_ORIGIN=https://second.example\n" + ), + "ambient": {}, + "container_expected": { + "CONF_ORIGIN": "https://second.example", + "CONF_ORDERED": "https://first.example/v1", + }, + "container_absent": (), + }, + { + "name": "broad-names", + "contents": "A.B=dotted\n1LEADING=digit\nCONF_BROAD=${A.B}-${1LEADING}\n", + "ambient": {}, + "container_expected": {"CONF_BROAD": "dotted-digit"}, + "container_absent": (), + }, + { + "name": "multiline-default", + "contents": 'CONF_MULTILINE="${PR69_MISSING:-first line\nsecond line}"\n', + "ambient": {}, + "container_expected": {"CONF_MULTILINE": "first line\nsecond line"}, + "container_absent": (), + }, + { + "name": "bare-default", + "contents": "CONF_BARE=$PR69_MISSING\nCONF_DEFAULT=${PR69_MISSING:-fallback}\n", + "ambient": {}, + "container_expected": { + "CONF_BARE": "$PR69_MISSING", + "CONF_DEFAULT": "fallback", + }, + "container_absent": (), + }, + { + "name": "empty-valueless", + "contents": ( + "CONF_EMPTY=\n" + "CONF_VALUELESS\n" + "CONF_FROM_EMPTY=${CONF_EMPTY:-fallback}\n" + "CONF_FROM_VALUELESS=${CONF_VALUELESS:-fallback}\n" + ), + "ambient": {}, + "container_expected": { + "CONF_EMPTY": "", + "CONF_FROM_EMPTY": "", + "CONF_FROM_VALUELESS": "", + }, + "container_absent": ("CONF_VALUELESS",), + }, + { + "name": "allowed-disallowed-ambient", + "contents": ( + "CONF_ALLOWED=${OPENAI_ALLOWED_SOURCE}\n" + "CONF_DISALLOWED=${PR69_DISALLOWED_SECRET}\n" + ), + "ambient": { + "OPENAI_ALLOWED_SOURCE": "allowlisted-source", + "PR69_DISALLOWED_SECRET": "host-sensitive-value", + }, + "container_expected": { + "CONF_ALLOWED": "allowlisted-source", + "CONF_DISALLOWED": "${PR69_DISALLOWED_SECRET}", + }, + "container_absent": ("PR69_DISALLOWED_SECRET",), + }, +) + +DOTENV_CONFORMANCE_ENV_KEYS = frozenset( + { + "PR69_MISSING", + "PR69_DISALLOWED_SECRET", + "OPENAI_ALLOWED_SOURCE", + "CONF_ORIGIN", + "A.B", + "1LEADING", + "CONF_EMPTY", + "CONF_VALUELESS", + } +) + +CROSS_LAYER_TOMBSTONE_CASES = ( + { + "name": "config-dotenv", + "config_env": "./config.env", + "config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n", + "shell_env": {}, + "expected": {}, + }, + { + "name": "config-mapping", + "config_env": {"CONF_TOMBSTONE": "from-config-mapping"}, + "config_dotenv": None, + "shell_env": {}, + "expected": {}, + }, + { + "name": "allowlisted-shell-restores", + "config_env": {"OPENAI_API_KEY": "from-config"}, + "config_dotenv": None, + "shell_env": {"OPENAI_API_KEY": "from-shell"}, + "expected": {"OPENAI_API_KEY": "from-shell"}, + }, +) + + +def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> None: + """Compare the runtime loader with the installed python-dotenv version.""" + from dotenv import dotenv_values + + from agentseek_api.cli import build_runtime_env + + if expected_dotenv_version is not None: + assert importlib.metadata.version("python-dotenv") == expected_dotenv_version + + previous = {key: os.environ.get(key) for key in DOTENV_CONFORMANCE_ENV_KEYS} + try: + with tempfile.TemporaryDirectory(prefix="agentseek-dotenv-") as directory: + root = Path(directory) + for index, case in enumerate(DOTENV_CONFORMANCE_CASES): + for key in DOTENV_CONFORMANCE_ENV_KEYS: + os.environ.pop(key, None) + os.environ.update(case["ambient"]) + env_file = root / f"{index}.env" + env_file.write_text(case["contents"], encoding="utf-8") + upstream = dotenv_values(env_file) + expected = {key: value for key, value in upstream.items() if value is not None} + actual = build_runtime_env( + config_path=None, + env_file=str(env_file), + cwd=root, + base_env=dict(os.environ), + ) + assert {key: actual[key] for key in expected} == expected, case["name"] + assert all(key not in actual for key, value in upstream.items() if value is None), case["name"] + finally: + for key, value in previous.items(): + if value is None: + os.environ.pop(key, None) + else: + os.environ[key] = value diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 7aaae2d..710f3a9 100644 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -1,60 +1,142 @@ -"""Verify the container handoff does not expand secrets from the host shell.""" +"""Verify the shared dotenv matrix through the real container handoff.""" from __future__ import annotations +import json import os import subprocess +import sys import tempfile from pathlib import Path +from dotenv_conformance import ( + CROSS_LAYER_TOMBSTONE_CASES, + DOTENV_CONFORMANCE_CASES, + DOTENV_CONFORMANCE_ENV_KEYS, +) -def main() -> None: - from agentseek_api.cli import build_container_env - with tempfile.TemporaryDirectory(prefix="agentseek-container-env-") as directory: - root = Path(directory) - package = root / "chat" - package.mkdir() - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") - config = root / "langgraph.json" - config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") - env_file = root / ".env" - env_file.write_text("OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", encoding="utf-8") - - previous = os.environ.get("PR69_DISALLOWED_SECRET") - os.environ["PR69_DISALLOWED_SECRET"] = "host-sensitive-value" - try: - container_env = build_container_env(config_path=config, env_file=str(env_file), cwd=root) - finally: - if previous is None: - os.environ.pop("PR69_DISALLOWED_SECRET", None) - else: - os.environ["PR69_DISALLOWED_SECRET"] = previous - - assert container_env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" - assert "PR69_DISALLOWED_SECRET" not in container_env +def _inspect_real_up( + *, + root: Path, + config: Path, + env_file: Path, + port: int, + process_env: dict[str, str], +) -> dict[str, str]: + container_name = f"agentseek-up-{port}" + try: result = subprocess.run( [ - "docker", - "run", - "--rm", - "-e", - f"OPENAI_API_KEY={container_env['OPENAI_API_KEY']}", + sys.executable, + "-m", + "agentseek_api.cli", + "up", + "--config", + str(config), + "--image", "python:3.12-slim", - "python", - "-c", - "import os; print(os.environ['OPENAI_API_KEY'])", + "--port", + str(port), + "--env-file", + str(env_file), + "--recreate", ], + cwd=root, + env=process_env, check=False, capture_output=True, text=True, ) if result.returncode != 0: - raise RuntimeError(f"Docker container smoke failed: {result.stderr.strip()}") - assert result.stdout.strip() == "${PR69_DISALLOWED_SECRET}" - assert "host-sensitive-value" not in result.stdout - print(result.stdout.strip()) + raise RuntimeError(f"agentseek-api up smoke failed: {result.stderr.strip()}") + inspected = subprocess.run( + ["docker", "inspect", container_name, "--format", "{{json .Config.Env}}"], + check=True, + capture_output=True, + text=True, + ) + assert "host-sensitive-value" not in inspected.stdout + return dict(entry.split("=", maxsplit=1) for entry in json.loads(inspected.stdout)) + finally: + subprocess.run( + ["docker", "rm", "-f", container_name], + check=False, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + +def main() -> None: + from agentseek_api.cli import _CONTAINER_ENV_PREFIXES + + inherited_allowlisted = { + key: value + for key, value in os.environ.items() + if key.startswith(_CONTAINER_ENV_PREFIXES) + } + clean_env = { + key: value + for key, value in os.environ.items() + if not key.startswith(_CONTAINER_ENV_PREFIXES) and key not in DOTENV_CONFORMANCE_ENV_KEYS + } + + with tempfile.TemporaryDirectory(prefix="agentseek-container-env-") as directory: + root = Path(directory) + package = root / "chat" + package.mkdir() + (package / "__init__.py").write_text("", encoding="utf-8") + (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") + + port = 18125 + for index, case in enumerate(DOTENV_CONFORMANCE_CASES): + config = root / f"matrix-{index}.json" + config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") + env_file = root / f"matrix-{index}.env" + env_file.write_text(case["contents"], encoding="utf-8") + process_env = {**clean_env, **case["ambient"]} + + actual = _inspect_real_up( + root=root, + config=config, + env_file=env_file, + port=port, + process_env=process_env, + ) + + expected = case["container_expected"] + assert {key: actual[key] for key in expected} == expected, case["name"] + assert all(key not in actual for key in case["container_absent"]), case["name"] + port += 1 + + for index, case in enumerate(CROSS_LAYER_TOMBSTONE_CASES): + config = root / f"tombstone-{index}.json" + config.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), + encoding="utf-8", + ) + if case["config_dotenv"] is not None: + (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") + env_file = root / f"tombstone-{index}.env" + tombstone_key = next(iter(case["expected"]), "CONF_TOMBSTONE") + env_file.write_text(f"{tombstone_key}\n", encoding="utf-8") + process_env = {**clean_env, **case["shell_env"]} + + actual = _inspect_real_up( + root=root, + config=config, + env_file=env_file, + port=port, + process_env=process_env, + ) + + assert {key: actual[key] for key in case["expected"]} == case["expected"], case["name"] + if not case["expected"]: + assert "CONF_TOMBSTONE" not in actual, case["name"] + port += 1 + + os.environ.update(inherited_allowlisted) + print("container dotenv conformance passed") if __name__ == "__main__": diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index 8ec521b..c048bbb 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -31,6 +31,23 @@ def main() -> None: cli = environment / ("Scripts/agentseek-api.exe" if sys.platform == "win32" else "bin/agentseek-api") result = subprocess.run([str(cli), "version"], check=True, capture_output=True, text=True) assert result.stdout.strip() == "agentseek-api 0.2.1" + conformance = subprocess.run( + [ + str(python), + "-c", + ( + "import sys; " + f"sys.path.insert(0, {str(repository / 'scripts')!r}); " + "from dotenv_conformance import assert_runtime_conformance; " + "assert_runtime_conformance(expected_dotenv_version='1.0.0')" + ), + ], + check=False, + capture_output=True, + text=True, + ) + if conformance.returncode != 0: + raise RuntimeError(f"Minimum dependency dotenv conformance failed: {conformance.stderr.strip()}") if __name__ == "__main__": diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 6375443..573e10e 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -3,7 +3,6 @@ import argparse import json import os -import re import signal import subprocess import sys @@ -15,7 +14,9 @@ from pathlib import Path from typing import TextIO -from dotenv import dotenv_values +from dotenv.main import with_warn_for_invalid_lines +from dotenv.parser import parse_stream +from dotenv.variables import Literal, Variable, parse_variables from agentseek_api import __version__ from agentseek_api.settings import DEFAULT_API_PORT @@ -149,41 +150,116 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -_ENV_REFERENCE = re.compile( - r"\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-(.*?))?\}" -) - - -def _resolve_env_references(value: str, *, context: dict[str, str]) -> str: - def replace(match: re.Match[str]) -> str: - key = match.group(1) - default = match.group(2) - if key in context: - return context[key] - if default is not None: - return default - return match.group(0) +def _resolve_env_value( + value: str, + *, + context: dict[str, str | None], + preserve_unresolved: bool, +) -> str: + """Resolve a value with python-dotenv's grammar and missing-value rules.""" + atoms = list(parse_variables(value)) + if not preserve_unresolved: + return "".join(atom.resolve(context) for atom in atoms) + + parts: list[str] = [] + for atom in atoms: + if isinstance(atom, Literal): + parts.append(atom.value) + continue + if not isinstance(atom, Variable): + raise TypeError(f"Unsupported python-dotenv interpolation atom: {type(atom).__name__}") + if atom.name in context: + parts.append(context[atom.name] or "") + elif atom.default is not None: + parts.append(atom.default) + else: + parts.append(f"${{{atom.name}}}") + return "".join(parts) + + +def _parse_env_file( + env_file: Path, + *, + context: dict[str, str | None], + preserve_unresolved: bool, +) -> dict[str, str | None]: + """Parse and interpolate dotenv bindings in physical source order.""" + values: dict[str, str | None] = {} + with env_file.open(encoding="utf-8") as stream: + bindings = with_warn_for_invalid_lines(parse_stream(stream)) + for binding in bindings: + if binding.key is None: + continue + if binding.value is None: + # A valueless binding participates in interpolation just as it + # does in python-dotenv, but is not exported to child processes. + context[binding.key] = None + values[binding.key] = None + continue + resolved = _resolve_env_value( + binding.value, + context=context, + preserve_unresolved=preserve_unresolved, + ) + values[binding.key] = resolved + context[binding.key] = resolved + return values - return _ENV_REFERENCE.sub(replace, value) +def _apply_env_layer(env: dict[str, str], layer: dict[str, str | None]) -> None: + for key, value in layer.items(): + if value is None: + env.pop(key, None) + else: + env[key] = value -def _parse_env_file(env_file: Path, *, base_env: dict[str, str] | None = None) -> dict[str, str]: - """Parse dotenv syntax and resolve only previously selected values. - python-dotenv supplies the standards-compliant parser. Interpolation is - deliberately performed here so callers can provide a restricted context - for container handoff instead of exposing the full host environment. - """ - raw_values = dotenv_values(env_file, interpolate=False) - context = dict(base_env or {}) - values: dict[str, str] = {} - for key, value in raw_values.items(): - if value is None: - continue - resolved = _resolve_env_references(value, context=context) - values[key] = resolved - context[key] = resolved - return values +def _build_env( + *, + config_path: Path | None, + env_file: str | None, + cwd: Path, + shell_env: dict[str, str], + preserve_unresolved: bool, +) -> dict[str, str]: + env: dict[str, str] = {} + interpolation_context: dict[str, str | None] = dict(shell_env) + config: CliConfig | None = _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, + _parse_env_file( + config.env_file, + context=interpolation_context, + preserve_unresolved=preserve_unresolved, + ), + ) + # JSON env mappings are literal values. They form the next precedence + # layer and are available to interpolation in the CLI dotenv layer. + env.update(config.env_mapping) + interpolation_context.update(config.env_mapping) + if config.auth_path: + env["AUTH_MODULE_PATH"] = config.auth_path + interpolation_context["AUTH_MODULE_PATH"] = config.auth_path + if env_file: + resolved_env_file = _resolve_path(env_file, cwd=cwd) + if not resolved_env_file.exists(): + raise CliError(f"Env file '{resolved_env_file}' does not exist.") + _apply_env_layer( + env, + _parse_env_file( + resolved_env_file, + context=interpolation_context, + preserve_unresolved=preserve_unresolved, + ), + ) + # The launching shell is both the initial interpolation context and the + # highest-precedence output layer. + env.update(shell_env) + if config_path is not None: + env["AGENTSEEK_GRAPHS"] = str(config_path) + return env def _resolve_path_from_config(path_text: str, *, config_path: Path) -> Path: @@ -303,33 +379,13 @@ def build_runtime_env( base_env: dict[str, str] | None = None, ) -> dict[str, str]: shell_env = dict(os.environ if base_env is None else base_env) - env: dict[str, str] = {} - interpolation_context = dict(shell_env) - config: CliConfig | None = _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: - parsed_config_env = _parse_env_file(config.env_file, base_env=interpolation_context) - env.update(parsed_config_env) - interpolation_context.update(parsed_config_env) - env.update(config.env_mapping) - interpolation_context.update(config.env_mapping) - if config.auth_path: - env["AUTH_MODULE_PATH"] = config.auth_path - interpolation_context["AUTH_MODULE_PATH"] = config.auth_path - if env_file: - resolved_env_file = _resolve_path(env_file, cwd=cwd) - if not resolved_env_file.exists(): - raise CliError(f"Env file '{resolved_env_file}' does not exist.") - parsed_cli_env = _parse_env_file(resolved_env_file, base_env=interpolation_context) - env.update(parsed_cli_env) - interpolation_context.update(parsed_cli_env) - # The launching shell is the highest-precedence source. This is important - # for agentseek dev, whose child environment may also be described by a - # langgraph.json env file. - env.update(shell_env) - if config_path is not None: - env["AGENTSEEK_GRAPHS"] = str(config_path) - return env + return _build_env( + config_path=config_path, + env_file=env_file, + cwd=cwd, + shell_env=shell_env, + preserve_unresolved=False, + ) def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: @@ -660,11 +716,12 @@ 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( + env = _build_env( config_path=config_path, env_file=env_file, cwd=cwd, - base_env=_ambient_container_env(), + shell_env=_ambient_container_env(), + preserve_unresolved=True, ) env["AGENTSEEK_GRAPHS"] = _container_config_path(config_path=config_path, cwd=cwd) auth_module_path = env.get("AUTH_MODULE_PATH") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index dd00fe3..6e21d59 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,13 +3,25 @@ import argparse import importlib import io +import json +import os import tomllib from dataclasses import dataclass from pathlib import Path import pytest +from agentseek_api.cli import _CONTAINER_ENV_PREFIXES from agentseek_api.services.langgraph_service import LangGraphService +from scripts.dotenv_conformance import DOTENV_CONFORMANCE_CASES, DOTENV_CONFORMANCE_ENV_KEYS + + +@pytest.fixture(autouse=True) +def _clean_ambient_container_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Keep allowlisted host variables out of tests unless a test opts in.""" + for key in tuple(os.environ): + if key.startswith(_CONTAINER_ENV_PREFIXES): + monkeypatch.delenv(key) def test_python_dotenv_dependency_is_available() -> None: @@ -938,6 +950,187 @@ def test_build_runtime_env_shell_values_override_config_and_cli_dotenv(tmp_path: assert env["TOKEN"] == "from-shell" +@pytest.mark.parametrize( + "case", + DOTENV_CONFORMANCE_CASES, + ids=[case["name"] for case in DOTENV_CONFORMANCE_CASES], +) +def test_runtime_dotenv_interpolation_conforms_to_python_dotenv( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + case: dict[str, object], +) -> None: + from dotenv import dotenv_values + + from agentseek_api.cli import build_runtime_env + + for key in DOTENV_CONFORMANCE_ENV_KEYS: + monkeypatch.delenv(key, raising=False) + ambient = case["ambient"] + assert isinstance(ambient, dict) + for key, value in ambient.items(): + monkeypatch.setenv(key, value) + env_file = tmp_path / ".env" + contents = case["contents"] + assert isinstance(contents, str) + env_file.write_text(contents, encoding="utf-8") + expected = {key: value for key, value in dotenv_values(env_file).items() if value is not None} + + actual = build_runtime_env( + config_path=None, + env_file=str(env_file), + cwd=tmp_path, + base_env=dict(os.environ), + ) + + assert {key: actual[key] for key in expected} == expected + + +def test_runtime_dotenv_interpolation_sees_prior_layers_before_final_shell_override(tmp_path: Path) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("ORIGIN=https://config.example\n", encoding="utf-8") + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":"./config.env"}', + encoding="utf-8", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text("RESULT=${ORIGIN}/v1\n", encoding="utf-8") + + env = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={"ORIGIN": "https://shell.example"}, + ) + + assert env["RESULT"] == "https://config.example/v1" + assert env["ORIGIN"] == "https://shell.example" + + +def test_cli_dotenv_interpolation_sees_literal_config_mapping(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":{"ORIGIN":"https://mapping.example"}}', + encoding="utf-8", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text("RESULT=${ORIGIN}/v1\n", encoding="utf-8") + + env = build_runtime_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={}) + + assert env["RESULT"] == "https://mapping.example/v1" + + +def test_higher_precedence_valueless_binding_masks_lower_export(tmp_path: Path) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},"env":"./config.env"}', + encoding="utf-8", + ) + 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={}) + + assert "TOKEN" not in env + assert env["RESULT"] == "" + + +def test_container_dotenv_uses_full_grammar_but_preserves_unavailable_references( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + from agentseek_api.cli import build_container_env + + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / ".env" + env_file.write_text( + "A.B=dotted\n" + "1LEADING=digit\n" + "OPENAI_BASE_URL=${A.B}-${1LEADING}-${PR69_DISALLOWED_SECRET}\n", + encoding="utf-8", + ) + monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") + + env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) + + assert env["OPENAI_BASE_URL"] == "dotted-digit-${PR69_DISALLOWED_SECRET}" + assert "PR69_DISALLOWED_SECRET" not in env + + +def test_container_dotenv_preserves_physical_duplicate_order(tmp_path: Path) -> None: + from agentseek_api.cli import build_container_env + + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / ".env" + env_file.write_text( + "API_ORIGIN=https://first.example\n" + "OPENAI_BASE_URL=${API_ORIGIN}/v1\n" + "API_ORIGIN=https://second.example\n", + encoding="utf-8", + ) + + env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) + + assert env["OPENAI_BASE_URL"] == "https://first.example/v1" + + +@pytest.mark.parametrize("config_env", ["./config.env", {"API_ORIGIN": "https://mapping.example"}]) +def test_container_dotenv_sees_selected_config_layer( + tmp_path: Path, + config_env: str | dict[str, str], +) -> None: + from agentseek_api.cli import build_container_env + + if isinstance(config_env, str): + (tmp_path / "config.env").write_text("API_ORIGIN=https://dotenv.example\n", encoding="utf-8") + expected_origin = "https://dotenv.example" + else: + expected_origin = "https://mapping.example" + config_path = tmp_path / "langgraph.json" + config_path.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": config_env}), + encoding="utf-8", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text("OPENAI_BASE_URL=${API_ORIGIN}/v1\n", encoding="utf-8") + + env = build_container_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path) + + assert env["OPENAI_BASE_URL"] == f"{expected_origin}/v1" + + +def test_container_dotenv_resolves_allowlisted_but_not_disallowed_ambient_reference( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api.cli import build_container_env + + config_path = _write_basic_langgraph_config(tmp_path) + env_file = tmp_path / ".env" + env_file.write_text( + "ALLOWED_COPY=${OPENAI_API_KEY}\n" + "DISALLOWED_COPY=${PR69_DISALLOWED_SECRET}\n", + encoding="utf-8", + ) + monkeypatch.setenv("OPENAI_API_KEY", "allowlisted-value") + monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") + + env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) + + assert env["ALLOWED_COPY"] == "allowlisted-value" + assert env["DISALLOWED_COPY"] == "${PR69_DISALLOWED_SECRET}" + assert "PR69_DISALLOWED_SECRET" not in env + + def test_build_container_env_does_not_interpolate_disallowed_host_values( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/uv.lock b/uv.lock index 5424e81..2ec5ede 100644 --- a/uv.lock +++ b/uv.lock @@ -93,7 +93,7 @@ requires-dist = [ { name = "pydantic", specifier = ">=2.8.0" }, { name = "pydantic-settings", specifier = ">=2.4.0" }, { name = "pymysql", specifier = ">=1.1.0" }, - { name = "python-dotenv", specifier = ">=1.0" }, + { name = "python-dotenv", specifier = ">=1.0,<1.3" }, { name = "redis", specifier = ">=5.0.0" }, { name = "scalar-fastapi", specifier = ">=1.0.3" }, { name = "sqlalchemy", specifier = ">=2.0.0" }, From 8bc8bce402846cb173012bdd8eef156c585668b1 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Thu, 13 Aug 2026 11:07:02 +0800 Subject: [PATCH 10/28] test: harden dotenv conformance coverage --- scripts/dotenv_conformance.py | 124 ++++++++++++++++++++----- scripts/test_container_env_boundary.py | 14 ++- tests/unit/test_cli.py | 44 ++++++++- 3 files changed, 151 insertions(+), 31 deletions(-) diff --git a/scripts/dotenv_conformance.py b/scripts/dotenv_conformance.py index 44bae91..e6155af 100644 --- a/scripts/dotenv_conformance.py +++ b/scripts/dotenv_conformance.py @@ -3,10 +3,15 @@ from __future__ import annotations import importlib.metadata +import io +import json import os import tempfile from pathlib import Path +from dotenv.parser import parse_stream +from dotenv.variables import Variable, parse_variables + DOTENV_CONFORMANCE_CASES = ( { "name": "missing", @@ -87,44 +92,97 @@ }, ) -DOTENV_CONFORMANCE_ENV_KEYS = frozenset( +CROSS_LAYER_CONFORMANCE_CASES = ( { - "PR69_MISSING", - "PR69_DISALLOWED_SECRET", - "OPENAI_ALLOWED_SOURCE", - "CONF_ORIGIN", - "A.B", - "1LEADING", - "CONF_EMPTY", - "CONF_VALUELESS", - } -) - -CROSS_LAYER_TOMBSTONE_CASES = ( - { - "name": "config-dotenv", + "name": "config-dotenv-reference", "config_env": "./config.env", - "config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n", + "config_dotenv": "CONF_SOURCE=https://dotenv.example\n", + "cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n", "shell_env": {}, - "expected": {}, + "expected": { + "CONF_SOURCE": "https://dotenv.example", + "CONF_RESULT": "https://dotenv.example/v1", + }, + "absent": (), }, { - "name": "config-mapping", - "config_env": {"CONF_TOMBSTONE": "from-config-mapping"}, + "name": "config-mapping-reference", + "config_env": {"CONF_SOURCE": "https://mapping.example"}, "config_dotenv": None, + "cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n", "shell_env": {}, - "expected": {}, + "expected": { + "CONF_SOURCE": "https://mapping.example", + "CONF_RESULT": "https://mapping.example/v1", + }, + "absent": (), }, { - "name": "allowlisted-shell-restores", + "name": "final-allowlisted-shell-override", "config_env": {"OPENAI_API_KEY": "from-config"}, "config_dotenv": None, + "cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n", "shell_env": {"OPENAI_API_KEY": "from-shell"}, - "expected": {"OPENAI_API_KEY": "from-shell"}, + "expected": { + "CONF_RESULT": "from-config", + "OPENAI_API_KEY": "from-shell", + }, + "absent": (), + }, + { + "name": "config-dotenv-tombstone", + "config_env": "./config.env", + "config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n", + "cli_dotenv": "CONF_TOMBSTONE\n", + "shell_env": {}, + "expected": {}, + "absent": ("CONF_TOMBSTONE",), + }, + { + "name": "config-mapping-tombstone", + "config_env": {"CONF_TOMBSTONE": "from-config-mapping"}, + "config_dotenv": None, + "cli_dotenv": "CONF_TOMBSTONE\n", + "shell_env": {}, + "expected": {}, + "absent": ("CONF_TOMBSTONE",), }, ) +def _dotenv_keys(contents: str) -> set[str]: + keys: set[str] = set() + for binding in parse_stream(io.StringIO(contents)): + if binding.key is not None: + keys.add(binding.key) + if binding.value is not None: + keys.update(atom.name for atom in parse_variables(binding.value) if isinstance(atom, Variable)) + return keys + + +def _conformance_env_keys() -> frozenset[str]: + keys: set[str] = set() + for case in DOTENV_CONFORMANCE_CASES: + keys.update(_dotenv_keys(case["contents"])) + keys.update(case["ambient"]) + keys.update(case["container_expected"]) + keys.update(case["container_absent"]) + for case in CROSS_LAYER_CONFORMANCE_CASES: + config_env = case["config_env"] + if isinstance(config_env, dict): + keys.update(config_env) + if case["config_dotenv"] is not None: + keys.update(_dotenv_keys(case["config_dotenv"])) + keys.update(_dotenv_keys(case["cli_dotenv"])) + keys.update(case["shell_env"]) + keys.update(case["expected"]) + keys.update(case["absent"]) + return frozenset(keys) + + +DOTENV_CONFORMANCE_ENV_KEYS = _conformance_env_keys() + + def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> None: """Compare the runtime loader with the installed python-dotenv version.""" from dotenv import dotenv_values @@ -154,6 +212,28 @@ def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> ) assert {key: actual[key] for key in expected} == expected, case["name"] assert all(key not in actual for key, value in upstream.items() if value is None), case["name"] + + for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): + for key in DOTENV_CONFORMANCE_ENV_KEYS: + os.environ.pop(key, None) + os.environ.update(case["shell_env"]) + config_path = root / f"cross-layer-{index}.json" + config_path.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), + encoding="utf-8", + ) + if case["config_dotenv"] is not None: + (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") + env_file = root / f"cross-layer-{index}.env" + env_file.write_text(case["cli_dotenv"], encoding="utf-8") + actual = build_runtime_env( + config_path=config_path, + env_file=str(env_file), + cwd=root, + base_env=dict(os.environ), + ) + assert {key: actual[key] for key in case["expected"]} == case["expected"], case["name"] + assert all(key not in actual for key in case["absent"]), case["name"] finally: for key, value in previous.items(): if value is None: diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 710f3a9..75bd60b 100644 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -10,7 +10,7 @@ from pathlib import Path from dotenv_conformance import ( - CROSS_LAYER_TOMBSTONE_CASES, + CROSS_LAYER_CONFORMANCE_CASES, DOTENV_CONFORMANCE_CASES, DOTENV_CONFORMANCE_ENV_KEYS, ) @@ -109,17 +109,16 @@ def main() -> None: assert all(key not in actual for key in case["container_absent"]), case["name"] port += 1 - for index, case in enumerate(CROSS_LAYER_TOMBSTONE_CASES): - config = root / f"tombstone-{index}.json" + for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): + config = root / f"cross-layer-{index}.json" config.write_text( json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), encoding="utf-8", ) if case["config_dotenv"] is not None: (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") - env_file = root / f"tombstone-{index}.env" - tombstone_key = next(iter(case["expected"]), "CONF_TOMBSTONE") - env_file.write_text(f"{tombstone_key}\n", encoding="utf-8") + env_file = root / f"cross-layer-{index}.env" + env_file.write_text(case["cli_dotenv"], encoding="utf-8") process_env = {**clean_env, **case["shell_env"]} actual = _inspect_real_up( @@ -131,8 +130,7 @@ def main() -> None: ) assert {key: actual[key] for key in case["expected"]} == case["expected"], case["name"] - if not case["expected"]: - assert "CONF_TOMBSTONE" not in actual, case["name"] + assert all(key not in actual for key in case["absent"]), case["name"] port += 1 os.environ.update(inherited_allowlisted) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 6e21d59..48f0618 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -13,7 +13,11 @@ from agentseek_api.cli import _CONTAINER_ENV_PREFIXES from agentseek_api.services.langgraph_service import LangGraphService -from scripts.dotenv_conformance import DOTENV_CONFORMANCE_CASES, DOTENV_CONFORMANCE_ENV_KEYS +from scripts.dotenv_conformance import ( + CROSS_LAYER_CONFORMANCE_CASES, + DOTENV_CONFORMANCE_CASES, + DOTENV_CONFORMANCE_ENV_KEYS, +) @pytest.fixture(autouse=True) @@ -1010,6 +1014,44 @@ def test_runtime_dotenv_interpolation_sees_prior_layers_before_final_shell_overr assert env["ORIGIN"] == "https://shell.example" +@pytest.mark.parametrize( + "case", + CROSS_LAYER_CONFORMANCE_CASES, + ids=[case["name"] for case in CROSS_LAYER_CONFORMANCE_CASES], +) +def test_runtime_cross_layer_dotenv_conformance(tmp_path: Path, case: dict[str, object]) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = tmp_path / "langgraph.json" + config_path.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), + encoding="utf-8", + ) + config_dotenv = case["config_dotenv"] + if isinstance(config_dotenv, str): + (tmp_path / "config.env").write_text(config_dotenv, encoding="utf-8") + cli_env = tmp_path / "cli.env" + cli_dotenv = case["cli_dotenv"] + assert isinstance(cli_dotenv, str) + cli_env.write_text(cli_dotenv, encoding="utf-8") + shell_env = case["shell_env"] + assert isinstance(shell_env, dict) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env=shell_env, + ) + + expected = case["expected"] + assert isinstance(expected, dict) + assert {key: actual[key] for key in expected} == expected + absent = case["absent"] + assert isinstance(absent, tuple) + assert all(key not in actual for key in absent) + + def test_cli_dotenv_interpolation_sees_literal_config_mapping(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env From 8465c47e7b113ecc7c040cb7d6456c29866af888 Mon Sep 17 00:00:00 2001 From: chuixue <2960494764@qq.com> Date: Thu, 13 Aug 2026 11:29:50 +0800 Subject: [PATCH 11/28] fix: preserve layered valueless dotenv semantics --- scripts/dotenv_conformance.py | 135 ++++++++++++++++--------- scripts/test_container_env_boundary.py | 90 +++++++++-------- src/agentseek_api/cli.py | 12 +-- tests/unit/test_cli.py | 4 +- 4 files changed, 144 insertions(+), 97 deletions(-) diff --git a/scripts/dotenv_conformance.py b/scripts/dotenv_conformance.py index e6155af..03f7c08 100644 --- a/scripts/dotenv_conformance.py +++ b/scripts/dotenv_conformance.py @@ -130,25 +130,58 @@ "absent": (), }, { - "name": "config-dotenv-tombstone", + "name": "config-dotenv-valueless", "config_env": "./config.env", "config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n", - "cli_dotenv": "CONF_TOMBSTONE\n", + "cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n", "shell_env": {}, - "expected": {}, - "absent": ("CONF_TOMBSTONE",), + "expected": { + "CONF_TOMBSTONE": "from-config-dotenv", + "CONF_RESULT": "", + }, + "absent": (), }, { - "name": "config-mapping-tombstone", + "name": "config-mapping-valueless", "config_env": {"CONF_TOMBSTONE": "from-config-mapping"}, "config_dotenv": None, - "cli_dotenv": "CONF_TOMBSTONE\n", + "cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n", "shell_env": {}, - "expected": {}, - "absent": ("CONF_TOMBSTONE",), + "expected": { + "CONF_TOMBSTONE": "from-config-mapping", + "CONF_RESULT": "", + }, + "absent": (), + }, + { + "name": "config-dotenv-valueless-does-not-mask-shell-for-next-file", + "config_env": "./config.env", + "config_dotenv": "OPENAI_API_KEY\n", + "cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n", + "shell_env": {"OPENAI_API_KEY": "from-shell"}, + "expected": { + "OPENAI_API_KEY": "from-shell", + "CONF_RESULT": "from-shell", + }, + "absent": (), }, ) +CONFORMANCE_AMBIENT_MODES = ( + ("clean", {}), + ( + "hostile", + { + "OPENAI_API_KEY": "ambient-provider-key", + "OPENAI_ALLOWED_SOURCE": "ambient-allowed-source", + "PR69_MISSING": "ambient-missing-value", + "PR69_DISALLOWED_SECRET": "host-sensitive-value", + "A.B": "ambient-dotted-value", + "1LEADING": "ambient-digit-value", + }, + ), +) + def _dotenv_keys(contents: str) -> set[str]: keys: set[str] = set() @@ -196,44 +229,54 @@ def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> try: with tempfile.TemporaryDirectory(prefix="agentseek-dotenv-") as directory: root = Path(directory) - for index, case in enumerate(DOTENV_CONFORMANCE_CASES): - for key in DOTENV_CONFORMANCE_ENV_KEYS: - os.environ.pop(key, None) - os.environ.update(case["ambient"]) - env_file = root / f"{index}.env" - env_file.write_text(case["contents"], encoding="utf-8") - upstream = dotenv_values(env_file) - expected = {key: value for key, value in upstream.items() if value is not None} - actual = build_runtime_env( - config_path=None, - env_file=str(env_file), - cwd=root, - base_env=dict(os.environ), - ) - assert {key: actual[key] for key in expected} == expected, case["name"] - assert all(key not in actual for key, value in upstream.items() if value is None), case["name"] - - for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): - for key in DOTENV_CONFORMANCE_ENV_KEYS: - os.environ.pop(key, None) - os.environ.update(case["shell_env"]) - config_path = root / f"cross-layer-{index}.json" - config_path.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), - encoding="utf-8", - ) - if case["config_dotenv"] is not None: - (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") - env_file = root / f"cross-layer-{index}.env" - env_file.write_text(case["cli_dotenv"], encoding="utf-8") - actual = build_runtime_env( - config_path=config_path, - env_file=str(env_file), - cwd=root, - base_env=dict(os.environ), - ) - assert {key: actual[key] for key in case["expected"]} == case["expected"], case["name"] - assert all(key not in actual for key in case["absent"]), case["name"] + for mode_name, mode_ambient in CONFORMANCE_AMBIENT_MODES: + for index, case in enumerate(DOTENV_CONFORMANCE_CASES): + for key in DOTENV_CONFORMANCE_ENV_KEYS: + os.environ.pop(key, None) + os.environ.update(mode_ambient) + os.environ.update(case["ambient"]) + env_file = root / f"{mode_name}-{index}.env" + env_file.write_text(case["contents"], encoding="utf-8") + upstream = dotenv_values(env_file) + expected = {key: value for key, value in upstream.items() if value is not None} + expected.update({key: os.environ[key] for key in upstream.keys() & os.environ.keys()}) + actual = build_runtime_env( + config_path=None, + env_file=str(env_file), + cwd=root, + base_env=dict(os.environ), + ) + assertion = f"{mode_name}/{case['name']}" + assert {key: actual[key] for key in expected} == expected, assertion + assert all( + key not in actual + for key, value in upstream.items() + if value is None and key not in os.environ + ), assertion + + for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): + for key in DOTENV_CONFORMANCE_ENV_KEYS: + os.environ.pop(key, None) + os.environ.update(mode_ambient) + os.environ.update(case["shell_env"]) + config_path = root / f"cross-layer-{mode_name}-{index}.json" + config_path.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), + encoding="utf-8", + ) + if case["config_dotenv"] is not None: + (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") + env_file = root / f"cross-layer-{mode_name}-{index}.env" + env_file.write_text(case["cli_dotenv"], encoding="utf-8") + actual = build_runtime_env( + config_path=config_path, + env_file=str(env_file), + cwd=root, + base_env=dict(os.environ), + ) + assertion = f"{mode_name}/{case['name']}" + assert {key: actual[key] for key in case["expected"]} == case["expected"], assertion + assert all(key not in actual for key in case["absent"]), assertion finally: for key, value in previous.items(): if value is None: diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py index 75bd60b..c51202e 100644 --- a/scripts/test_container_env_boundary.py +++ b/scripts/test_container_env_boundary.py @@ -10,6 +10,7 @@ from pathlib import Path from dotenv_conformance import ( + CONFORMANCE_AMBIENT_MODES, CROSS_LAYER_CONFORMANCE_CASES, DOTENV_CONFORMANCE_CASES, DOTENV_CONFORMANCE_ENV_KEYS, @@ -89,49 +90,52 @@ def main() -> None: (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") port = 18125 - for index, case in enumerate(DOTENV_CONFORMANCE_CASES): - config = root / f"matrix-{index}.json" - config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") - env_file = root / f"matrix-{index}.env" - env_file.write_text(case["contents"], encoding="utf-8") - process_env = {**clean_env, **case["ambient"]} - - actual = _inspect_real_up( - root=root, - config=config, - env_file=env_file, - port=port, - process_env=process_env, - ) - - expected = case["container_expected"] - assert {key: actual[key] for key in expected} == expected, case["name"] - assert all(key not in actual for key in case["container_absent"]), case["name"] - port += 1 - - for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): - config = root / f"cross-layer-{index}.json" - config.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), - encoding="utf-8", - ) - if case["config_dotenv"] is not None: - (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") - env_file = root / f"cross-layer-{index}.env" - env_file.write_text(case["cli_dotenv"], encoding="utf-8") - process_env = {**clean_env, **case["shell_env"]} - - actual = _inspect_real_up( - root=root, - config=config, - env_file=env_file, - port=port, - process_env=process_env, - ) - - assert {key: actual[key] for key in case["expected"]} == case["expected"], case["name"] - assert all(key not in actual for key in case["absent"]), case["name"] - port += 1 + for mode_name, mode_ambient in CONFORMANCE_AMBIENT_MODES: + for index, case in enumerate(DOTENV_CONFORMANCE_CASES): + config = root / f"matrix-{mode_name}-{index}.json" + config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") + env_file = root / f"matrix-{mode_name}-{index}.env" + env_file.write_text(case["contents"], encoding="utf-8") + process_env = {**clean_env, **mode_ambient, **case["ambient"]} + + actual = _inspect_real_up( + root=root, + config=config, + env_file=env_file, + port=port, + process_env=process_env, + ) + + assertion = f"{mode_name}/{case['name']}" + expected = case["container_expected"] + assert {key: actual[key] for key in expected} == expected, assertion + assert all(key not in actual for key in case["container_absent"]), assertion + port += 1 + + for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): + config = root / f"cross-layer-{mode_name}-{index}.json" + config.write_text( + json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), + encoding="utf-8", + ) + if case["config_dotenv"] is not None: + (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") + env_file = root / f"cross-layer-{mode_name}-{index}.env" + env_file.write_text(case["cli_dotenv"], encoding="utf-8") + process_env = {**clean_env, **mode_ambient, **case["shell_env"]} + + actual = _inspect_real_up( + root=root, + config=config, + env_file=env_file, + port=port, + process_env=process_env, + ) + + assertion = f"{mode_name}/{case['name']}" + assert {key: actual[key] for key in case["expected"]} == case["expected"], assertion + assert all(key not in actual for key in case["absent"]), assertion + port += 1 os.environ.update(inherited_allowlisted) print("container dotenv conformance passed") diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 573e10e..d4d059d 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -184,6 +184,7 @@ def _parse_env_file( preserve_unresolved: bool, ) -> dict[str, str | None]: """Parse and interpolate dotenv bindings in physical source order.""" + local_context = dict(context) values: dict[str, str | None] = {} with env_file.open(encoding="utf-8") as stream: bindings = with_warn_for_invalid_lines(parse_stream(stream)) @@ -193,24 +194,23 @@ def _parse_env_file( if binding.value is None: # A valueless binding participates in interpolation just as it # does in python-dotenv, but is not exported to child processes. - context[binding.key] = None + local_context[binding.key] = None values[binding.key] = None continue resolved = _resolve_env_value( binding.value, - context=context, + context=local_context, preserve_unresolved=preserve_unresolved, ) values[binding.key] = resolved - context[binding.key] = resolved + local_context[binding.key] = resolved + context.update({key: value for key, value in values.items() if value is not None}) return values def _apply_env_layer(env: dict[str, str], layer: dict[str, str | None]) -> None: for key, value in layer.items(): - if value is None: - env.pop(key, None) - else: + if value is not None: env[key] = value diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 48f0618..c740f62 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -1068,7 +1068,7 @@ def test_cli_dotenv_interpolation_sees_literal_config_mapping(tmp_path: Path) -> assert env["RESULT"] == "https://mapping.example/v1" -def test_higher_precedence_valueless_binding_masks_lower_export(tmp_path: Path) -> None: +def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env config_env = tmp_path / "config.env" @@ -1083,7 +1083,7 @@ def test_higher_precedence_valueless_binding_masks_lower_export(tmp_path: Path) env = build_runtime_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={}) - assert "TOKEN" not in env + assert env["TOKEN"] == "from-config" assert env["RESULT"] == "" From f41f7d77a46bc21b317beef56cbf84c70c20b882 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:08:17 +0800 Subject: [PATCH 12/28] refactor: narrow environment fix to host runtimes --- .github/workflows/ci.yml | 3 - scripts/dotenv_conformance.py | 47 ++----- scripts/test-cli-docker.sh | 31 ----- scripts/test_container_env_boundary.py | 145 -------------------- src/agentseek_api/cli.py | 33 +---- tests/unit/test_cli.py | 176 ------------------------- 6 files changed, 13 insertions(+), 422 deletions(-) delete mode 100644 scripts/test_container_env_boundary.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06926da..5eda70e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -138,9 +138,6 @@ jobs: - name: CLI Docker smoke run: make test-cli-docker - - name: Container environment boundary smoke - run: uv run python scripts/test_container_env_boundary.py - sample-graphs: name: Sample Graphs runs-on: ubuntu-latest diff --git a/scripts/dotenv_conformance.py b/scripts/dotenv_conformance.py index 03f7c08..8d93867 100644 --- a/scripts/dotenv_conformance.py +++ b/scripts/dotenv_conformance.py @@ -1,4 +1,4 @@ -"""Shared dotenv interpolation cases for supported-version and handoff tests.""" +"""Shared dotenv interpolation cases for supported-version runtime tests.""" from __future__ import annotations @@ -15,10 +15,8 @@ DOTENV_CONFORMANCE_CASES = ( { "name": "missing", - "contents": "CONF_MISSING=prefix-${PR69_MISSING}-suffix\n", + "contents": "CONF_MISSING=prefix-${CONF_UNSET_REFERENCE}-suffix\n", "ambient": {}, - "container_expected": {"CONF_MISSING": "prefix-${PR69_MISSING}-suffix"}, - "container_absent": (), }, { "name": "duplicate-order", @@ -28,35 +26,21 @@ "CONF_ORIGIN=https://second.example\n" ), "ambient": {}, - "container_expected": { - "CONF_ORIGIN": "https://second.example", - "CONF_ORDERED": "https://first.example/v1", - }, - "container_absent": (), }, { "name": "broad-names", "contents": "A.B=dotted\n1LEADING=digit\nCONF_BROAD=${A.B}-${1LEADING}\n", "ambient": {}, - "container_expected": {"CONF_BROAD": "dotted-digit"}, - "container_absent": (), }, { "name": "multiline-default", - "contents": 'CONF_MULTILINE="${PR69_MISSING:-first line\nsecond line}"\n', + "contents": 'CONF_MULTILINE="${CONF_UNSET_REFERENCE:-first line\nsecond line}"\n', "ambient": {}, - "container_expected": {"CONF_MULTILINE": "first line\nsecond line"}, - "container_absent": (), }, { "name": "bare-default", - "contents": "CONF_BARE=$PR69_MISSING\nCONF_DEFAULT=${PR69_MISSING:-fallback}\n", + "contents": "CONF_BARE=$CONF_UNSET_REFERENCE\nCONF_DEFAULT=${CONF_UNSET_REFERENCE:-fallback}\n", "ambient": {}, - "container_expected": { - "CONF_BARE": "$PR69_MISSING", - "CONF_DEFAULT": "fallback", - }, - "container_absent": (), }, { "name": "empty-valueless", @@ -67,28 +51,17 @@ "CONF_FROM_VALUELESS=${CONF_VALUELESS:-fallback}\n" ), "ambient": {}, - "container_expected": { - "CONF_EMPTY": "", - "CONF_FROM_EMPTY": "", - "CONF_FROM_VALUELESS": "", - }, - "container_absent": ("CONF_VALUELESS",), }, { - "name": "allowed-disallowed-ambient", + "name": "ambient-references", "contents": ( "CONF_ALLOWED=${OPENAI_ALLOWED_SOURCE}\n" - "CONF_DISALLOWED=${PR69_DISALLOWED_SECRET}\n" + "CONF_AMBIENT=${CONF_AMBIENT_SOURCE}\n" ), "ambient": { "OPENAI_ALLOWED_SOURCE": "allowlisted-source", - "PR69_DISALLOWED_SECRET": "host-sensitive-value", - }, - "container_expected": { - "CONF_ALLOWED": "allowlisted-source", - "CONF_DISALLOWED": "${PR69_DISALLOWED_SECRET}", + "CONF_AMBIENT_SOURCE": "ambient-source", }, - "container_absent": ("PR69_DISALLOWED_SECRET",), }, ) @@ -174,8 +147,8 @@ { "OPENAI_API_KEY": "ambient-provider-key", "OPENAI_ALLOWED_SOURCE": "ambient-allowed-source", - "PR69_MISSING": "ambient-missing-value", - "PR69_DISALLOWED_SECRET": "host-sensitive-value", + "CONF_UNSET_REFERENCE": "ambient-missing-value", + "CONF_AMBIENT_SOURCE": "ambient-source", "A.B": "ambient-dotted-value", "1LEADING": "ambient-digit-value", }, @@ -198,8 +171,6 @@ def _conformance_env_keys() -> frozenset[str]: for case in DOTENV_CONFORMANCE_CASES: keys.update(_dotenv_keys(case["contents"])) keys.update(case["ambient"]) - keys.update(case["container_expected"]) - keys.update(case["container_absent"]) for case in CROSS_LAYER_CONFORMANCE_CASES: config_env = case["config_env"] if isinstance(config_env, dict): diff --git a/scripts/test-cli-docker.sh b/scripts/test-cli-docker.sh index d2f1132..3998042 100644 --- a/scripts/test-cli-docker.sh +++ b/scripts/test-cli-docker.sh @@ -8,14 +8,12 @@ IMAGE_TAG="${IMAGE_TAG:-agentseek-api-cli-smoke:latest}" DB_CONTAINER="${DB_CONTAINER:-agentseek-cli-mysql}" APP_CONTAINER="${APP_CONTAINER:-agentseek-up-8123}" APP_CONTAINER_AUTOBUILD="${APP_CONTAINER_AUTOBUILD:-agentseek-up-8124}" -APP_CONTAINER_SECURITY="${APP_CONTAINER_SECURITY:-agentseek-up-8125}" PG_CONTAINER="${PG_CONTAINER:-agentseek-cli-postgres}" TMP_DIR="${TMP_DIR:-$ROOT_DIR/.tmp/cli-docker}" 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 "$APP_CONTAINER_SECURITY" >/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 } @@ -23,7 +21,6 @@ cleanup() { print_logs() { docker logs "$APP_CONTAINER" || true docker logs "$APP_CONTAINER_AUTOBUILD" || true - docker logs "$APP_CONTAINER_SECURITY" || true docker logs "$DB_CONTAINER" || true docker logs "$PG_CONTAINER" || true } @@ -119,34 +116,6 @@ if ! uv run python scripts/verify_docker_api.py --base-url http://127.0.0.1:8123 exit 1 fi -cat >"$TMP_DIR/disallowed.env" <<'EOF' -OPENAI_API_KEY=${PR69_DISALLOWED_SECRET} -EOF - -if ! env -u OPENAI_API_KEY PR69_DISALLOWED_SECRET=host-sensitive-value uv run agentseek-api up \ - --config "$CONFIG_PATH" \ - --image "$IMAGE_TAG" \ - --port 8125 \ - --env-file "$TMP_DIR/disallowed.env" \ - --recreate; then - print_logs - exit 1 -fi - -for _ in $(seq 1 60); do - if docker inspect "$APP_CONTAINER_SECURITY" --format '{{.State.Running}}' 2>/dev/null | grep -q true; then - break - fi - sleep 1 -done - -CONTAINER_SECRET="$(docker exec "$APP_CONTAINER_SECURITY" python -c 'import os; print(os.environ["OPENAI_API_KEY"])')" -if [[ "$CONTAINER_SECRET" != '${PR69_DISALLOWED_SECRET}' || "$CONTAINER_SECRET" == 'host-sensitive-value' ]]; then - print_logs - echo "Container environment expanded a host-only secret." >&2 - exit 1 -fi - DUPLICATE_STDERR="$TMP_DIR/up-duplicate.stderr" set +e uv run agentseek-api up \ diff --git a/scripts/test_container_env_boundary.py b/scripts/test_container_env_boundary.py deleted file mode 100644 index c51202e..0000000 --- a/scripts/test_container_env_boundary.py +++ /dev/null @@ -1,145 +0,0 @@ -"""Verify the shared dotenv matrix through the real container handoff.""" - -from __future__ import annotations - -import json -import os -import subprocess -import sys -import tempfile -from pathlib import Path - -from dotenv_conformance import ( - CONFORMANCE_AMBIENT_MODES, - CROSS_LAYER_CONFORMANCE_CASES, - DOTENV_CONFORMANCE_CASES, - DOTENV_CONFORMANCE_ENV_KEYS, -) - - -def _inspect_real_up( - *, - root: Path, - config: Path, - env_file: Path, - port: int, - process_env: dict[str, str], -) -> dict[str, str]: - container_name = f"agentseek-up-{port}" - try: - result = subprocess.run( - [ - sys.executable, - "-m", - "agentseek_api.cli", - "up", - "--config", - str(config), - "--image", - "python:3.12-slim", - "--port", - str(port), - "--env-file", - str(env_file), - "--recreate", - ], - cwd=root, - env=process_env, - check=False, - capture_output=True, - text=True, - ) - if result.returncode != 0: - raise RuntimeError(f"agentseek-api up smoke failed: {result.stderr.strip()}") - inspected = subprocess.run( - ["docker", "inspect", container_name, "--format", "{{json .Config.Env}}"], - check=True, - capture_output=True, - text=True, - ) - assert "host-sensitive-value" not in inspected.stdout - return dict(entry.split("=", maxsplit=1) for entry in json.loads(inspected.stdout)) - finally: - subprocess.run( - ["docker", "rm", "-f", container_name], - check=False, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -def main() -> None: - from agentseek_api.cli import _CONTAINER_ENV_PREFIXES - - inherited_allowlisted = { - key: value - for key, value in os.environ.items() - if key.startswith(_CONTAINER_ENV_PREFIXES) - } - clean_env = { - key: value - for key, value in os.environ.items() - if not key.startswith(_CONTAINER_ENV_PREFIXES) and key not in DOTENV_CONFORMANCE_ENV_KEYS - } - - with tempfile.TemporaryDirectory(prefix="agentseek-container-env-") as directory: - root = Path(directory) - package = root / "chat" - package.mkdir() - (package / "__init__.py").write_text("", encoding="utf-8") - (package / "graph.py").write_text("graph = object()\n", encoding="utf-8") - - port = 18125 - for mode_name, mode_ambient in CONFORMANCE_AMBIENT_MODES: - for index, case in enumerate(DOTENV_CONFORMANCE_CASES): - config = root / f"matrix-{mode_name}-{index}.json" - config.write_text('{"graphs":{"chat":"chat.graph:graph"}}\n', encoding="utf-8") - env_file = root / f"matrix-{mode_name}-{index}.env" - env_file.write_text(case["contents"], encoding="utf-8") - process_env = {**clean_env, **mode_ambient, **case["ambient"]} - - actual = _inspect_real_up( - root=root, - config=config, - env_file=env_file, - port=port, - process_env=process_env, - ) - - assertion = f"{mode_name}/{case['name']}" - expected = case["container_expected"] - assert {key: actual[key] for key in expected} == expected, assertion - assert all(key not in actual for key in case["container_absent"]), assertion - port += 1 - - for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): - config = root / f"cross-layer-{mode_name}-{index}.json" - config.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), - encoding="utf-8", - ) - if case["config_dotenv"] is not None: - (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") - env_file = root / f"cross-layer-{mode_name}-{index}.env" - env_file.write_text(case["cli_dotenv"], encoding="utf-8") - process_env = {**clean_env, **mode_ambient, **case["shell_env"]} - - actual = _inspect_real_up( - root=root, - config=config, - env_file=env_file, - port=port, - process_env=process_env, - ) - - assertion = f"{mode_name}/{case['name']}" - assert {key: actual[key] for key in case["expected"]} == case["expected"], assertion - assert all(key not in actual for key in case["absent"]), assertion - port += 1 - - os.environ.update(inherited_allowlisted) - print("container dotenv conformance passed") - - -if __name__ == "__main__": - main() diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index d4d059d..61ba562 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -16,7 +16,7 @@ from dotenv.main import with_warn_for_invalid_lines from dotenv.parser import parse_stream -from dotenv.variables import Literal, Variable, parse_variables +from dotenv.variables import parse_variables from agentseek_api import __version__ from agentseek_api.settings import DEFAULT_API_PORT @@ -154,34 +154,15 @@ def _resolve_env_value( value: str, *, context: dict[str, str | None], - preserve_unresolved: bool, ) -> str: """Resolve a value with python-dotenv's grammar and missing-value rules.""" - atoms = list(parse_variables(value)) - if not preserve_unresolved: - return "".join(atom.resolve(context) for atom in atoms) - - parts: list[str] = [] - for atom in atoms: - if isinstance(atom, Literal): - parts.append(atom.value) - continue - if not isinstance(atom, Variable): - raise TypeError(f"Unsupported python-dotenv interpolation atom: {type(atom).__name__}") - if atom.name in context: - parts.append(context[atom.name] or "") - elif atom.default is not None: - parts.append(atom.default) - else: - parts.append(f"${{{atom.name}}}") - return "".join(parts) + return "".join(atom.resolve(context) for atom in parse_variables(value)) def _parse_env_file( env_file: Path, *, context: dict[str, str | None], - preserve_unresolved: bool, ) -> dict[str, str | None]: """Parse and interpolate dotenv bindings in physical source order.""" local_context = dict(context) @@ -200,7 +181,6 @@ def _parse_env_file( resolved = _resolve_env_value( binding.value, context=local_context, - preserve_unresolved=preserve_unresolved, ) values[binding.key] = resolved local_context[binding.key] = resolved @@ -220,7 +200,6 @@ def _build_env( env_file: str | None, cwd: Path, shell_env: dict[str, str], - preserve_unresolved: bool, ) -> dict[str, str]: env: dict[str, str] = {} interpolation_context: dict[str, str | None] = dict(shell_env) @@ -232,7 +211,6 @@ def _build_env( _parse_env_file( config.env_file, context=interpolation_context, - preserve_unresolved=preserve_unresolved, ), ) # JSON env mappings are literal values. They form the next precedence @@ -251,7 +229,6 @@ def _build_env( _parse_env_file( resolved_env_file, context=interpolation_context, - preserve_unresolved=preserve_unresolved, ), ) # The launching shell is both the initial interpolation context and the @@ -384,7 +361,6 @@ def build_runtime_env( env_file=env_file, cwd=cwd, shell_env=shell_env, - preserve_unresolved=False, ) @@ -716,12 +692,11 @@ 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_env( + env = build_runtime_env( config_path=config_path, env_file=env_file, cwd=cwd, - shell_env=_ambient_container_env(), - preserve_unresolved=True, + 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") diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index c740f62..ffce652 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -11,7 +11,6 @@ import pytest -from agentseek_api.cli import _CONTAINER_ENV_PREFIXES from agentseek_api.services.langgraph_service import LangGraphService from scripts.dotenv_conformance import ( CROSS_LAYER_CONFORMANCE_CASES, @@ -20,14 +19,6 @@ ) -@pytest.fixture(autouse=True) -def _clean_ambient_container_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Keep allowlisted host variables out of tests unless a test opts in.""" - for key in tuple(os.environ): - if key.startswith(_CONTAINER_ENV_PREFIXES): - monkeypatch.delenv(key) - - def test_python_dotenv_dependency_is_available() -> None: from dotenv import dotenv_values @@ -1087,108 +1078,6 @@ def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) assert env["RESULT"] == "" -def test_container_dotenv_uses_full_grammar_but_preserves_unavailable_references( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from agentseek_api.cli import build_container_env - - config_path = _write_basic_langgraph_config(tmp_path) - env_file = tmp_path / ".env" - env_file.write_text( - "A.B=dotted\n" - "1LEADING=digit\n" - "OPENAI_BASE_URL=${A.B}-${1LEADING}-${PR69_DISALLOWED_SECRET}\n", - encoding="utf-8", - ) - monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") - - env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) - - assert env["OPENAI_BASE_URL"] == "dotted-digit-${PR69_DISALLOWED_SECRET}" - assert "PR69_DISALLOWED_SECRET" not in env - - -def test_container_dotenv_preserves_physical_duplicate_order(tmp_path: Path) -> None: - from agentseek_api.cli import build_container_env - - config_path = _write_basic_langgraph_config(tmp_path) - env_file = tmp_path / ".env" - env_file.write_text( - "API_ORIGIN=https://first.example\n" - "OPENAI_BASE_URL=${API_ORIGIN}/v1\n" - "API_ORIGIN=https://second.example\n", - encoding="utf-8", - ) - - env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) - - assert env["OPENAI_BASE_URL"] == "https://first.example/v1" - - -@pytest.mark.parametrize("config_env", ["./config.env", {"API_ORIGIN": "https://mapping.example"}]) -def test_container_dotenv_sees_selected_config_layer( - tmp_path: Path, - config_env: str | dict[str, str], -) -> None: - from agentseek_api.cli import build_container_env - - if isinstance(config_env, str): - (tmp_path / "config.env").write_text("API_ORIGIN=https://dotenv.example\n", encoding="utf-8") - expected_origin = "https://dotenv.example" - else: - expected_origin = "https://mapping.example" - config_path = tmp_path / "langgraph.json" - config_path.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": config_env}), - encoding="utf-8", - ) - cli_env = tmp_path / "cli.env" - cli_env.write_text("OPENAI_BASE_URL=${API_ORIGIN}/v1\n", encoding="utf-8") - - env = build_container_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path) - - assert env["OPENAI_BASE_URL"] == f"{expected_origin}/v1" - - -def test_container_dotenv_resolves_allowlisted_but_not_disallowed_ambient_reference( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from agentseek_api.cli import build_container_env - - config_path = _write_basic_langgraph_config(tmp_path) - env_file = tmp_path / ".env" - env_file.write_text( - "ALLOWED_COPY=${OPENAI_API_KEY}\n" - "DISALLOWED_COPY=${PR69_DISALLOWED_SECRET}\n", - encoding="utf-8", - ) - monkeypatch.setenv("OPENAI_API_KEY", "allowlisted-value") - monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") - - env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) - - assert env["ALLOWED_COPY"] == "allowlisted-value" - assert env["DISALLOWED_COPY"] == "${PR69_DISALLOWED_SECRET}" - assert "PR69_DISALLOWED_SECRET" not in env - - -def test_build_container_env_does_not_interpolate_disallowed_host_values( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - from agentseek_api.cli import build_container_env - - monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") - config_path = _write_basic_langgraph_config(tmp_path) - env_file = tmp_path / ".env" - env_file.write_text("OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", encoding="utf-8") - - env = build_container_env(config_path=config_path, env_file=str(env_file), cwd=tmp_path) - - assert env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" - assert "PR69_DISALLOWED_SECRET" not in env - - def test_build_runtime_env_rejects_invalid_config_env_shape(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env @@ -1668,71 +1557,6 @@ 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_resolves_same_file_references_without_expanding_disallowed_host_values( - 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( - "API_ORIGIN=https://api.example.test\n" - "OPENAI_BASE_URL=${API_ORIGIN}/v1\n" - "OPENAI_API_KEY=${PR69_DISALLOWED_SECRET}\n", - encoding="utf-8", - ) - monkeypatch.setenv("PR69_DISALLOWED_SECRET", "host-sensitive-value") - capture = _RunCapture() - - exit_code = main( - [ - "up", - "--config", - str(config_path), - "--image", - "agentseek:test", - "--env-file", - str(env_file), - ], - runner=capture, - cwd=tmp_path, - ) - - assert exit_code == 0 - assert capture.calls is not None - container_env = _docker_env_from_run_command(capture.calls[1]) - assert container_env["OPENAI_BASE_URL"] == "https://api.example.test/v1" - assert container_env["OPENAI_API_KEY"] == "${PR69_DISALLOWED_SECRET}" - assert "PR69_DISALLOWED_SECRET" not in container_env - - -def test_up_command_preserves_dotenv_default_and_bare_variable_syntax(tmp_path: Path) -> 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_BASE_URL=${MISSING_API_ORIGIN:-https://default.example.test}/v1\n" - "BARE_REFERENCE=$MISSING_API_ORIGIN\n", - encoding="utf-8", - ) - capture = _RunCapture() - - exit_code = main( - ["up", "--config", str(config_path), "--image", "agentseek:test", "--env-file", str(env_file)], - runner=capture, - cwd=tmp_path, - ) - - assert exit_code == 0 - assert capture.calls is not None - container_env = _docker_env_from_run_command(capture.calls[1]) - assert container_env["OPENAI_BASE_URL"] == "https://default.example.test/v1" - assert container_env["BARE_REFERENCE"] == "$MISSING_API_ORIGIN" - - - - def test_up_command_prefers_agentseek_json_without_explicit_flag(tmp_path: Path) -> None: from agentseek_api.cli import main From 635951c92e01e7b0688c2a35562a64846dd85c71 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:11:44 +0800 Subject: [PATCH 13/28] refactor: isolate CLI constants from runtime settings --- src/agentseek_api/cli.py | 2 +- src/agentseek_api/constants.py | 3 + src/agentseek_api/settings.py | 2 +- .../integration/test_cli_runtime_processes.py | 88 +++++++++++++++++++ 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 src/agentseek_api/constants.py create mode 100644 tests/integration/test_cli_runtime_processes.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 61ba562..99c459b 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -19,7 +19,7 @@ from dotenv.variables import parse_variables from agentseek_api import __version__ -from agentseek_api.settings import DEFAULT_API_PORT +from agentseek_api.constants import DEFAULT_API_PORT DEFAULT_CLI_NAME = "agentseek-api" diff --git a/src/agentseek_api/constants.py b/src/agentseek_api/constants.py new file mode 100644 index 0000000..43d7a82 --- /dev/null +++ b/src/agentseek_api/constants.py @@ -0,0 +1,3 @@ +"""Inert package constants safe to import from command-line tooling.""" + +DEFAULT_API_PORT = 2024 diff --git a/src/agentseek_api/settings.py b/src/agentseek_api/settings.py index 11e50ec..5aabd7c 100644 --- a/src/agentseek_api/settings.py +++ b/src/agentseek_api/settings.py @@ -1,6 +1,6 @@ from pydantic_settings import BaseSettings, SettingsConfigDict -DEFAULT_API_PORT = 2024 +from agentseek_api.constants import DEFAULT_API_PORT class Settings(BaseSettings): diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py new file mode 100644 index 0000000..05bc5ea --- /dev/null +++ b/tests/integration/test_cli_runtime_processes.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + + +def _run_python( + *arguments: str, + cwd: Path, + extra_env: dict[str, str] | None = None, +) -> subprocess.CompletedProcess[str]: + env = dict(os.environ) + env.update(extra_env or {}) + return subprocess.run( + [sys.executable, *arguments], + cwd=cwd, + env=env, + check=False, + capture_output=True, + text=True, + ) + + +def test_cli_import_does_not_import_runtime_settings(tmp_path: Path) -> None: + result = _run_python( + "-c", + ( + "import sys; " + "import agentseek_api.cli; " + "assert 'agentseek_api.settings' not in sys.modules" + ), + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.parametrize( + "arguments", + [ + ("-m", "agentseek_api.cli", "version"), + ("-m", "agentseek_api.cli", "--help"), + ], + ids=["version", "help"], +) +def test_non_runtime_commands_ignore_invalid_runtime_settings( + tmp_path: Path, + arguments: tuple[str, ...], +) -> None: + result = _run_python( + *arguments, + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + assert "ValidationError" not in result.stderr + + +def test_dockerfile_rendering_ignores_invalid_runtime_settings( + tmp_path: Path, +) -> None: + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"}}', + encoding="utf-8", + ) + output_path = tmp_path / "Dockerfile" + + result = _run_python( + "-m", + "agentseek_api.cli", + "dockerfile", + "--config", + str(config_path), + str(output_path), + cwd=tmp_path, + extra_env={"PORT": "not-an-integer"}, + ) + + assert result.returncode == 0, result.stderr + assert output_path.exists() + assert "ValidationError" not in result.stderr From dc2d3a5636bd0005fcbfc40091953cf3b51cf390 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:15:03 +0800 Subject: [PATCH 14/28] feat: add strict dotenv adapter --- src/agentseek_api/dotenv_adapter.py | 69 ++++++++++++++++++++++ tests/unit/test_dotenv_adapter.py | 89 +++++++++++++++++++++++++++++ 2 files changed, 158 insertions(+) create mode 100644 src/agentseek_api/dotenv_adapter.py create mode 100644 tests/unit/test_dotenv_adapter.py diff --git a/src/agentseek_api/dotenv_adapter.py b/src/agentseek_api/dotenv_adapter.py new file mode 100644 index 0000000..d9955c0 --- /dev/null +++ b/src/agentseek_api/dotenv_adapter.py @@ -0,0 +1,69 @@ +"""Strict adapter around the supported python-dotenv implementation APIs.""" + +from __future__ import annotations + +from collections.abc import Mapping +from pathlib import Path + +from dotenv.parser import parse_stream +from dotenv.variables import parse_variables + + +class DotenvFileError(ValueError): + def __init__( + self, + path: Path, + message: str, + *, + line: int | None = None, + ) -> None: + self.path = path + self.line = line + location = f" at line {line}" if line is not None else "" + 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)) + + +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)) + except FileNotFoundError as exc: + raise DotenvFileError(path, "does not exist") from exc + except UnicodeDecodeError as exc: + raise DotenvFileError(path, "is not valid UTF-8") from exc + except OSError as exc: + reason = exc.strerror or type(exc).__name__ + raise DotenvFileError(path, f"could not be read: {reason}") from exc + + malformed = next((binding for binding in bindings if binding.error), None) + if malformed is not None: + raise DotenvFileError( + path, + "has malformed dotenv syntax", + line=malformed.original.line, + ) + + context: dict[str, str | None] = dict(ambient) + values: dict[str, str | None] = {} + for binding in bindings: + if binding.key is None: + continue + if binding.value is None: + value = None + else: + value = _resolve_value(binding.value, context=context) + values[binding.key] = value + context[binding.key] = value + return values diff --git a/tests/unit/test_dotenv_adapter.py b/tests/unit/test_dotenv_adapter.py new file mode 100644 index 0000000..ea55c34 --- /dev/null +++ b/tests/unit/test_dotenv_adapter.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + + +def test_parse_dotenv_file_preserves_file_local_physical_order( + tmp_path: Path, +) -> None: + from agentseek_api.dotenv_adapter import parse_dotenv_file + + env_file = tmp_path / "runtime.env" + env_file.write_text( + "export FIRST=one\n" + "DUPLICATE=first\n" + "FROM_DUPLICATE=${DUPLICATE}/v1\n" + "DUPLICATE=second\n" + 'MULTILINE="line one\nline two"\n' + "FROM_AMBIENT=${AMBIENT}/v2\n" + "BARE_REFERENCE=$AMBIENT\n" + "MISSING_REFERENCE=${UNSET}\n" + "MISSING_DEFAULT=${UNSET:-fallback}\n" + "EMPTY=\n" + "VALUELESS\n" + "FROM_VALUELESS=${VALUELESS:-fallback}\n", + encoding="utf-8", + ) + ambient = {"AMBIENT": "from-shell"} + + values = parse_dotenv_file(env_file, ambient=ambient) + + assert values == { + "FIRST": "one", + "DUPLICATE": "second", + "FROM_DUPLICATE": "first/v1", + "MULTILINE": "line one\nline two", + "FROM_AMBIENT": "from-shell/v2", + "BARE_REFERENCE": "$AMBIENT", + "MISSING_REFERENCE": "", + "MISSING_DEFAULT": "fallback", + "EMPTY": "", + "VALUELESS": None, + "FROM_VALUELESS": "", + } + assert ambient == {"AMBIENT": "from-shell"} + + +@pytest.mark.parametrize( + "contents", + [ + 'BROKEN "value"\n', + 'UNTERMINATED="value\n', + ], +) +def test_parse_dotenv_file_rejects_genuinely_malformed_syntax( + tmp_path: Path, + contents: str, +) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "broken.env" + env_file.write_text("SECRET=must-not-leak\n" + contents, encoding="utf-8") + + with pytest.raises(DotenvFileError) as raised: + parse_dotenv_file(env_file, ambient={}) + + assert raised.value.path == env_file + assert raised.value.line == 2 + assert "must-not-leak" not in str(raised.value) + + +def test_parse_dotenv_file_reports_missing_source(tmp_path: Path) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "missing.env" + + with pytest.raises(DotenvFileError, match="does not exist"): + parse_dotenv_file(env_file, ambient={}) + + +def test_parse_dotenv_file_reports_utf8_decode_failure(tmp_path: Path) -> None: + from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file + + env_file = tmp_path / "invalid.env" + env_file.write_bytes(b"TOKEN=\xff\n") + + with pytest.raises(DotenvFileError, match="not valid UTF-8"): + parse_dotenv_file(env_file, ambient={}) From 6d8aa8bbf8f851683b842711a9f0cf40114c1714 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:21:24 +0800 Subject: [PATCH 15/28] fix: make inherited runtime environment authoritative --- src/agentseek_api/cli.py | 131 ++---- tests/unit/test_cli.py | 109 ++--- tests/unit/test_runtime_environment.py | 609 +++++++++++++++++++++++++ 3 files changed, 676 insertions(+), 173 deletions(-) create mode 100644 tests/unit/test_runtime_environment.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 99c459b..8d22270 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -14,12 +14,9 @@ from pathlib import Path from typing import TextIO -from dotenv.main import with_warn_for_invalid_lines -from dotenv.parser import parse_stream -from dotenv.variables import parse_variables - from agentseek_api import __version__ from agentseek_api.constants import DEFAULT_API_PORT +from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file DEFAULT_CLI_NAME = "agentseek-api" @@ -150,93 +147,24 @@ def discover_config_path(*, explicit_path: str | None, cwd: Path) -> Path | None return None -def _resolve_env_value( - value: str, - *, - context: dict[str, str | None], -) -> str: - """Resolve a value with python-dotenv's grammar and missing-value rules.""" - return "".join(atom.resolve(context) for atom in parse_variables(value)) - - -def _parse_env_file( - env_file: Path, - *, - context: dict[str, str | None], -) -> dict[str, str | None]: - """Parse and interpolate dotenv bindings in physical source order.""" - local_context = dict(context) - values: dict[str, str | None] = {} - with env_file.open(encoding="utf-8") as stream: - bindings = with_warn_for_invalid_lines(parse_stream(stream)) - for binding in bindings: - if binding.key is None: - continue - if binding.value is None: - # A valueless binding participates in interpolation just as it - # does in python-dotenv, but is not exported to child processes. - local_context[binding.key] = None - values[binding.key] = None - continue - resolved = _resolve_env_value( - binding.value, - context=local_context, - ) - values[binding.key] = resolved - local_context[binding.key] = resolved - context.update({key: value for key, value in values.items() if value is not None}) - return values - - -def _apply_env_layer(env: dict[str, str], layer: dict[str, str | None]) -> None: +def _apply_env_layer( + env: dict[str, str], + layer: dict[str, str | None], +) -> None: for key, value in layer.items(): if value is not None: env[key] = value -def _build_env( +def _read_env_layer( + path: Path, *, - config_path: Path | None, - env_file: str | None, - cwd: Path, - shell_env: dict[str, str], -) -> dict[str, str]: - env: dict[str, str] = {} - interpolation_context: dict[str, str | None] = dict(shell_env) - config: CliConfig | None = _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, - _parse_env_file( - config.env_file, - context=interpolation_context, - ), - ) - # JSON env mappings are literal values. They form the next precedence - # layer and are available to interpolation in the CLI dotenv layer. - env.update(config.env_mapping) - interpolation_context.update(config.env_mapping) - if config.auth_path: - env["AUTH_MODULE_PATH"] = config.auth_path - interpolation_context["AUTH_MODULE_PATH"] = config.auth_path - if env_file: - resolved_env_file = _resolve_path(env_file, cwd=cwd) - if not resolved_env_file.exists(): - raise CliError(f"Env file '{resolved_env_file}' does not exist.") - _apply_env_layer( - env, - _parse_env_file( - resolved_env_file, - context=interpolation_context, - ), - ) - # The launching shell is both the initial interpolation context and the - # highest-precedence output layer. - env.update(shell_env) - if config_path is not None: - env["AGENTSEEK_GRAPHS"] = str(config_path) - return env + inherited: dict[str, str], +) -> dict[str, str | None]: + try: + return parse_dotenv_file(path, ambient=inherited) + except DotenvFileError as exc: + raise CliError(str(exc)) from exc def _resolve_path_from_config(path_text: str, *, config_path: Path) -> Path: @@ -355,13 +283,32 @@ def build_runtime_env( cwd: Path, base_env: dict[str, str] | None = None, ) -> dict[str, str]: - shell_env = dict(os.environ if base_env is None else base_env) - return _build_env( - config_path=config_path, - env_file=env_file, - cwd=cwd, - shell_env=shell_env, - ) + 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) + if config_path is not None: + env["AGENTSEEK_GRAPHS"] = str(config_path) + return env def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ffce652..f594321 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,7 +3,6 @@ import argparse import importlib import io -import json import os import tomllib from dataclasses import dataclass @@ -13,7 +12,6 @@ from agentseek_api.services.langgraph_service import LangGraphService from scripts.dotenv_conformance import ( - CROSS_LAYER_CONFORMANCE_CASES, DOTENV_CONFORMANCE_CASES, DOTENV_CONFORMANCE_ENV_KEYS, ) @@ -391,10 +389,14 @@ def test_dev_command_rejects_unsupported_langgraph_flags(tmp_path: Path) -> None assert "Use 'langgraph dev' for mocked or tunneled local workflows." in stderr.getvalue() -def test_dev_command_marks_runtime_as_local_dev_for_studio_auth(tmp_path: Path) -> None: +def test_dev_command_forces_local_studio_auth_after_inherited_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: from agentseek_api.cli import main _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("STUDIO_AUTH_LOCAL_DEV", "false") capture = _RunCapture() exit_code = main(["dev", "--no-reload"], runner=capture, cwd=tmp_path) @@ -404,6 +406,29 @@ def test_dev_command_marks_runtime_as_local_dev_for_studio_auth(tmp_path: Path) assert capture.env["STUDIO_AUTH_LOCAL_DEV"] == "true" +def test_serve_port_flag_does_not_rewrite_inherited_port_env( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + monkeypatch.setenv("PORT", "7777") + capture = _RunCapture() + + exit_code = main( + ["serve", "--port", "3030"], + runner=capture, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert capture.command is not None + assert capture.command[-2:] == ["--port", "3030"] + assert capture.env is not None + assert capture.env["PORT"] == "7777" + + def test_resolve_dev_urls_use_localhost_display_and_loopback_base_url() -> None: from agentseek_api.cli import _resolve_dev_urls @@ -981,84 +1006,6 @@ def test_runtime_dotenv_interpolation_conforms_to_python_dotenv( assert {key: actual[key] for key in expected} == expected -def test_runtime_dotenv_interpolation_sees_prior_layers_before_final_shell_override(tmp_path: Path) -> None: - from agentseek_api.cli import build_runtime_env - - config_env = tmp_path / "config.env" - config_env.write_text("ORIGIN=https://config.example\n", encoding="utf-8") - config_path = tmp_path / "langgraph.json" - config_path.write_text( - '{"graphs":{"chat":"chat.graph:graph"},"env":"./config.env"}', - encoding="utf-8", - ) - cli_env = tmp_path / "cli.env" - cli_env.write_text("RESULT=${ORIGIN}/v1\n", encoding="utf-8") - - env = build_runtime_env( - config_path=config_path, - env_file=str(cli_env), - cwd=tmp_path, - base_env={"ORIGIN": "https://shell.example"}, - ) - - assert env["RESULT"] == "https://config.example/v1" - assert env["ORIGIN"] == "https://shell.example" - - -@pytest.mark.parametrize( - "case", - CROSS_LAYER_CONFORMANCE_CASES, - ids=[case["name"] for case in CROSS_LAYER_CONFORMANCE_CASES], -) -def test_runtime_cross_layer_dotenv_conformance(tmp_path: Path, case: dict[str, object]) -> None: - from agentseek_api.cli import build_runtime_env - - config_path = tmp_path / "langgraph.json" - config_path.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), - encoding="utf-8", - ) - config_dotenv = case["config_dotenv"] - if isinstance(config_dotenv, str): - (tmp_path / "config.env").write_text(config_dotenv, encoding="utf-8") - cli_env = tmp_path / "cli.env" - cli_dotenv = case["cli_dotenv"] - assert isinstance(cli_dotenv, str) - cli_env.write_text(cli_dotenv, encoding="utf-8") - shell_env = case["shell_env"] - assert isinstance(shell_env, dict) - - actual = build_runtime_env( - config_path=config_path, - env_file=str(cli_env), - cwd=tmp_path, - base_env=shell_env, - ) - - expected = case["expected"] - assert isinstance(expected, dict) - assert {key: actual[key] for key in expected} == expected - absent = case["absent"] - assert isinstance(absent, tuple) - assert all(key not in actual for key in absent) - - -def test_cli_dotenv_interpolation_sees_literal_config_mapping(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":{"ORIGIN":"https://mapping.example"}}', - encoding="utf-8", - ) - cli_env = tmp_path / "cli.env" - cli_env.write_text("RESULT=${ORIGIN}/v1\n", encoding="utf-8") - - env = build_runtime_env(config_path=config_path, env_file=str(cli_env), cwd=tmp_path, base_env={}) - - assert env["RESULT"] == "https://mapping.example/v1" - - def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env diff --git a/tests/unit/test_runtime_environment.py b/tests/unit/test_runtime_environment.py new file mode 100644 index 0000000..2d21686 --- /dev/null +++ b/tests/unit/test_runtime_environment.py @@ -0,0 +1,609 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def _write_config( + root: Path, + *, + env: str | dict[str, object] | None, + auth_path: str | None = None, +) -> Path: + payload: dict[str, object] = { + "graphs": {"chat": "chat.graph:graph"}, + } + if env is not None: + payload["env"] = env + if auth_path is not None: + payload["auth"] = {"path": auth_path} + config_path = root / "langgraph.json" + config_path.write_text(json.dumps(payload), encoding="utf-8") + return config_path + + +@pytest.mark.parametrize( + ("cli_binding", "inherited", "expected"), + [ + ("TOKEN=from-cli\n", {}, "from-cli"), + ("TOKEN\n", {}, "from-config"), + ("TOKEN=\n", {}, ""), + ("TOKEN=from-cli\n", {"TOKEN": ""}, ""), + ("TOKEN=from-cli\n", {"TOKEN": "from-shell"}, "from-shell"), + ], + ids=[ + "cli-over-config", + "valueless-does-not-assign", + "explicit-empty-assigns", + "inherited-empty-is-final", + "inherited-nonempty-is-final", + ], +) +def test_host_runtime_assignment_matrix( + tmp_path: Path, + cli_binding: str, + inherited: dict[str, str], + expected: str, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("TOKEN=from-config\n", encoding="utf-8") + config_path = _write_config(tmp_path, env="./config.env") + cli_env = tmp_path / "cli.env" + cli_env.write_text(cli_binding, encoding="utf-8") + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env=inherited, + ) + + assert actual["TOKEN"] == expected + + +def test_config_mapping_and_auth_are_below_cli_and_inherited( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config( + tmp_path, + env={"TOKEN": "from-mapping", "AUTH_MODULE_PATH": "from-env-mapping"}, + auth_path="auth.module:backend", + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text( + "TOKEN=from-cli\nAUTH_MODULE_PATH=from-cli-auth\n", + encoding="utf-8", + ) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={ + "TOKEN": "from-shell", + "AUTH_MODULE_PATH": "", + }, + ) + + assert actual["TOKEN"] == "from-shell" + assert actual["AUTH_MODULE_PATH"] == "" + + +def test_config_dotenv_valueless_is_absent_and_empty_is_present( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text("VALUELESS\nEMPTY=\n", encoding="utf-8") + config_path = _write_config(tmp_path, env="./config.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={}, + ) + + assert "VALUELESS" not in actual + assert actual["EMPTY"] == "" + + +@pytest.mark.parametrize( + "case", + [ + { + "id": "config-bare", + "config_env": ("dotenv", "KEY\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": False, + "value": None, + }, + { + "id": "config-empty", + "config_env": ("dotenv", "KEY=\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "config-value", + "config_env": ("dotenv", "KEY=config\n"), + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "config", + }, + { + "id": "mapping-empty", + "config_env": {"KEY": ""}, + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "mapping-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": None, + "inherited": {}, + "key": "KEY", + "present": True, + "value": "mapping", + }, + { + "id": "auth-over-dotenv", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": None, + "inherited": {}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "auth.module:backend", + }, + { + "id": "cli-bare", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "mapping", + }, + { + "id": "cli-empty", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "cli-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {}, + "key": "KEY", + "present": True, + "value": "cli", + }, + { + "id": "inherited-empty", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {"KEY": ""}, + "key": "KEY", + "present": True, + "value": "", + }, + { + "id": "inherited-value", + "config_env": {"KEY": "mapping"}, + "auth_path": None, + "cli_dotenv": "KEY=cli\n", + "inherited": {"KEY": "shell"}, + "key": "KEY", + "present": True, + "value": "shell", + }, + { + "id": "inherited-empty-auth", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": "AUTH_MODULE_PATH=cli\n", + "inherited": {"AUTH_MODULE_PATH": ""}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "", + }, + { + "id": "inherited-value-auth", + "config_env": ("dotenv", "AUTH_MODULE_PATH=dotenv\n"), + "auth_path": "auth.module:backend", + "cli_dotenv": "AUTH_MODULE_PATH=cli\n", + "inherited": {"AUTH_MODULE_PATH": "shell"}, + "key": "AUTH_MODULE_PATH", + "present": True, + "value": "shell", + }, + ], + ids=lambda case: case["id"], +) +def test_complete_host_assignment_collision_matrix( + tmp_path: Path, + case: dict[str, object], +) -> None: + from agentseek_api.cli import build_runtime_env + + config_source = case["config_env"] + if isinstance(config_source, tuple): + _, contents = config_source + assert isinstance(contents, str) + (tmp_path / "config.env").write_text(contents, encoding="utf-8") + config_env: str | dict[str, object] = "./config.env" + else: + assert isinstance(config_source, dict) + config_env = config_source + auth_path = case["auth_path"] + assert auth_path is None or isinstance(auth_path, str) + config_path = _write_config( + tmp_path, + env=config_env, + auth_path=auth_path, + ) + cli_dotenv = case["cli_dotenv"] + cli_env: Path | None = None + if cli_dotenv is not None: + assert isinstance(cli_dotenv, str) + cli_env = tmp_path / "cli.env" + cli_env.write_text(cli_dotenv, encoding="utf-8") + inherited = case["inherited"] + assert isinstance(inherited, dict) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env) if cli_env is not None else None, + cwd=tmp_path, + base_env=inherited, + ) + + key = case["key"] + assert isinstance(key, str) + assert (key in actual) is case["present"] + if case["present"]: + assert actual[key] == case["value"] + + +def test_each_dotenv_file_uses_an_independent_interpolation_context( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text( + "ORIGIN=https://config.example\n" + "CONFIG_RESULT=${ORIGIN}/v1\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./config.env") + cli_env = tmp_path / "cli.env" + cli_env.write_text("CLI_RESULT=${ORIGIN}/v2\n", encoding="utf-8") + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={"ORIGIN": "https://shell.example"}, + ) + + assert actual == { + "CONFIG_RESULT": "https://config.example/v1", + "CLI_RESULT": "https://shell.example/v2", + "ORIGIN": "https://shell.example", + "AGENTSEEK_GRAPHS": str(config_path), + } + + +def test_cli_dotenv_does_not_interpolate_literal_config_mapping( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config( + tmp_path, + env={"ORIGIN": "https://mapping.example"}, + ) + cli_env = tmp_path / "cli.env" + cli_env.write_text( + "RESULT=${ORIGIN:-https://fallback.example}/v1\n", + encoding="utf-8", + ) + + actual = build_runtime_env( + config_path=config_path, + env_file=str(cli_env), + cwd=tmp_path, + base_env={}, + ) + + assert actual["ORIGIN"] == "https://mapping.example" + assert actual["RESULT"] == "https://fallback.example/v1" + + +def test_inherited_override_does_not_recompute_earlier_file_value( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_env = tmp_path / "config.env" + config_env.write_text( + "ORIGIN=https://config.example\n" + "BASE_URL=${ORIGIN}/v1\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./config.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={"ORIGIN": "https://shell.example"}, + ) + + assert actual["ORIGIN"] == "https://shell.example" + assert actual["BASE_URL"] == "https://config.example/v1" + + +def test_malformed_dotenv_returns_exit_2_without_starting_child( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + config_path = _write_config(tmp_path, env=None) + env_file = tmp_path / "broken.env" + env_file.write_text('SECRET=must-not-leak\nBROKEN "value"\n', encoding="utf-8") + calls: list[list[str]] = [] + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + calls.append(command) + return 0 + + import io + + stderr = io.StringIO() + exit_code = main( + [ + "serve", + "--config", + str(config_path), + "--env-file", + str(env_file), + ], + runner=runner, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert calls == [] + assert "line 2" in stderr.getvalue() + assert "must-not-leak" not in stderr.getvalue() + + +@pytest.mark.parametrize( + "failure", + ["missing", "decode", "read"], +) +def test_unreadable_dotenv_returns_exit_2_without_starting_child( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure: str, +) -> None: + import io + + from agentseek_api.cli import main + + config_path = _write_config(tmp_path, env=None) + env_file = tmp_path / f"{failure}.env" + if failure == "decode": + env_file.write_bytes(b"TOKEN=\xff\n") + elif failure == "read": + env_file.write_text("TOKEN=hidden\n", encoding="utf-8") + original_open = Path.open + + def fail_selected_open(path: Path, *args: object, **kwargs: object): + if path == env_file: + raise PermissionError(13, "Permission denied", str(path)) + return original_open(path, *args, **kwargs) + + monkeypatch.setattr(Path, "open", fail_selected_open) + calls: list[list[str]] = [] + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + calls.append(command) + return 0 + + stderr = io.StringIO() + exit_code = main( + [ + "serve", + "--config", + str(config_path), + "--env-file", + str(env_file), + ], + runner=runner, + cwd=tmp_path, + stderr=stderr, + ) + + assert exit_code == 2 + assert calls == [] + if failure == "missing": + assert "does not exist" in stderr.getvalue() + elif failure == "decode": + assert "not valid UTF-8" in stderr.getvalue() + else: + assert "could not be read" in stderr.getvalue() + assert "hidden" not in stderr.getvalue() + + +def test_command_owned_graph_path_overrides_inherited_value( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + config_path = _write_config(tmp_path, env=None) + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env={"AGENTSEEK_GRAPHS": "/stale/manifest.json"}, + ) + + assert actual["AGENTSEEK_GRAPHS"] == str(config_path) + + +def test_command_owned_graph_path_is_absent_without_selected_config( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + actual = build_runtime_env( + config_path=None, + env_file=None, + cwd=tmp_path, + base_env={"AGENTSEEK_GRAPHS": "/stale/manifest.json"}, + ) + + assert "AGENTSEEK_GRAPHS" not in actual + + +def test_shared_lifecycle_dotenv_mutation_cannot_replace_inherited_present_values( + tmp_path: Path, +) -> None: + from agentseek_api.cli import build_runtime_env + + shared_env = tmp_path / ".env" + shared_env.write_text( + "PRESENT=initial\n" + "EMPTY=\n" + "CHILD_ONLY=initial\n", + encoding="utf-8", + ) + snapshot = {"PRESENT": "initial", "EMPTY": ""} + shared_env.write_text( + "PRESENT=mutated\n" + "EMPTY=mutated\n" + "CHILD_ONLY=added-later\n", + encoding="utf-8", + ) + config_path = _write_config(tmp_path, env="./.env") + + actual = build_runtime_env( + config_path=config_path, + env_file=None, + cwd=tmp_path, + base_env=snapshot, + ) + + assert actual["PRESENT"] == "initial" + assert "EMPTY" in actual + assert actual["EMPTY"] == "" + assert actual["CHILD_ONLY"] == "added-later" + + +@pytest.mark.parametrize("role", ["dev", "serve", "worker", "scheduler"]) +@pytest.mark.parametrize( + ("source", "source_value"), + [ + ("config-dotenv", "false"), + ("config-mapping", "false"), + ("cli-dotenv", "false"), + ("inherited-empty", ""), + ("inherited-nonempty", "false"), + ], +) +def test_studio_auth_local_dev_is_command_owned_only_for_dev( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + role: str, + source: str, + source_value: str, +) -> None: + from agentseek_api.cli import main + + monkeypatch.delenv("STUDIO_AUTH_LOCAL_DEV", raising=False) + config_env: str | dict[str, object] | None = None + env_file: Path | None = None + if source == "config-dotenv": + (tmp_path / "config.env").write_text( + "STUDIO_AUTH_LOCAL_DEV=false\n", + encoding="utf-8", + ) + config_env = "./config.env" + elif source == "config-mapping": + config_env = {"STUDIO_AUTH_LOCAL_DEV": "false"} + elif source == "cli-dotenv": + env_file = tmp_path / "cli.env" + env_file.write_text("STUDIO_AUTH_LOCAL_DEV=false\n", encoding="utf-8") + else: + monkeypatch.setenv("STUDIO_AUTH_LOCAL_DEV", source_value) + config_path = _write_config(tmp_path, env=config_env) + captured_env: dict[str, str] | None = None + + def runner( + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> int: + nonlocal captured_env + captured_env = env + return 0 + + argv = [role, "--config", str(config_path)] + if role == "dev": + argv.append("--no-reload") + if env_file is not None: + argv.extend(["--env-file", str(env_file)]) + + exit_code = main(argv, runner=runner, cwd=tmp_path) + + assert exit_code == 0 + assert captured_env is not None + expected = "true" if role == "dev" else source_value + assert captured_env["STUDIO_AUTH_LOCAL_DEV"] == expected From ca9591f36aed782bdc07f5477ae5d7d61e784004 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:30:01 +0800 Subject: [PATCH 16/28] fix: launch runtime roles in fresh processes --- src/agentseek_api/cli.py | 59 ++-- src/agentseek_api/runtime_entrypoint.py | 59 ++++ .../runtime_settings_probe/sitecustomize.py | 34 +++ .../integration/test_cli_runtime_processes.py | 261 ++++++++++++++++++ tests/unit/test_cli.py | 138 ++++----- 5 files changed, 458 insertions(+), 93 deletions(-) create mode 100644 src/agentseek_api/runtime_entrypoint.py create mode 100644 tests/fixtures/runtime_settings_probe/sitecustomize.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 8d22270..ece0b12 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -312,18 +312,39 @@ def build_runtime_env( def build_uvicorn_command(*, host: str, port: int, reload_enabled: bool) -> list[str]: - command = [sys.executable, "-m", "uvicorn", "agentseek_api.main:app", "--host", host, "--port", str(port)] + command = [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + host, + "--port", + str(port), + ] if reload_enabled: command.append("--reload") return command def build_worker_command() -> list[str]: - return [sys.executable, "-m", "agentseek_api.worker"] + return [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "worker", + ] def build_scheduler_command() -> list[str]: - return [sys.executable, "-m", "agentseek_api.scheduler"] + return [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "scheduler", + ] def _default_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: @@ -429,6 +450,10 @@ def _terminate_child(_signum, _frame) -> None: browser_opener = webbrowser.open browser_opener(urls.studio_url) return process.wait() + except CliError: + if process.poll() is not None: + return process.returncode + raise except KeyboardInterrupt: if process.poll() is None: process.terminate() @@ -489,40 +514,12 @@ 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) - if runner is _default_runner: - from agentseek_api import worker as worker_module - - previous_env = os.environ.copy() - previous_cwd = Path.cwd() - try: - os.environ.clear() - os.environ.update(env) - os.chdir(cwd) - return worker_module.main() - finally: - os.chdir(previous_cwd) - os.environ.clear() - os.environ.update(previous_env) 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) - if runner is _default_runner: - from agentseek_api import scheduler as scheduler_module - - previous_env = os.environ.copy() - previous_cwd = Path.cwd() - try: - os.environ.clear() - os.environ.update(env) - os.chdir(cwd) - return scheduler_module.main() - finally: - os.chdir(previous_cwd) - os.environ.clear() - os.environ.update(previous_env) return runner(build_scheduler_command(), env=env, cwd=str(cwd)) diff --git a/src/agentseek_api/runtime_entrypoint.py b/src/agentseek_api/runtime_entrypoint.py new file mode 100644 index 0000000..f9ae427 --- /dev/null +++ b/src/agentseek_api/runtime_entrypoint.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +import importlib +import runpy +import sys +from collections.abc import Sequence + +from pydantic import ValidationError + + +TARGET_MODULES = { + "uvicorn": "uvicorn.__main__", + "worker": "agentseek_api.worker", + "scheduler": "agentseek_api.scheduler", +} + + +def _format_settings_validation_error(exc: ValidationError) -> str: + fields = sorted( + { + ".".join(str(part) for part in error["loc"]) + f" ({error['type']})" + for error in exc.errors(include_input=False, include_url=False) + } + ) + return f"Invalid runtime setting(s): {', '.join(fields)}." + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + if not arguments or arguments[0] not in TARGET_MODULES: + sys.stderr.write("Invalid internal runtime target.\n") + return 2 + target_name, *target_argv = arguments + if target_argv[:1] == ["--"]: + target_argv = target_argv[1:] + target_module = TARGET_MODULES[target_name] + previous_argv = sys.argv + sys.argv = [target_module, *target_argv] + try: + try: + importlib.import_module("agentseek_api.settings") + except ValidationError as exc: + sys.stderr.write(_format_settings_validation_error(exc) + "\n") + return 2 + try: + runpy.run_module(target_module, run_name="__main__") + except SystemExit as exc: + return ( + exc.code + if isinstance(exc.code, int) + else (0 if exc.code is None else 1) + ) + return 0 + finally: + sys.argv = previous_argv + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/fixtures/runtime_settings_probe/sitecustomize.py b/tests/fixtures/runtime_settings_probe/sitecustomize.py new file mode 100644 index 0000000..5a7a0a4 --- /dev/null +++ b/tests/fixtures/runtime_settings_probe/sitecustomize.py @@ -0,0 +1,34 @@ +"""Test-only probe loaded by runtime-role child interpreters.""" + +from __future__ import annotations + +import asyncio +import json +import os +from pathlib import Path + + +probe_path = os.environ.get("AGENTSEEK_SETTINGS_PROBE_PATH") +if probe_path: + probe_fields = tuple( + field + for field in os.environ["AGENTSEEK_SETTINGS_PROBE_FIELDS"].split(",") + if field + ) + probe_exit_code = int(os.environ.get("AGENTSEEK_SETTINGS_PROBE_EXIT_CODE", "0")) + + def _record_settings(awaitable) -> int: + awaitable.close() + from agentseek_api.settings import settings + + observed = { + "pid": os.getpid(), + "settings": {field: getattr(settings, field) for field in probe_fields}, + } + Path(probe_path).write_text( + json.dumps(observed, sort_keys=True), + encoding="utf-8", + ) + return probe_exit_code + + asyncio.run = _record_settings diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 05bc5ea..195ae73 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import os import subprocess import sys @@ -8,6 +9,11 @@ import pytest +PROBE_SITE_DIR = ( + Path(__file__).resolve().parents[1] / "fixtures" / "runtime_settings_probe" +) + + def _run_python( *arguments: str, cwd: Path, @@ -22,7 +28,84 @@ def _run_python( check=False, capture_output=True, text=True, + timeout=20, + ) + + +def _write_runtime_config( + root: Path, + name: str, + env_mapping: dict[str, object], +) -> Path: + config_path = root / f"{name}.json" + config_path.write_text( + json.dumps( + { + "graphs": {"chat": "chat.graph:graph"}, + "env": env_mapping, + } + ), + encoding="utf-8", + ) + return config_path + + +def _settings_probe_environment( + *, + output_path: Path, + fields: tuple[str, ...], + exit_code: int, +) -> dict[str, str]: + environment = os.environ.copy() + existing_pythonpath = environment.get("PYTHONPATH") + pythonpath = str(PROBE_SITE_DIR) + if existing_pythonpath: + pythonpath = os.pathsep.join((pythonpath, existing_pythonpath)) + environment.update( + { + "PYTHONPATH": pythonpath, + "AGENTSEEK_SETTINGS_PROBE_PATH": str(output_path), + "AGENTSEEK_SETTINGS_PROBE_FIELDS": ",".join(fields), + "AGENTSEEK_SETTINGS_PROBE_EXIT_CODE": str(exit_code), + } + ) + for field in fields: + environment.pop(field, None) + return environment + + +def _run_role_probe( + *, + role: str, + config_path: Path, + environment: dict[str, str], +) -> tuple[int, int, str]: + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ], + cwd=config_path.parent, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, ) + try: + _stdout, stderr = process.communicate(timeout=20) + except subprocess.TimeoutExpired: + process.terminate() + try: + process.communicate(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.communicate() + raise + return process.returncode, process.pid, stderr def test_cli_import_does_not_import_runtime_settings(tmp_path: Path) -> None: @@ -86,3 +169,181 @@ def test_dockerfile_rendering_ignores_invalid_runtime_settings( assert result.returncode == 0, result.stderr assert output_path.exists() assert "ValidationError" not in result.stderr + + +@pytest.mark.parametrize( + ("role", "invalid_field", "invalid_value", "error_type"), + [ + ("dev", "PORT", "invalid-port-canary", "int_parsing"), + ("serve", "PORT", "invalid-port-canary", "int_parsing"), + ( + "worker", + "WORKER_CONCURRENT_JOBS", + "invalid-jobs-canary", + "int_parsing", + ), + ( + "scheduler", + "WORKER_CONCURRENT_JOBS", + "invalid-jobs-canary", + "int_parsing", + ), + ], +) +def test_invalid_runtime_setting_is_redacted_by_fresh_child( + tmp_path: Path, + role: str, + invalid_field: str, + invalid_value: str, + error_type: str, +) -> None: + config_path = _write_runtime_config( + tmp_path, + f"invalid-{role}", + {invalid_field: invalid_value}, + ) + arguments = [ + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ] + if role == "dev": + arguments.append("--no-reload") + + result = _run_python(*arguments, cwd=tmp_path) + + assert result.returncode == 2 + assert result.stderr == ( + f"Invalid runtime setting(s): {invalid_field} ({error_type}).\n" + ) + assert invalid_value not in result.stderr + assert "ValidationError" not in result.stderr + assert "input_value" not in result.stderr + assert "Traceback" not in result.stderr + + +def test_invalid_internal_runtime_target_returns_fixed_error( + tmp_path: Path, +) -> None: + result = _run_python( + "-m", + "agentseek_api.runtime_entrypoint", + "invalid-target", + cwd=tmp_path, + ) + + assert result.returncode == 2 + assert result.stderr == "Invalid internal runtime target.\n" + + +@pytest.mark.parametrize( + ("role", "env_mapping", "fields", "expected", "exit_code"), + [ + ( + "worker", + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": 3, + "REDIS_URL": "redis://worker.example:6379/1", + }, + ( + "EXECUTOR_BACKEND", + "WORKER_CONCURRENT_JOBS", + "REDIS_URL", + ), + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": 3, + "REDIS_URL": "redis://worker.example:6379/1", + }, + 17, + ), + ( + "scheduler", + { + "SCHEDULER_CLAIM_LIMIT": 23, + "SCHEDULER_POLL_INTERVAL_SECONDS": 0.25, + "REDIS_URL": "redis://scheduler.example:6379/2", + }, + ( + "SCHEDULER_CLAIM_LIMIT", + "SCHEDULER_POLL_INTERVAL_SECONDS", + "REDIS_URL", + ), + { + "SCHEDULER_CLAIM_LIMIT": 23, + "SCHEDULER_POLL_INTERVAL_SECONDS": 0.25, + "REDIS_URL": "redis://scheduler.example:6379/2", + }, + 19, + ), + ], + ids=["worker", "scheduler"], +) +def test_runtime_role_default_path_observes_settings_in_fresh_child( + tmp_path: Path, + role: str, + env_mapping: dict[str, object], + fields: tuple[str, ...], + expected: dict[str, object], + exit_code: int, +) -> None: + config_path = _write_runtime_config( + tmp_path, + f"{role}-config", + env_mapping, + ) + probe_output = tmp_path / f"{role}-settings.json" + environment = _settings_probe_environment( + output_path=probe_output, + fields=fields, + exit_code=exit_code, + ) + + actual_exit_code, cli_pid, stderr = _run_role_probe( + role=role, + config_path=config_path, + environment=environment, + ) + + assert actual_exit_code == exit_code + assert stderr == "" + observation = json.loads(probe_output.read_text(encoding="utf-8")) + assert observation["pid"] != cli_pid + assert observation["settings"] == expected + + +def test_sequential_worker_invocations_do_not_reuse_settings_singleton( + tmp_path: Path, +) -> None: + observed: list[int] = [] + for index, concurrent_jobs in enumerate((2, 7), start=1): + config_path = _write_runtime_config( + tmp_path, + f"worker-{index}", + { + "EXECUTOR_BACKEND": "redis", + "WORKER_CONCURRENT_JOBS": concurrent_jobs, + }, + ) + probe_output = tmp_path / f"worker-{index}.json" + environment = _settings_probe_environment( + output_path=probe_output, + fields=("WORKER_CONCURRENT_JOBS",), + exit_code=0, + ) + + exit_code, cli_pid, stderr = _run_role_probe( + role="worker", + config_path=config_path, + environment=environment, + ) + assert exit_code == 0 + assert stderr == "" + observation = json.loads(probe_output.read_text(encoding="utf-8")) + assert observation["pid"] != cli_pid + observed.append(observation["settings"]["WORKER_CONCURRENT_JOBS"]) + + assert observed == [2, 7] diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index f594321..ea1f8da 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -9,6 +9,7 @@ from pathlib import Path import pytest +from pydantic import ValidationError from agentseek_api.services.langgraph_service import LangGraphService from scripts.dotenv_conformance import ( @@ -103,7 +104,17 @@ def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) exit_code = main(["dev", "--no-reload"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "127.0.0.1", "--port", "2024"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -117,7 +128,17 @@ def test_serve_command_falls_back_to_langgraph_json_and_runs_graph(tmp_path: Pat exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -139,7 +160,17 @@ def test_serve_command_uses_agentseek_graphs_env_for_manifest_named_config( exit_code = main(["serve", "--host", "0.0.0.0", "--port", "3030"], runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) @@ -154,41 +185,15 @@ def test_worker_command_uses_runtime_env_and_worker_module(tmp_path: Path) -> No assert exit_code == 0 assert capture.command is not None - assert capture.command[1:] == ["-m", "agentseek_api.worker"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "worker", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_worker_command_runs_in_process_with_default_runner( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from agentseek_api import cli as cli_module - - config_path = _write_basic_langgraph_config(tmp_path) - observed: dict[str, object] = {} - previous_cwd = Path.cwd() - sentinel_key = "AGENTSEEK_WORKER_TEST_SENTINEL" - - def fake_worker_main() -> int: - observed["graphs"] = cli_module.os.environ["AGENTSEEK_GRAPHS"] - observed["cwd"] = str(Path.cwd()) - return 7 - - monkeypatch.setattr("agentseek_api.worker.main", fake_worker_main) - monkeypatch.setenv(sentinel_key, "before") - - exit_code = cli_module.main(["worker", "--config", str(config_path)], cwd=tmp_path) - - assert exit_code == 7 - assert observed == { - "graphs": str(config_path.resolve()), - "cwd": str(tmp_path.resolve()), - } - assert Path.cwd() == previous_cwd - assert cli_module.os.environ.get(sentinel_key) == "before" - - def test_scheduler_command_uses_runtime_env_and_scheduler_module(tmp_path: Path) -> None: from agentseek_api.cli import main @@ -199,39 +204,28 @@ def test_scheduler_command_uses_runtime_env_and_scheduler_module(tmp_path: Path) assert exit_code == 0 assert capture.command is not None - assert capture.command[1:] == ["-m", "agentseek_api.scheduler"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "scheduler", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) -def test_scheduler_command_runs_in_process_with_default_runner( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, -) -> None: - from agentseek_api import cli as cli_module - - config_path = _write_basic_langgraph_config(tmp_path) - observed: dict[str, object] = {} - previous_cwd = Path.cwd() - sentinel_key = "AGENTSEEK_SCHEDULER_TEST_SENTINEL" - - def fake_scheduler_main() -> int: - observed["graphs"] = cli_module.os.environ["AGENTSEEK_GRAPHS"] - observed["cwd"] = str(Path.cwd()) - return 11 +def test_settings_validation_formatter_omits_input_values() -> None: + from agentseek_api.runtime_entrypoint import ( + _format_settings_validation_error, + ) + from agentseek_api.settings import Settings - monkeypatch.setattr("agentseek_api.scheduler.main", fake_scheduler_main) - monkeypatch.setenv(sentinel_key, "before") + with pytest.raises(ValidationError) as captured: + Settings.model_validate({"PORT": "invalid-port-canary"}) - exit_code = cli_module.main(["scheduler", "--config", str(config_path)], cwd=tmp_path) + message = _format_settings_validation_error(captured.value) - assert exit_code == 11 - assert observed == { - "graphs": str(config_path.resolve()), - "cwd": str(tmp_path.resolve()), - } - assert Path.cwd() == previous_cwd - assert cli_module.os.environ.get(sentinel_key) == "before" + assert message == "Invalid runtime setting(s): PORT (int_parsing)." + assert "invalid-port-canary" not in message def test_dev_command_accepts_langgraph_cli_flags_and_env_file( @@ -263,7 +257,17 @@ def test_dev_command_accepts_langgraph_cli_flags_and_env_file( ) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "9999"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "9999", + ] assert capture.env is not None assert capture.env["AGENTSEEK_GRAPHS"] == str(config_path.resolve()) assert capture.env["AUTH_MODULE_PATH"] == "test.module:backend" @@ -632,7 +636,17 @@ def test_run_namespace_allows_parent_cli_dispatch(tmp_path: Path) -> None: exit_code = cli_module.run_namespace(parsed, runner=capture, cwd=tmp_path) assert exit_code == 0 - assert capture.command[2:] == ["uvicorn", "agentseek_api.main:app", "--host", "0.0.0.0", "--port", "3030"] + assert capture.command[1:] == [ + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "0.0.0.0", + "--port", + "3030", + ] def test_dockerfile_command_writes_langgraph_compatible_runtime_file(tmp_path: Path) -> None: From 9857fc5e72ebdeb581157d0714b1d7843bdcfa84 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 17:38:02 +0800 Subject: [PATCH 17/28] test: harden runtime child process regressions --- .../runtime_settings_probe/sitecustomize.py | 10 +- .../integration/test_cli_runtime_processes.py | 115 ++++++++++++++++-- 2 files changed, 116 insertions(+), 9 deletions(-) diff --git a/tests/fixtures/runtime_settings_probe/sitecustomize.py b/tests/fixtures/runtime_settings_probe/sitecustomize.py index 5a7a0a4..912a3d1 100644 --- a/tests/fixtures/runtime_settings_probe/sitecustomize.py +++ b/tests/fixtures/runtime_settings_probe/sitecustomize.py @@ -1,4 +1,4 @@ -"""Test-only probe loaded by runtime-role child interpreters.""" +"""Test-only probe loaded by runtime child interpreters.""" from __future__ import annotations @@ -8,6 +8,14 @@ from pathlib import Path +validation_child_pid_path = os.environ.get("AGENTSEEK_VALIDATION_CHILD_PID_PATH") +if validation_child_pid_path: + Path(validation_child_pid_path).write_text( + str(os.getpid()), + encoding="utf-8", + ) + + probe_path = os.environ.get("AGENTSEEK_SETTINGS_PROBE_PATH") if probe_path: probe_fields = tuple( diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 195ae73..82cd476 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -2,8 +2,10 @@ import json import os +import signal import subprocess import sys +import time from pathlib import Path import pytest @@ -12,15 +14,91 @@ PROBE_SITE_DIR = ( Path(__file__).resolve().parents[1] / "fixtures" / "runtime_settings_probe" ) +VALIDATION_CHILD_PID_PATH_ENV = "AGENTSEEK_VALIDATION_CHILD_PID_PATH" + + +def _probe_pythonpath() -> str: + existing_pythonpath = os.environ.get("PYTHONPATH") + pythonpath = str(PROBE_SITE_DIR) + if existing_pythonpath: + pythonpath = os.pathsep.join((pythonpath, existing_pythonpath)) + return pythonpath + + +def _pid_is_alive(pid: int) -> bool: + if os.name == "nt": + import ctypes + from ctypes import wintypes + + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + open_process = kernel32.OpenProcess + open_process.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] + open_process.restype = wintypes.HANDLE + get_exit_code = kernel32.GetExitCodeProcess + get_exit_code.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)] + get_exit_code.restype = wintypes.BOOL + close_handle = kernel32.CloseHandle + close_handle.argtypes = [wintypes.HANDLE] + close_handle.restype = wintypes.BOOL + handle = open_process(0x1000, False, pid) + if not handle: + return False + try: + exit_code = wintypes.DWORD() + if not get_exit_code(handle, ctypes.byref(exit_code)): + return False + return exit_code.value == 259 + finally: + close_handle(handle) + + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _read_observed_pid(path: Path, *, timeout_seconds: float = 2.0) -> int: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + return int(path.read_text(encoding="utf-8")) + except FileNotFoundError: + time.sleep(0.01) + raise AssertionError("Runtime validation child PID was not observed.") + + +def _terminate_observed_pid(pid: int, *, timeout_seconds: float = 2.0) -> None: + if not _pid_is_alive(pid): + return + os.kill(pid, signal.SIGTERM) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not _pid_is_alive(pid): + return + time.sleep(0.01) + kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM) + os.kill(pid, kill_signal) + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not _pid_is_alive(pid): + return + time.sleep(0.01) + raise AssertionError(f"Runtime validation child PID {pid} did not exit.") def _run_python( *arguments: str, cwd: Path, extra_env: dict[str, str] | None = None, + removed_env: tuple[str, ...] = (), ) -> subprocess.CompletedProcess[str]: env = dict(os.environ) env.update(extra_env or {}) + for field in removed_env: + env.pop(field, None) return subprocess.run( [sys.executable, *arguments], cwd=cwd, @@ -57,13 +135,9 @@ def _settings_probe_environment( exit_code: int, ) -> dict[str, str]: environment = os.environ.copy() - existing_pythonpath = environment.get("PYTHONPATH") - pythonpath = str(PROBE_SITE_DIR) - if existing_pythonpath: - pythonpath = os.pathsep.join((pythonpath, existing_pythonpath)) environment.update( { - "PYTHONPATH": pythonpath, + "PYTHONPATH": _probe_pythonpath(), "AGENTSEEK_SETTINGS_PROBE_PATH": str(output_path), "AGENTSEEK_SETTINGS_PROBE_FIELDS": ",".join(fields), "AGENTSEEK_SETTINGS_PROBE_EXIT_CODE": str(exit_code), @@ -190,17 +264,26 @@ def test_dockerfile_rendering_ignores_invalid_runtime_settings( ), ], ) -def test_invalid_runtime_setting_is_redacted_by_fresh_child( +def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, role: str, invalid_field: str, invalid_value: str, error_type: str, ) -> None: + monkeypatch.setenv( + invalid_field, + "2024" if invalid_field == "PORT" else "10", + ) + validation_child_pid_path = tmp_path / f"{role}-validation-child.pid" config_path = _write_runtime_config( tmp_path, f"invalid-{role}", - {invalid_field: invalid_value}, + { + invalid_field: invalid_value, + VALIDATION_CHILD_PID_PATH_ENV: str(validation_child_pid_path), + }, ) arguments = [ "-m", @@ -212,7 +295,22 @@ def test_invalid_runtime_setting_is_redacted_by_fresh_child( if role == "dev": arguments.append("--no-reload") - result = _run_python(*arguments, cwd=tmp_path) + observed_pid: int | None = None + child_alive_after_cli: bool | None = None + try: + result = _run_python( + *arguments, + cwd=tmp_path, + extra_env={"PYTHONPATH": _probe_pythonpath()}, + removed_env=(invalid_field, VALIDATION_CHILD_PID_PATH_ENV), + ) + observed_pid = _read_observed_pid(validation_child_pid_path) + child_alive_after_cli = _pid_is_alive(observed_pid) + finally: + if observed_pid is None and validation_child_pid_path.exists(): + observed_pid = int(validation_child_pid_path.read_text(encoding="utf-8")) + if observed_pid is not None: + _terminate_observed_pid(observed_pid) assert result.returncode == 2 assert result.stderr == ( @@ -222,6 +320,7 @@ def test_invalid_runtime_setting_is_redacted_by_fresh_child( assert "ValidationError" not in result.stderr assert "input_value" not in result.stderr assert "Traceback" not in result.stderr + assert child_alive_after_cli is False def test_invalid_internal_runtime_target_returns_fixed_error( From 2e9d79b2b531f1e5fc975b6a1a909be0ac528cba Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 18:34:52 +0800 Subject: [PATCH 18/28] fix: clean up interrupted child processes --- src/agentseek_api/cli.py | 36 +- src/agentseek_api/process_supervisor.py | 1495 +++++++++++++++++ .../runtime_settings_probe/sitecustomize.py | 26 +- tests/fixtures/termination_tree.py | 67 + .../integration/test_cli_runtime_processes.py | 250 +++ tests/unit/test_cli.py | 240 +++ tests/unit/test_process_supervisor.py | 1269 ++++++++++++++ 7 files changed, 3380 insertions(+), 3 deletions(-) create mode 100644 src/agentseek_api/process_supervisor.py create mode 100644 tests/fixtures/termination_tree.py create mode 100644 tests/unit/test_process_supervisor.py diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index ece0b12..5b30101 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -17,6 +17,12 @@ from agentseek_api import __version__ from agentseek_api.constants import DEFAULT_API_PORT from agentseek_api.dotenv_adapter import DotenvFileError, parse_dotenv_file +from agentseek_api.process_supervisor import ( + ForegroundChildSupervisor, + ForwardingSignalGuard, + ProcessSupervisionError, + _ForwardedSignal, +) DEFAULT_CLI_NAME = "agentseek-api" @@ -348,8 +354,34 @@ def build_scheduler_command() -> list[str]: def _default_runner(command: list[str], *, env: dict[str, str], cwd: str | None = None) -> int: - completed = subprocess.run(command, env=env, cwd=cwd, check=False) - return completed.returncode + try: + with ForwardingSignalGuard() as signals: + child = ForegroundChildSupervisor.start(command, env=env, cwd=cwd) + try: + signals.attach(child) + exit_code = child.wait() + child.close_remaining_tree(timeout=5.0) + return exit_code + except KeyboardInterrupt: + signals.begin_cleanup() + child.forward_and_reap(signal.SIGINT, timeout=5.0) + return 130 + except _ForwardedSignal as exc: + signals.begin_cleanup() + child.forward_and_reap(exc.signum, timeout=5.0) + return 128 + exc.signum + except BaseException: + signals.begin_cleanup() + child.terminate_and_reap(timeout=5.0) + raise + finally: + signals.begin_cleanup() + try: + child.ensure_closed(timeout=5.0) + finally: + child.close() + except ProcessSupervisionError as exc: + raise CliError("Could not supervise the runtime child safely.") from exc def _format_http_host(host: str) -> str: diff --git a/src/agentseek_api/process_supervisor.py b/src/agentseek_api/process_supervisor.py new file mode 100644 index 0000000..58d6d7f --- /dev/null +++ b/src/agentseek_api/process_supervisor.py @@ -0,0 +1,1495 @@ +from __future__ import annotations + +import ctypes +import errno +import math +import os +import signal +import subprocess +import sys +import threading +import time +from ctypes import wintypes +from types import FrameType +from typing import Protocol, Self + + +_IS_WINDOWS = os.name == "nt" +_MANAGED_SIGNALS = (signal.SIGINT, signal.SIGTERM) +_SUPERVISION_ERROR = "Runtime child supervision failed." +_WINDOWS_WAIT_POLL_SECONDS = 0.05 +_DARWIN_P_PID = 1 +_DARWIN_WNOHANG = 0x00000001 +_DARWIN_WEXITED = 0x00000004 +_DARWIN_WNOWAIT = 0x00000020 +_CLD_EXITED = 1 +_CLD_KILLED = 2 +_CLD_DUMPED = 3 +_DARWIN_WAITID_FUNCTION = None + + +class ProcessSupervisionError(RuntimeError): + """A value-free failure at the child-process ownership boundary.""" + + def __init__(self, _detail: object | None = None) -> None: + super().__init__(_SUPERVISION_ERROR) + + +class _ForwardedSignal(Exception): + def __init__(self, signum: int) -> None: + super().__init__() + self.signum = signum + + +class _SignalTarget(Protocol): + def forward_signal(self, signum: int) -> None: ... + + +class ForwardingSignalGuard: + """Own temporary foreground handlers without exposing an unguarded spawn gap.""" + + def __init__(self) -> None: + self._state = "new" + self._child: _SignalTarget | None = None + self._pending_signal: int | None = None + self._previous_handlers: dict[int, object] = {} + self._installed_signals: list[int] = [] + self._original_mask: set[signal.Signals] | None = None + self._mask_is_blocked = False + self._cleanup_forward_failed = False + self._installed_handler = self._handle_signal + + def __enter__(self) -> Self: + if threading.current_thread() is not threading.main_thread(): + raise ProcessSupervisionError() + self._state = "acquiring" + try: + self._block_for_handler_installation() + if self._original_mask is not None and any( + signum in self._original_mask for signum in _MANAGED_SIGNALS + ): + raise ProcessSupervisionError() + for signum in _MANAGED_SIGNALS: + self._previous_handlers[signum] = signal.getsignal(signum) + for signum in _MANAGED_SIGNALS: + signal.signal(signum, self._installed_handler) + self._installed_signals.append(signum) + for signum in _MANAGED_SIGNALS: + if signal.getsignal(signum) is not self._installed_handler: + raise ProcessSupervisionError() + self._restore_entry_mask() + return self + except BaseException as exc: + self._state = "cleanup" + self._restore_after_failed_entry() + if isinstance(exc, ProcessSupervisionError): + raise + raise ProcessSupervisionError() from exc + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + traceback, + ) -> bool: + self.begin_cleanup() + restore_failed = False + try: + self._restore_handlers_and_mask() + except ProcessSupervisionError: + restore_failed = True + self._state = "closed" + if restore_failed or self._cleanup_forward_failed: + raise ProcessSupervisionError() + return False + + def attach(self, child: _SignalTarget) -> None: + if self._state != "acquiring" or self._child is not None: + raise ProcessSupervisionError() + self._child = child + self._state = "waiting" + pending_signal = self._pending_signal + self._pending_signal = None + if pending_signal is not None: + raise _ForwardedSignal(pending_signal) + + def begin_cleanup(self) -> None: + if self._state != "closed": + self._state = "cleanup" + + def _handle_signal( + self, + signum: int, + _frame: FrameType | None, + ) -> None: + if self._state == "acquiring": + if self._pending_signal is None: + self._pending_signal = signum + return + if self._state == "waiting": + if self._pending_signal is not None: + signum = self._pending_signal + self._pending_signal = None + raise _ForwardedSignal(signum) + if self._state == "cleanup": + if self._child is None: + return + try: + self._child.forward_signal(signum) + except BaseException: + self._cleanup_forward_failed = True + + def _block_for_handler_installation(self) -> None: + if _IS_WINDOWS or not hasattr(signal, "pthread_sigmask"): + return + self._original_mask = signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + self._mask_is_blocked = True + + def _restore_entry_mask(self) -> None: + if not self._mask_is_blocked or self._original_mask is None: + return + self._mask_is_blocked = False + signal.pthread_sigmask(signal.SIG_SETMASK, self._original_mask) + + def _restore_after_failed_entry(self) -> None: + failed = False + for signum in reversed(self._installed_signals): + previous = self._previous_handlers.get(signum) + if previous is None: + continue + try: + signal.signal(signum, previous) + except BaseException: + failed = True + try: + self._restore_entry_mask() + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + def _restore_handlers_and_mask(self) -> None: + failed = False + mask_temporarily_blocked = False + if not _IS_WINDOWS and hasattr(signal, "pthread_sigmask"): + try: + signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + mask_temporarily_blocked = True + except BaseException: + failed = True + for signum in reversed(self._installed_signals): + previous = self._previous_handlers[signum] + try: + signal.signal(signum, previous) + except BaseException: + failed = True + if mask_temporarily_blocked and self._original_mask is not None: + try: + signal.pthread_sigmask(signal.SIG_SETMASK, self._original_mask) + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + +def _decode_waitid_exit(result, *, expected_pid: int) -> int: + if result is None or result.si_pid != expected_pid: + raise ProcessSupervisionError() + if result.si_code == _CLD_EXITED: + return int(result.si_status) + if result.si_code in (_CLD_KILLED, _CLD_DUMPED): + return -int(result.si_status) + raise ProcessSupervisionError() + + +class _DarwinSigval(ctypes.Union): + _fields_ = [ + ("sival_int", ctypes.c_int), + ("sival_ptr", ctypes.c_void_p), + ] + + +class _DarwinSiginfo(ctypes.Structure): + _fields_ = [ + ("si_signo", ctypes.c_int), + ("si_errno", ctypes.c_int), + ("si_code", ctypes.c_int), + ("si_pid", ctypes.c_int), + ("si_uid", ctypes.c_uint), + ("si_status", ctypes.c_int), + ("si_addr", ctypes.c_void_p), + ("si_value", _DarwinSigval), + ("si_band", ctypes.c_long), + ("reserved", ctypes.c_ulong * 7), + ] + + +def _darwin_libc_waitid(): + global _DARWIN_WAITID_FUNCTION + if _DARWIN_WAITID_FUNCTION is not None: + return _DARWIN_WAITID_FUNCTION + if ctypes.sizeof(_DarwinSiginfo) != 104: + raise ProcessSupervisionError() + try: + libc = ctypes.CDLL("/usr/lib/libSystem.B.dylib", use_errno=True) + waitid = libc.waitid + waitid.argtypes = [ + ctypes.c_int, + ctypes.c_uint, + ctypes.POINTER(_DarwinSiginfo), + ctypes.c_int, + ] + waitid.restype = ctypes.c_int + except BaseException as exc: + raise ProcessSupervisionError() from exc + _DARWIN_WAITID_FUNCTION = waitid + return waitid + + +def _darwin_waitid_no_reap(pid: int, *, nohang: bool) -> int | None: + options = _DARWIN_WEXITED | _DARWIN_WNOWAIT + if nohang: + options |= _DARWIN_WNOHANG + while True: + information = _DarwinSiginfo() + ctypes.set_errno(0) + try: + result = _darwin_libc_waitid()( + _DARWIN_P_PID, + pid, + ctypes.byref(information), + options, + ) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + if result == 0: + if information.si_pid == 0: + return None + return _decode_waitid_exit(information, expected_pid=pid) + if ctypes.get_errno() != errno.EINTR: + raise ProcessSupervisionError() + + +def _require_posix_supervision_support() -> None: + required_names = ( + "P_PID", + "WEXITED", + "WNOWAIT", + "WNOHANG", + "CLD_EXITED", + "CLD_KILLED", + "CLD_DUMPED", + ) + if sys.platform == "darwin" and not hasattr(os, "waitid"): + _darwin_libc_waitid() + return + if ( + not hasattr(os, "waitid") + or any(not hasattr(os, name) for name in required_names) + or not (sys.platform == "darwin" or sys.platform.startswith("linux")) + ): + raise ProcessSupervisionError() + + +def _waitid_no_reap(pid: int, *, nohang: bool) -> int | None: + if sys.platform == "darwin" and not hasattr(os, "waitid"): + return _darwin_waitid_no_reap(pid, nohang=nohang) + _require_posix_supervision_support() + options = os.WEXITED | os.WNOWAIT + if nohang: + options |= os.WNOHANG + try: + result = os.waitid(os.P_PID, pid, options) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + if result is None: + return None + return _decode_waitid_exit(result, expected_pid=pid) + + +def _linux_process_group_members(pgid: int) -> set[int]: + members: set[int] = set() + try: + entries = os.scandir("/proc") + except BaseException as exc: + raise ProcessSupervisionError() from exc + with entries: + for entry in entries: + if not entry.name.isdecimal(): + continue + try: + with open( + f"/proc/{entry.name}/stat", + encoding="utf-8", + ) as stat_file: + stat_text = stat_file.read() + except FileNotFoundError: + continue + except BaseException as exc: + raise ProcessSupervisionError() from exc + command_end = stat_text.rfind(")") + fields = stat_text[command_end + 1 :].split() + if command_end < 0 or len(fields) < 3: + raise ProcessSupervisionError() + try: + observed_pgid = int(fields[2]) + pid = int(entry.name) + except ValueError as exc: + raise ProcessSupervisionError() from exc + if observed_pgid == pgid: + members.add(pid) + return members + + +def _darwin_process_group_members(pgid: int) -> set[int]: + try: + libproc = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True) + list_group_pids = libproc.proc_listpgrppids + list_group_pids.argtypes = [ + ctypes.c_int, + ctypes.c_void_p, + ctypes.c_int, + ] + list_group_pids.restype = ctypes.c_int + capacity = list_group_pids(pgid, None, 0) + except BaseException as exc: + raise ProcessSupervisionError() from exc + if capacity < 0: + raise ProcessSupervisionError() + capacity = max(16, capacity) + for _attempt in range(3): + buffer = (ctypes.c_int * capacity)() + count = list_group_pids( + pgid, + ctypes.cast(buffer, ctypes.c_void_p), + ctypes.sizeof(buffer), + ) + if count < 0: + raise ProcessSupervisionError() + if count < capacity: + return {int(pid) for pid in buffer[:count] if pid > 0} + capacity *= 2 + raise ProcessSupervisionError() + + +def _process_group_has_other_members(pgid: int, leader_pid: int) -> bool: + if pgid <= 0 or leader_pid <= 0 or pgid != leader_pid: + raise ProcessSupervisionError() + if sys.platform == "darwin": + members = _darwin_process_group_members(pgid) + elif sys.platform.startswith("linux"): + members = _linux_process_group_members(pgid) + else: + raise ProcessSupervisionError() + return any(pid != leader_pid for pid in members) + + +class _PosixChild: + def __init__(self, process: subprocess.Popen[bytes]) -> None: + self._process = process + self._pgid = process.pid + self._observed_exit_code: int | None = None + self._direct_reaped = False + self._cleanup_error = False + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + ) -> Self: + _require_posix_supervision_support() + if sys.platform == "darwin": + _darwin_process_group_members(os.getpgrp()) + else: + _linux_process_group_members(os.getpgrp()) + try: + process = subprocess.Popen( + command, + env=env, + cwd=cwd, + start_new_session=True, + ) + except BaseException as exc: + raise ProcessSupervisionError() from exc + return cls(process) + + def wait(self) -> int: + try: + if not self._observe_exit(nohang=False): + raise ProcessSupervisionError() + assert self._observed_exit_code is not None + return self._observed_exit_code + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_signal(self, signum: int) -> None: + try: + self._signal_group(signum) + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self._clean_and_reap( + signum, + timeout=timeout, + signal_only_if_members=False, + ) + + def terminate_and_reap(self, *, timeout: float) -> None: + self._clean_and_reap( + signal.SIGTERM, + timeout=timeout, + signal_only_if_members=False, + ) + + def close_remaining_tree(self, *, timeout: float) -> None: + if self._observed_exit_code is None and not self._direct_reaped: + raise ProcessSupervisionError() + self._clean_and_reap( + signal.SIGTERM, + timeout=timeout, + signal_only_if_members=True, + ) + + def ensure_closed(self, *, timeout: float) -> None: + if self._direct_reaped: + if self._cleanup_error: + raise ProcessSupervisionError() + return + if self._observed_exit_code is not None: + self.close_remaining_tree(timeout=timeout) + return + self.terminate_and_reap(timeout=timeout) + + def close(self) -> None: + return None + + def _validate_process_group(self) -> None: + if self._pgid <= 0 or self._pgid == os.getpgrp(): + raise ProcessSupervisionError() + try: + observed_pgid = os.getpgid(self._process.pid) + except ProcessLookupError: + if self._observed_exit_code is not None and not self._direct_reaped: + return + raise ProcessSupervisionError() from None + except BaseException as exc: + raise ProcessSupervisionError() from exc + if observed_pgid != self._pgid: + raise ProcessSupervisionError() + + def _signal_group(self, signum: int) -> None: + self._validate_process_group() + try: + os.killpg(self._pgid, signum) + except ProcessLookupError: + return + except OSError as exc: + if exc.errno == errno.ESRCH: + return + raise ProcessSupervisionError() from exc + + def _observe_exit(self, *, nohang: bool) -> bool: + if self._observed_exit_code is not None: + return True + exit_code = _waitid_no_reap(self._process.pid, nohang=nohang) + if exit_code is None: + return False + self._observed_exit_code = exit_code + return True + + def _has_other_group_members(self) -> bool: + return _process_group_has_other_members( + self._pgid, + self._process.pid, + ) + + def _wait_for_owned_tree_exit(self, *, deadline: float) -> tuple[bool, bool]: + failure = False + while True: + try: + direct_exited = self._observe_exit(nohang=True) + other_members = self._has_other_group_members() + except ProcessSupervisionError: + direct_exited = False + other_members = True + failure = True + if direct_exited and not other_members: + return True, failure + remaining = deadline - time.monotonic() + if remaining <= 0: + return False, failure + time.sleep(min(0.02, remaining)) + + def _reap_observed_child(self) -> None: + if self._direct_reaped: + return + if self._observed_exit_code is None: + raise ProcessSupervisionError() + expected_exit_code = self._observed_exit_code + try: + observed_exit_code = self._process.wait(timeout=0.0) + except BaseException as exc: + raise ProcessSupervisionError() from exc + self._direct_reaped = True + if observed_exit_code != expected_exit_code: + raise ProcessSupervisionError() + + def _clean_and_reap( + self, + signum: int, + *, + timeout: float, + signal_only_if_members: bool, + ) -> None: + if self._direct_reaped: + if self._cleanup_error: + raise ProcessSupervisionError() + return + failure = False + + if self._observed_exit_code is None: + try: + self._observe_exit(nohang=True) + except ProcessSupervisionError: + failure = True + + should_signal = True + if signal_only_if_members: + try: + should_signal = self._has_other_group_members() + except ProcessSupervisionError: + failure = True + + if not should_signal and self._observed_exit_code is not None: + try: + self._reap_observed_child() + except ProcessSupervisionError: + failure = True + if failure: + self._cleanup_error = True + raise ProcessSupervisionError() + return + + try: + self._signal_group(signum) + except ProcessSupervisionError: + failure = True + soft_deadline = time.monotonic() + timeout + complete, wait_failed = self._wait_for_owned_tree_exit( + deadline=soft_deadline, + ) + failure = failure or wait_failed + + if not complete: + try: + self._signal_group(signal.SIGKILL) + except ProcessSupervisionError: + failure = True + hard_deadline = time.monotonic() + timeout + complete, hard_wait_failed = self._wait_for_owned_tree_exit( + deadline=hard_deadline, + ) + failure = failure or hard_wait_failed + + if self._observed_exit_code is not None: + try: + self._reap_observed_child() + except ProcessSupervisionError: + failure = True + else: + try: + self._process.wait(timeout=0.0) + except subprocess.TimeoutExpired: + pass + except BaseException: + failure = True + else: + self._direct_reaped = True + failure = True + + if failure or not complete or not self._direct_reaped: + if self._direct_reaped: + self._cleanup_error = True + raise ProcessSupervisionError() + + +class _Win32ApiProtocol(Protocol): + def create_job(self): ... + + def set_kill_on_close(self, job) -> None: ... + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + ): ... + + def assign_process_to_job(self, job, process) -> None: ... + + def resume_thread(self, thread) -> None: ... + + def terminate_process(self, process) -> None: ... + + def terminate_job(self, job) -> None: ... + + def wait_process(self, process, timeout: float | None) -> bool: ... + + def process_exit_code(self, process) -> int: ... + + def wait_for_job_empty(self, job, timeout: float) -> bool: ... + + def send_ctrl_break(self, process_id: int) -> None: ... + + def close_handle(self, handle) -> None: ... + + +class _IO_COUNTERS(ctypes.Structure): + _fields_ = [ + ("ReadOperationCount", ctypes.c_ulonglong), + ("WriteOperationCount", ctypes.c_ulonglong), + ("OtherOperationCount", ctypes.c_ulonglong), + ("ReadTransferCount", ctypes.c_ulonglong), + ("WriteTransferCount", ctypes.c_ulonglong), + ("OtherTransferCount", ctypes.c_ulonglong), + ] + + +class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("PerProcessUserTimeLimit", ctypes.c_longlong), + ("PerJobUserTimeLimit", ctypes.c_longlong), + ("LimitFlags", wintypes.DWORD), + ("MinimumWorkingSetSize", ctypes.c_size_t), + ("MaximumWorkingSetSize", ctypes.c_size_t), + ("ActiveProcessLimit", wintypes.DWORD), + ("Affinity", ctypes.c_size_t), + ("PriorityClass", wintypes.DWORD), + ("SchedulingClass", wintypes.DWORD), + ] + + +class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): + _fields_ = [ + ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION), + ("IoInfo", _IO_COUNTERS), + ("ProcessMemoryLimit", ctypes.c_size_t), + ("JobMemoryLimit", ctypes.c_size_t), + ("PeakProcessMemoryUsed", ctypes.c_size_t), + ("PeakJobMemoryUsed", ctypes.c_size_t), + ] + + +class _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): + _fields_ = [ + ("TotalUserTime", ctypes.c_longlong), + ("TotalKernelTime", ctypes.c_longlong), + ("ThisPeriodTotalUserTime", ctypes.c_longlong), + ("ThisPeriodTotalKernelTime", ctypes.c_longlong), + ("TotalPageFaultCount", wintypes.DWORD), + ("TotalProcesses", wintypes.DWORD), + ("ActiveProcesses", wintypes.DWORD), + ("TotalTerminatedProcesses", wintypes.DWORD), + ] + + +class _STARTUPINFOW(ctypes.Structure): + _fields_ = [ + ("cb", wintypes.DWORD), + ("lpReserved", wintypes.LPWSTR), + ("lpDesktop", wintypes.LPWSTR), + ("lpTitle", wintypes.LPWSTR), + ("dwX", wintypes.DWORD), + ("dwY", wintypes.DWORD), + ("dwXSize", wintypes.DWORD), + ("dwYSize", wintypes.DWORD), + ("dwXCountChars", wintypes.DWORD), + ("dwYCountChars", wintypes.DWORD), + ("dwFillAttribute", wintypes.DWORD), + ("dwFlags", wintypes.DWORD), + ("wShowWindow", wintypes.WORD), + ("cbReserved2", wintypes.WORD), + ("lpReserved2", ctypes.POINTER(wintypes.BYTE)), + ("hStdInput", wintypes.HANDLE), + ("hStdOutput", wintypes.HANDLE), + ("hStdError", wintypes.HANDLE), + ] + + +class _STARTUPINFOEXW(ctypes.Structure): + _fields_ = [ + ("StartupInfo", _STARTUPINFOW), + ("lpAttributeList", ctypes.c_void_p), + ] + + +class _PROCESS_INFORMATION(ctypes.Structure): + _fields_ = [ + ("hProcess", wintypes.HANDLE), + ("hThread", wintypes.HANDLE), + ("dwProcessId", wintypes.DWORD), + ("dwThreadId", wintypes.DWORD), + ] + + +class _Win32AttributeList: + def __init__(self, *, buffer, pointer, handle_array) -> None: + self.buffer = buffer + self.pointer = pointer + self.handle_array = handle_array + + +class _WindowsLaunchNativeProtocol(Protocol): + def get_standard_handle(self, stream: int): ... + + def open_null_handle(self, stream: int): ... + + def duplicate_inheritable_handle(self, handle): ... + + def create_handle_list(self, handles): ... + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + standard_handles, + attribute_list, + ): ... + + def delete_handle_list(self, attribute_list) -> None: ... + + def close_handle(self, handle) -> None: ... + + def abort_suspended_process(self, process, thread) -> None: ... + + +class _WindowsProcessLauncher: + def __init__(self, native: _WindowsLaunchNativeProtocol) -> None: + self._native = native + + def create( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + ): + duplicates: list[object] = [] + owned_sources: list[object] = [] + attribute_list = None + result = None + failure: BaseException | None = None + try: + standard_handles = [] + for stream in (-10, -11, -12): + handle = self._native.get_standard_handle(stream) + if handle is None: + handle = self._native.open_null_handle(stream) + owned_sources.append(handle) + standard_handles.append(handle) + for handle in standard_handles: + duplicates.append(self._native.duplicate_inheritable_handle(handle)) + attribute_list = self._native.create_handle_list(duplicates) + result = self._native.create_suspended_process( + command, + env=env, + cwd=cwd, + standard_handles=tuple(duplicates), + attribute_list=attribute_list, + ) + except BaseException as exc: + failure = exc + + try: + if attribute_list is not None: + self._native.delete_handle_list(attribute_list) + except BaseException as exc: + if failure is None: + failure = exc + for handle in duplicates: + try: + self._native.close_handle(handle) + except BaseException as exc: + if failure is None: + failure = exc + for handle in owned_sources: + try: + self._native.close_handle(handle) + except BaseException as exc: + if failure is None: + failure = exc + + if failure is not None: + if result is not None: + try: + process, thread, _process_id = result + self._native.abort_suspended_process(process, thread) + except BaseException: + pass + raise failure + return result + + +class _CtypesWindowsLaunchNative: + _CREATE_SUSPENDED = 0x00000004 + _CREATE_NEW_PROCESS_GROUP = 0x00000200 + _CREATE_UNICODE_ENVIRONMENT = 0x00000400 + _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 + _STARTF_USESTDHANDLES = 0x00000100 + _PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 + _DUPLICATE_SAME_ACCESS = 0x00000002 + _WAIT_OBJECT_0 = 0x00000000 + _GENERIC_READ = 0x80000000 + _GENERIC_WRITE = 0x40000000 + _FILE_SHARE_READ = 0x00000001 + _FILE_SHARE_WRITE = 0x00000002 + _OPEN_EXISTING = 3 + _FILE_ATTRIBUTE_NORMAL = 0x00000080 + + def __init__(self, kernel32) -> None: + self._kernel32 = kernel32 + + def get_standard_handle(self, stream: int): + handle = self._kernel32.GetStdHandle(wintypes.DWORD(stream & 0xFFFFFFFF)) + if handle in (None, 0, ctypes.c_void_p(-1).value): + return None + return handle + + def open_null_handle(self, stream: int): + desired_access = self._GENERIC_READ if stream == -10 else self._GENERIC_WRITE + handle = self._kernel32.CreateFileW( + "NUL", + desired_access, + self._FILE_SHARE_READ | self._FILE_SHARE_WRITE, + None, + self._OPEN_EXISTING, + self._FILE_ATTRIBUTE_NORMAL, + None, + ) + if handle in (None, ctypes.c_void_p(-1).value): + self._raise_error() + return handle + + def duplicate_inheritable_handle(self, handle): + current_process = self._kernel32.GetCurrentProcess() + duplicate = wintypes.HANDLE() + if not self._kernel32.DuplicateHandle( + current_process, + handle, + current_process, + ctypes.byref(duplicate), + 0, + True, + self._DUPLICATE_SAME_ACCESS, + ): + self._raise_error() + return duplicate.value + + def create_handle_list(self, handles): + size = ctypes.c_size_t() + self._kernel32.InitializeProcThreadAttributeList( + None, + 1, + 0, + ctypes.byref(size), + ) + if size.value == 0: + self._raise_error() + buffer = ctypes.create_string_buffer(size.value) + pointer = ctypes.cast(buffer, ctypes.c_void_p) + if not self._kernel32.InitializeProcThreadAttributeList( + pointer, + 1, + 0, + ctypes.byref(size), + ): + self._raise_error() + handle_array = (wintypes.HANDLE * len(handles))(*handles) + if not self._kernel32.UpdateProcThreadAttribute( + pointer, + 0, + self._PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + ctypes.cast(handle_array, ctypes.c_void_p), + ctypes.sizeof(handle_array), + None, + None, + ): + self._kernel32.DeleteProcThreadAttributeList(pointer) + self._raise_error() + return _Win32AttributeList( + buffer=buffer, + pointer=pointer, + handle_array=handle_array, + ) + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + standard_handles, + attribute_list, + ): + command_line = ctypes.create_unicode_buffer(subprocess.list2cmdline(command)) + environment_text = "\0".join( + f"{key}={value}" + for key, value in sorted( + env.items(), + key=lambda item: item[0].casefold(), + ) + ) + environment = ctypes.create_unicode_buffer(environment_text + "\0\0") + startup = _STARTUPINFOEXW() + startup.StartupInfo.cb = ctypes.sizeof(startup) + creation_flags = ( + self._CREATE_SUSPENDED + | self._CREATE_NEW_PROCESS_GROUP + | self._CREATE_UNICODE_ENVIRONMENT + ) + inherit_handles = bool(standard_handles) + if inherit_handles: + startup.StartupInfo.dwFlags |= self._STARTF_USESTDHANDLES + ( + startup.StartupInfo.hStdInput, + startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError, + ) = standard_handles + startup.lpAttributeList = attribute_list.pointer + creation_flags |= self._EXTENDED_STARTUPINFO_PRESENT + process_information = _PROCESS_INFORMATION() + created = self._kernel32.CreateProcessW( + None, + command_line, + None, + None, + inherit_handles, + creation_flags, + environment, + cwd, + ctypes.cast(ctypes.byref(startup), ctypes.POINTER(_STARTUPINFOW)), + ctypes.byref(process_information), + ) + if not created: + self._raise_error() + return ( + process_information.hProcess, + process_information.hThread, + int(process_information.dwProcessId), + ) + + def delete_handle_list(self, attribute_list) -> None: + self._kernel32.DeleteProcThreadAttributeList(attribute_list.pointer) + + def close_handle(self, handle) -> None: + if handle and not self._kernel32.CloseHandle(handle): + self._raise_error() + + def abort_suspended_process(self, process, thread) -> None: + failed = False + try: + if not self._kernel32.TerminateProcess(process, 1): + failed = True + except BaseException: + failed = True + try: + if self._kernel32.WaitForSingleObject(process, 5000) != self._WAIT_OBJECT_0: + failed = True + except BaseException: + failed = True + for handle in (thread, process): + try: + if not self._kernel32.CloseHandle(handle): + failed = True + except BaseException: + failed = True + if failed: + self._raise_error() + + @staticmethod + def _raise_error() -> None: + get_last_error = getattr(ctypes, "get_last_error", None) + raise OSError(get_last_error() if get_last_error is not None else 0) + + +class _Win32Api: + _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 + _JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION = 1 + _WAIT_OBJECT_0 = 0x00000000 + _WAIT_TIMEOUT = 0x00000102 + _INFINITE = 0xFFFFFFFF + _CTRL_BREAK_EVENT = 1 + + def __init__(self) -> None: + kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + self._kernel32 = kernel32 + kernel32.CreateJobObjectW.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] + kernel32.CreateJobObjectW.restype = wintypes.HANDLE + kernel32.SetInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ] + kernel32.SetInformationJobObject.restype = wintypes.BOOL + kernel32.CreateProcessW.argtypes = [ + wintypes.LPCWSTR, + wintypes.LPWSTR, + ctypes.c_void_p, + ctypes.c_void_p, + wintypes.BOOL, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.LPCWSTR, + ctypes.POINTER(_STARTUPINFOW), + ctypes.POINTER(_PROCESS_INFORMATION), + ] + kernel32.CreateProcessW.restype = wintypes.BOOL + kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] + kernel32.AssignProcessToJobObject.restype = wintypes.BOOL + kernel32.ResumeThread.argtypes = [wintypes.HANDLE] + kernel32.ResumeThread.restype = wintypes.DWORD + kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateProcess.restype = wintypes.BOOL + kernel32.TerminateJobObject.argtypes = [wintypes.HANDLE, wintypes.UINT] + kernel32.TerminateJobObject.restype = wintypes.BOOL + kernel32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD] + kernel32.WaitForSingleObject.restype = wintypes.DWORD + kernel32.GetExitCodeProcess.argtypes = [ + wintypes.HANDLE, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.GetExitCodeProcess.restype = wintypes.BOOL + kernel32.QueryInformationJobObject.argtypes = [ + wintypes.HANDLE, + ctypes.c_int, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.QueryInformationJobObject.restype = wintypes.BOOL + kernel32.GenerateConsoleCtrlEvent.argtypes = [wintypes.DWORD, wintypes.DWORD] + kernel32.GenerateConsoleCtrlEvent.restype = wintypes.BOOL + kernel32.CloseHandle.argtypes = [wintypes.HANDLE] + kernel32.CloseHandle.restype = wintypes.BOOL + kernel32.GetStdHandle.argtypes = [wintypes.DWORD] + kernel32.GetStdHandle.restype = wintypes.HANDLE + kernel32.CreateFileW.argtypes = [ + wintypes.LPCWSTR, + wintypes.DWORD, + wintypes.DWORD, + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + wintypes.HANDLE, + ] + kernel32.CreateFileW.restype = wintypes.HANDLE + kernel32.GetCurrentProcess.argtypes = [] + kernel32.GetCurrentProcess.restype = wintypes.HANDLE + kernel32.DuplicateHandle.argtypes = [ + wintypes.HANDLE, + wintypes.HANDLE, + wintypes.HANDLE, + ctypes.POINTER(wintypes.HANDLE), + wintypes.DWORD, + wintypes.BOOL, + wintypes.DWORD, + ] + kernel32.DuplicateHandle.restype = wintypes.BOOL + kernel32.InitializeProcThreadAttributeList.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + wintypes.DWORD, + ctypes.POINTER(ctypes.c_size_t), + ] + kernel32.InitializeProcThreadAttributeList.restype = wintypes.BOOL + kernel32.UpdateProcThreadAttribute.argtypes = [ + ctypes.c_void_p, + wintypes.DWORD, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_size_t, + ctypes.c_void_p, + ctypes.c_void_p, + ] + kernel32.UpdateProcThreadAttribute.restype = wintypes.BOOL + kernel32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p] + kernel32.DeleteProcThreadAttributeList.restype = None + self._process_launcher = _WindowsProcessLauncher( + _CtypesWindowsLaunchNative(kernel32) + ) + + def create_job(self): + job = self._kernel32.CreateJobObjectW(None, None) + if not job: + self._raise_error() + return job + + def set_kill_on_close(self, job) -> None: + information = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + information.BasicLimitInformation.LimitFlags = ( + self._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + ) + if not self._kernel32.SetInformationJobObject( + job, + self._JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + ): + self._raise_error() + + def create_suspended_process( + self, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + ): + return self._process_launcher.create(command, env=env, cwd=cwd) + + def assign_process_to_job(self, job, process) -> None: + if not self._kernel32.AssignProcessToJobObject(job, process): + self._raise_error() + + def resume_thread(self, thread) -> None: + previous_count = self._kernel32.ResumeThread(thread) + if previous_count != 1: + self._raise_error() + + def terminate_process(self, process) -> None: + if not self._kernel32.TerminateProcess(process, 1): + self._raise_error() + + def terminate_job(self, job) -> None: + if not self._kernel32.TerminateJobObject(job, 1): + self._raise_error() + + def wait_process(self, process, timeout: float | None) -> bool: + milliseconds = ( + self._INFINITE + if timeout is None + else min(self._INFINITE - 1, max(0, math.ceil(timeout * 1000))) + ) + result = self._kernel32.WaitForSingleObject(process, milliseconds) + if result == self._WAIT_OBJECT_0: + return True + if result == self._WAIT_TIMEOUT: + return False + self._raise_error() + + def process_exit_code(self, process) -> int: + exit_code = wintypes.DWORD() + if not self._kernel32.GetExitCodeProcess(process, ctypes.byref(exit_code)): + self._raise_error() + return int(exit_code.value) + + def wait_for_job_empty(self, job, timeout: float) -> bool: + deadline = time.monotonic() + timeout + while True: + information = _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() + if not self._kernel32.QueryInformationJobObject( + job, + self._JOB_OBJECT_BASIC_ACCOUNTING_INFORMATION, + ctypes.byref(information), + ctypes.sizeof(information), + None, + ): + self._raise_error() + if information.ActiveProcesses == 0: + return True + remaining = deadline - time.monotonic() + if remaining <= 0: + return False + time.sleep(min(0.02, remaining)) + + def send_ctrl_break(self, process_id: int) -> None: + if not self._kernel32.GenerateConsoleCtrlEvent( + self._CTRL_BREAK_EVENT, + process_id, + ): + self._raise_error() + + def close_handle(self, handle) -> None: + if handle and not self._kernel32.CloseHandle(handle): + self._raise_error() + + @staticmethod + def _raise_error() -> None: + raise OSError(ctypes.get_last_error()) + + +class _WindowsChild: + def __init__( + self, + *, + api: _Win32ApiProtocol, + job, + process, + process_id: int, + ) -> None: + self._api = api + self._job = job + self._process = process + self._process_id = process_id + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None, + api: _Win32ApiProtocol | None = None, + ) -> Self: + native = api or _Win32Api() + job = None + process = None + thread = None + assigned = False + try: + job = native.create_job() + native.set_kill_on_close(job) + process, thread, process_id = native.create_suspended_process( + command, + env=env, + cwd=cwd, + ) + native.assign_process_to_job(job, process) + assigned = True + native.resume_thread(thread) + native.close_handle(thread) + thread = None + return cls( + api=native, + job=job, + process=process, + process_id=process_id, + ) + except BaseException as exc: + cls._rollback_start( + native, + job=job, + process=process, + thread=thread, + assigned=assigned, + ) + raise ProcessSupervisionError() from exc + + @staticmethod + def _rollback_start( + api: _Win32ApiProtocol, + *, + job, + process, + thread, + assigned: bool, + ) -> None: + if process is not None: + if assigned and job is not None: + try: + api.terminate_job(job) + except BaseException: + pass + try: + api.wait_for_job_empty(job, 5.0) + except BaseException: + pass + else: + try: + api.terminate_process(process) + except BaseException: + pass + try: + api.wait_process(process, 5.0) + except BaseException: + pass + for handle in (thread, process, job): + if handle is None: + continue + try: + api.close_handle(handle) + except BaseException: + pass + + def wait(self) -> int: + try: + while not self._api.wait_process( + self._process, + _WINDOWS_WAIT_POLL_SECONDS, + ): + pass + return self._api.process_exit_code(self._process) + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_signal(self, signum: int) -> None: + try: + if signum == signal.SIGINT: + try: + self._api.send_ctrl_break(self._process_id) + except BaseException: + self._api.terminate_job(self._job) + else: + self._api.terminate_job(self._job) + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + try: + if signum == signal.SIGINT: + try: + self._api.send_ctrl_break(self._process_id) + except BaseException: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + else: + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + if not job_empty: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty( + self._job, + timeout, + ) + else: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + job_empty = self._api.wait_for_job_empty(self._job, timeout) + if not job_empty: + raise ProcessSupervisionError() + if not self._wait_process_until(deadline): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def terminate_and_reap(self, *, timeout: float) -> None: + try: + self._api.terminate_job(self._job) + deadline = time.monotonic() + timeout + if not self._api.wait_for_job_empty(self._job, timeout): + raise ProcessSupervisionError() + if not self._wait_process_until(deadline): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def close_remaining_tree(self, *, timeout: float) -> None: + try: + if self._api.wait_for_job_empty(self._job, 0.0): + return + self._api.terminate_job(self._job) + if not self._api.wait_for_job_empty(self._job, timeout): + raise ProcessSupervisionError() + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + + def ensure_closed(self, *, timeout: float) -> None: + self.close_remaining_tree(timeout=timeout) + + def close(self) -> None: + failed = False + process, self._process = self._process, None + job, self._job = self._job, None + for handle in (process, job): + if handle is None: + continue + try: + self._api.close_handle(handle) + except BaseException: + failed = True + if failed: + raise ProcessSupervisionError() + + def _wait_process_until(self, deadline: float) -> bool: + while True: + remaining = deadline - time.monotonic() + wait_seconds = max( + 0.0, + min(_WINDOWS_WAIT_POLL_SECONDS, remaining), + ) + if self._api.wait_process(self._process, wait_seconds): + return True + if remaining <= 0: + return False + + +class ForegroundChildSupervisor: + def __init__(self, child: _PosixChild | _WindowsChild) -> None: + self._child = child + + @classmethod + def start( + cls, + command: list[str], + *, + env: dict[str, str], + cwd: str | None = None, + ) -> Self: + try: + child = ( + _WindowsChild.start(command, env=env, cwd=cwd) + if _IS_WINDOWS + else _PosixChild.start(command, env=env, cwd=cwd) + ) + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc + return cls(child) + + def wait(self) -> int: + return self._child.wait() + + def forward_signal(self, signum: int) -> None: + self._child.forward_signal(signum) + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self._child.forward_and_reap(signum, timeout=timeout) + + def terminate_and_reap(self, *, timeout: float) -> None: + self._child.terminate_and_reap(timeout=timeout) + + def close_remaining_tree(self, *, timeout: float) -> None: + self._child.close_remaining_tree(timeout=timeout) + + def ensure_closed(self, *, timeout: float) -> None: + self._child.ensure_closed(timeout=timeout) + + def close(self) -> None: + self._child.close() diff --git a/tests/fixtures/runtime_settings_probe/sitecustomize.py b/tests/fixtures/runtime_settings_probe/sitecustomize.py index 912a3d1..e830082 100644 --- a/tests/fixtures/runtime_settings_probe/sitecustomize.py +++ b/tests/fixtures/runtime_settings_probe/sitecustomize.py @@ -5,6 +5,9 @@ import asyncio import json import os +import subprocess +import sys +import time from pathlib import Path @@ -16,8 +19,29 @@ ) +termination_probe_path = os.environ.get("AGENTSEEK_TERMINATION_PROBE_PATH") probe_path = os.environ.get("AGENTSEEK_SETTINGS_PROBE_PATH") -if probe_path: +if termination_probe_path: + def _block_runtime_role(awaitable) -> int: + awaitable.close() + termination_fixture = Path(__file__).resolve().parents[1] / "termination_tree.py" + grandchild = subprocess.Popen( + [sys.executable, str(termination_fixture), "--grandchild"] + ) + Path(termination_probe_path).write_text( + json.dumps( + { + "parent": os.getpid(), + "grandchild": grandchild.pid, + } + ), + encoding="utf-8", + ) + while True: + time.sleep(60) + + asyncio.run = _block_runtime_role +elif probe_path: probe_fields = tuple( field for field in os.environ["AGENTSEEK_SETTINGS_PROBE_FIELDS"].split(",") diff --git a/tests/fixtures/termination_tree.py b/tests/fixtures/termination_tree.py new file mode 100644 index 0000000..1365f94 --- /dev/null +++ b/tests/fixtures/termination_tree.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import argparse +import json +import os +import signal +import subprocess +import sys +import time +from pathlib import Path + + +def _use_native_interrupt_termination() -> None: + signal.signal(signal.SIGINT, signal.SIG_DFL) + if hasattr(signal, "SIGBREAK"): + signal.signal(signal.SIGBREAK, signal.SIG_DFL) + + +def _block() -> int: + _use_native_interrupt_termination() + while True: + time.sleep(60) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("result_path", nargs="?") + parser.add_argument("--grandchild", action="store_true") + parser.add_argument("--parent-exit", type=int) + parser.add_argument("--output-marker") + args = parser.parse_args() + if args.grandchild: + return _block() + if args.result_path is None: + parser.error("result_path is required") + + _use_native_interrupt_termination() + if args.output_marker is not None: + print(args.output_marker, flush=True) + blocked_signals: list[int] = [] + if hasattr(signal, "pthread_sigmask"): + current_mask = signal.pthread_sigmask(signal.SIG_BLOCK, set()) + blocked_signals = sorted( + int(signum) + for signum in (signal.SIGINT, signal.SIGTERM) + if signum in current_mask + ) + grandchild = subprocess.Popen( + [sys.executable, str(Path(__file__).resolve()), "--grandchild"] + ) + Path(args.result_path).write_text( + json.dumps( + { + "parent": os.getpid(), + "grandchild": grandchild.pid, + "blocked_signals": blocked_signals, + } + ), + encoding="utf-8", + ) + if args.parent_exit is not None: + os._exit(args.parent_exit) + return _block() + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 82cd476..67662a1 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -14,7 +14,11 @@ PROBE_SITE_DIR = ( Path(__file__).resolve().parents[1] / "fixtures" / "runtime_settings_probe" ) +TERMINATION_TREE_FIXTURE = ( + Path(__file__).resolve().parents[1] / "fixtures" / "termination_tree.py" +) VALIDATION_CHILD_PID_PATH_ENV = "AGENTSEEK_VALIDATION_CHILD_PID_PATH" +TERMINATION_PROBE_PATH_ENV = "AGENTSEEK_TERMINATION_PROBE_PATH" def _probe_pythonpath() -> str: @@ -89,6 +93,51 @@ def _terminate_observed_pid(pid: int, *, timeout_seconds: float = 2.0) -> None: raise AssertionError(f"Runtime validation child PID {pid} did not exit.") +def _read_tree_pids( + path: Path, + *, + timeout_seconds: float = 5.0, +) -> tuple[int, int]: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + try: + payload = json.loads(path.read_text(encoding="utf-8")) + return int(payload["parent"]), int(payload["grandchild"]) + except (FileNotFoundError, KeyError, TypeError, ValueError): + time.sleep(0.01) + raise AssertionError("The supervised parent/grandchild PIDs were not observed.") + + +def _wait_for_pids_gone( + pids: tuple[int, ...], + *, + timeout_seconds: float = 8.0, +) -> None: + deadline = time.monotonic() + timeout_seconds + while time.monotonic() < deadline: + if not any(_pid_is_alive(pid) for pid in pids): + return + time.sleep(0.02) + live_pids = [pid for pid in pids if _pid_is_alive(pid)] + raise AssertionError(f"Supervised process IDs remained alive: {live_pids}") + + +def _stop_test_process(process: subprocess.Popen[str]) -> None: + if process.poll() is not None: + return + process.terminate() + try: + process.communicate(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=3) + + +def _cleanup_recorded_pids(pids: tuple[int, ...]) -> None: + for pid in pids: + _terminate_observed_pid(pid, timeout_seconds=3.0) + + def _run_python( *arguments: str, cwd: Path, @@ -446,3 +495,204 @@ def test_sequential_worker_invocations_do_not_reuse_settings_singleton( observed.append(observation["settings"]["WORKER_CONCURRENT_JOBS"]) assert observed == [2, 7] + + +def _start_supervisor_wrapper( + *, + command: list[str], + cwd: Path, +) -> subprocess.Popen[str]: + wrapper = ( + "import os, sys; " + "from agentseek_api.cli import _default_runner; " + "raise SystemExit(_default_runner(sys.argv[1:], " + "env=dict(os.environ), cwd=None))" + ) + return subprocess.Popen( + [sys.executable, "-c", wrapper, *command], + cwd=cwd, + env=dict(os.environ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + + +def test_external_sigterm_forwards_and_leaves_no_runtime_child( + tmp_path: Path, +) -> None: + result_path = tmp_path / "termination-tree.json" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + parent_pid, grandchild_pid = _read_tree_pids(result_path) + recorded_pids = (parent_pid, grandchild_pid) + if os.name == "nt": + process.terminate() + else: + os.kill(process.pid, signal.SIGTERM) + stdout, stderr = process.communicate(timeout=15) + + if os.name != "nt": + assert process.returncode == 128 + signal.SIGTERM + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +def test_normal_child_return_reaps_remaining_grandchild( + tmp_path: Path, +) -> None: + result_path = tmp_path / "normal-return-tree.json" + sentinel_exit_code = 37 + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + str(sentinel_exit_code), + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == sentinel_exit_code + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX signal masks only") +def test_supervised_posix_child_starts_with_forwarded_signals_unblocked( + tmp_path: Path, +) -> None: + result_path = tmp_path / "startup-mask-tree.json" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + "0", + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + observation = json.loads(result_path.read_text(encoding="utf-8")) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == 0 + assert observation["blocked_signals"] == [] + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +def test_supervised_child_preserves_captured_stdout( + tmp_path: Path, +) -> None: + result_path = tmp_path / "captured-output-tree.json" + output_marker = "captured-child-output" + process = _start_supervisor_wrapper( + command=[ + sys.executable, + str(TERMINATION_TREE_FIXTURE), + str(result_path), + "--parent-exit", + "0", + "--output-marker", + output_marker, + ], + cwd=tmp_path, + ) + recorded_pids: tuple[int, ...] = () + try: + recorded_pids = _read_tree_pids(result_path) + stdout, stderr = process.communicate(timeout=15) + + assert process.returncode == 0 + assert stdout == f"{output_marker}\n" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) + + +@pytest.mark.parametrize("role", ["worker", "scheduler"]) +def test_public_runtime_role_sigterm_reaps_role_tree( + tmp_path: Path, + role: str, +) -> None: + config_path = _write_runtime_config(tmp_path, f"{role}-termination", {}) + result_path = tmp_path / f"{role}-termination-tree.json" + environment = dict(os.environ) + environment.update( + { + "PYTHONPATH": _probe_pythonpath(), + TERMINATION_PROBE_PATH_ENV: str(result_path), + } + ) + for field in ( + "AGENTSEEK_SETTINGS_PROBE_PATH", + "AGENTSEEK_SETTINGS_PROBE_FIELDS", + "AGENTSEEK_SETTINGS_PROBE_EXIT_CODE", + VALIDATION_CHILD_PID_PATH_ENV, + ): + environment.pop(field, None) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ], + cwd=tmp_path, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + recorded_pids: tuple[int, ...] = () + try: + parent_pid, grandchild_pid = _read_tree_pids(result_path) + recorded_pids = (parent_pid, grandchild_pid) + assert parent_pid != process.pid + if os.name == "nt": + process.terminate() + else: + os.kill(process.pid, signal.SIGTERM) + stdout, stderr = process.communicate(timeout=15) + + if os.name != "nt": + assert process.returncode == 128 + signal.SIGTERM + assert stdout == "" + assert stderr == "" + _wait_for_pids_gone(recorded_pids) + finally: + _stop_test_process(process) + _cleanup_recorded_pids(recorded_pids) diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index ea1f8da..4d49e22 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -4,6 +4,7 @@ import importlib import io import os +import signal import tomllib from dataclasses import dataclass from pathlib import Path @@ -43,6 +44,245 @@ def __call__(self, command: list[str], *, env: dict[str, str], cwd: str | None = return 0 +class _FakeForegroundSupervisor: + def __init__( + self, + *, + wait_result: int | BaseException, + escalates: bool = False, + ) -> None: + self.wait_result = wait_result + self.escalates = escalates + self.terminated = False + self.killed = False + self.wait_calls = 0 + self.close_remaining_tree_calls: list[float] = [] + self.forward_and_reap_calls: list[tuple[int, float]] = [] + self.terminate_and_reap_calls: list[float] = [] + self.ensure_closed_calls: list[float] = [] + self.close_calls = 0 + + def wait(self) -> int: + self.wait_calls += 1 + if isinstance(self.wait_result, BaseException): + raise self.wait_result + return self.wait_result + + def close_remaining_tree(self, *, timeout: float) -> None: + self.close_remaining_tree_calls.append(timeout) + + def forward_signal(self, signum: int) -> None: + self.forward_and_reap_calls.append((signum, 0.0)) + + def forward_and_reap(self, signum: int, *, timeout: float) -> None: + self.forward_and_reap_calls.append((signum, timeout)) + self.terminated = True + self.killed = self.escalates + + def terminate_and_reap(self, *, timeout: float) -> None: + self.terminate_and_reap_calls.append(timeout) + self.terminated = True + + def ensure_closed(self, *, timeout: float) -> None: + self.ensure_closed_calls.append(timeout) + + def close(self) -> None: + self.close_calls += 1 + + +def test_default_runner_propagates_child_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor(wait_result=23) + observed: dict[str, object] = {} + + def fake_start(command, *, env, cwd): + observed.update(command=command, env=env, cwd=cwd) + return child + + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + fake_start, + ) + + exit_code = cli_module._default_runner( + ["python", "-m", "agentseek_api.worker"], + env={"TOKEN": "value"}, + cwd="/runtime", + ) + + assert exit_code == 23 + assert child.terminated is False + assert child.close_remaining_tree_calls == [5.0] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + assert observed == { + "command": ["python", "-m", "agentseek_api.worker"], + "env": {"TOKEN": "value"}, + "cwd": "/runtime", + } + + +def test_default_runner_terminates_and_reaps_child_on_keyboard_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor(wait_result=KeyboardInterrupt()) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + exit_code = cli_module._default_runner( + ["python", "-m", "agentseek_api.scheduler"], + env={}, + cwd="/runtime", + ) + + assert exit_code == 130 + assert child.terminated is True + assert child.killed is False + assert child.wait_calls == 1 + assert child.forward_and_reap_calls == [(signal.SIGINT, 5.0)] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + + +def test_default_runner_delegates_bounded_escalation_for_interrupt( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + child = _FakeForegroundSupervisor( + wait_result=KeyboardInterrupt(), + escalates=True, + ) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + assert cli_module._default_runner(["child"], env={}, cwd=None) == 130 + assert child.terminated is True + assert child.killed is True + assert child.forward_and_reap_calls == [(signal.SIGINT, 5.0)] + assert child.ensure_closed_calls == [5.0] + assert child.close_calls == 1 + + +@pytest.mark.parametrize( + "failure_point", + ["guard-entry", "child-start", "guard-attach", "native-cleanup"], +) +def test_public_worker_redacts_process_supervision_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + failure_point: str, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api.process_supervisor import ProcessSupervisionError + + setup_canary = "setup-canary" + command_canary = "command-canary" + environment_canary = "environment-canary" + config_path = tmp_path / "langgraph.json" + config_path.write_text( + '{"graphs":{"chat":"chat.graph:graph"},' + f'"env":{{"SUPERVISION_SECRET":"{environment_canary}"}}}}', + encoding="utf-8", + ) + monkeypatch.delenv("SUPERVISION_SECRET", raising=False) + + child = _FakeForegroundSupervisor(wait_result=0) + child.live = True + + def fail_native_cleanup(*, timeout: float) -> None: + raise ProcessSupervisionError(setup_canary) + + if failure_point == "native-cleanup": + child.close_remaining_tree = fail_native_cleanup # type: ignore[method-assign] + + original_terminate_and_reap = child.terminate_and_reap + + def terminate_and_reap(*, timeout: float) -> None: + original_terminate_and_reap(timeout=timeout) + child.live = False + + child.terminate_and_reap = terminate_and_reap # type: ignore[method-assign] + + original_close = child.close + + def close() -> None: + original_close() + child.live = False + + child.close = close # type: ignore[method-assign] + + class _FakeGuard: + def __enter__(self): + if failure_point == "guard-entry": + raise ProcessSupervisionError(setup_canary) + return self + + def __exit__(self, exc_type, exc, traceback) -> bool: + return False + + def attach(self, attached_child) -> None: + assert attached_child is child + if failure_point == "guard-attach": + raise ProcessSupervisionError(setup_canary) + + def begin_cleanup(self) -> None: + return None + + def start(command, *, env, cwd): + assert command == [command_canary] + assert env["SUPERVISION_SECRET"] == environment_canary + assert cwd == str(tmp_path) + if failure_point == "child-start": + raise ProcessSupervisionError(setup_canary) + return child + + monkeypatch.setattr(cli_module, "ForwardingSignalGuard", _FakeGuard) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + start, + ) + monkeypatch.setattr( + cli_module, + "build_worker_command", + lambda: [command_canary], + ) + stdout = io.StringIO() + stderr = io.StringIO() + + exit_code = cli_module.main( + ["worker", "--config", str(config_path)], + cwd=tmp_path, + stdout=stdout, + stderr=stderr, + ) + + combined_output = stdout.getvalue() + stderr.getvalue() + assert exit_code == 2 + assert stderr.getvalue() == "Could not supervise the runtime child safely.\n" + assert "Traceback" not in combined_output + assert setup_canary not in combined_output + assert command_canary not in combined_output + assert environment_canary not in combined_output + if failure_point in {"guard-attach", "native-cleanup"}: + assert child.live is False + assert child.ensure_closed_calls == [5.0] + 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): diff --git a/tests/unit/test_process_supervisor.py b/tests/unit/test_process_supervisor.py new file mode 100644 index 0000000..237792b --- /dev/null +++ b/tests/unit/test_process_supervisor.py @@ -0,0 +1,1269 @@ +from __future__ import annotations + +import signal +import subprocess +import sys +from types import SimpleNamespace + +import pytest + + +class _FakePopen: + def __init__( + self, + *, + pid: int = 4312, + wait_results: list[int | BaseException] | None = None, + ) -> None: + self.pid = pid + self.returncode: int | None = None + self.wait_results = list(wait_results or [0]) + self.wait_timeouts: list[float | None] = [] + + def wait(self, timeout: float | None = None) -> int: + self.wait_timeouts.append(timeout) + if not self.wait_results: + assert self.returncode is not None + return self.returncode + result = self.wait_results.pop(0) + if isinstance(result, BaseException): + raise result + self.returncode = result + return result + + def poll(self) -> int | None: + return self.returncode + + +class _SignalHarness: + def __init__( + self, + *, + deliver_on_restore: int | None = None, + deliver_on_install: int | None = None, + old_mask: frozenset[int] | None = None, + ) -> None: + self.previous = { + signal.SIGINT: object(), + signal.SIGTERM: object(), + } + self.handlers = dict(self.previous) + self.old_mask = ( + old_mask + if old_mask is not None + else ( + frozenset({signal.SIGUSR1}) + if hasattr(signal, "SIGUSR1") + else frozenset() + ) + ) + self.deliver_on_restore = deliver_on_restore + self.deliver_on_install = deliver_on_install + self.events: list[tuple[str, object]] = [] + + def getsignal(self, signum: int): + return self.handlers[signum] + + def install(self, signum: int, handler): + previous = self.handlers[signum] + self.handlers[signum] = handler + self.events.append(("handler", signum)) + if self.deliver_on_install == signum: + self.deliver_on_install = None + handler(signum, None) + return previous + + def pthread_sigmask(self, operation: int, mask): + frozen_mask = frozenset(mask) + self.events.append(("mask", (operation, frozen_mask))) + if operation == signal.SIG_BLOCK: + return self.old_mask + assert operation == signal.SIG_SETMASK + assert frozen_mask == self.old_mask + if self.deliver_on_restore is not None: + signum = self.deliver_on_restore + self.deliver_on_restore = None + self.handlers[signum](signum, None) + return frozenset({signal.SIGINT, signal.SIGTERM}) + + +class _AttachedChild: + def __init__(self) -> None: + self.forwarded: list[int] = [] + + def forward_signal(self, signum: int) -> None: + self.forwarded.append(signum) + + +def _install_signal_harness( + monkeypatch: pytest.MonkeyPatch, + harness: _SignalHarness, + *, + is_windows: bool = False, +): + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", is_windows) + monkeypatch.setattr(supervisor_module.signal, "getsignal", harness.getsignal) + monkeypatch.setattr(supervisor_module.signal, "signal", harness.install) + monkeypatch.setattr( + supervisor_module.signal, + "pthread_sigmask", + harness.pthread_sigmask, + ) + return supervisor_module + + +def test_forwarding_signal_guard_restores_exact_handlers_and_mask( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(RuntimeError, match="body-canary"): + with supervisor_module.ForwardingSignalGuard(): + raise RuntimeError("body-canary") + + assert harness.handlers == harness.previous + assert harness.events[0] == ( + "mask", + ( + signal.SIG_BLOCK, + frozenset({signal.SIGINT, signal.SIGTERM}), + ), + ) + assert harness.events[-1] == ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) + + +def test_pending_signal_is_delivered_only_after_child_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(deliver_on_restore=signal.SIGTERM) + supervisor_module = _install_signal_harness(monkeypatch, harness) + child = _AttachedChild() + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + assert child.forwarded == [] + guard.attach(child) + + assert captured.value.signum == signal.SIGTERM + assert harness.handlers == harness.previous + + +def test_second_signal_during_cleanup_is_non_throwing_and_reforwarded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + child = _AttachedChild() + + with supervisor_module.ForwardingSignalGuard() as guard: + guard.attach(child) + guard.begin_cleanup() + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + + assert child.forwarded == [signal.SIGTERM, signal.SIGTERM] + assert harness.handlers == harness.previous + + +def test_guard_rejects_unverified_handler_installation_and_restores_state( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + def ignore_sigterm_install(signum: int, handler): + previous = harness.handlers[signum] + if signum == signal.SIGINT or handler in harness.previous.values(): + harness.handlers[signum] = handler + return previous + + monkeypatch.setattr( + supervisor_module.signal, + "signal", + ignore_sigterm_install, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + assert harness.handlers == harness.previous + + +def test_guard_fails_closed_when_callers_mask_blocks_forwarded_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(old_mask=frozenset({signal.SIGTERM})) + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + assert harness.handlers == harness.previous + assert ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) in harness.events + + +def test_signal_arriving_during_popen_is_pending_until_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + process = _FakePopen(wait_results=[0]) + + def fake_popen(command, **kwargs): + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + return process + + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + guard.attach(child) + + assert captured.value.signum == signal.SIGTERM + + +def test_windows_signal_during_first_handler_install_is_not_lost( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(deliver_on_install=signal.SIGINT) + supervisor_module = _install_signal_harness( + monkeypatch, + harness, + is_windows=True, + ) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with supervisor_module.ForwardingSignalGuard() as guard: + guard.attach(_AttachedChild()) + + assert captured.value.signum == signal.SIGINT + + +def test_signal_during_pending_consumption_is_delivered_from_attach( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _AttachRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_pending_read = False + super().__init__() + + def __getattribute__(self, name: str): + value = object.__getattribute__(self, name) + if name == "_pending_signal" and object.__getattribute__( + self, "arm_pending_read" + ): + object.__setattr__(self, "arm_pending_read", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + return value + + guard = _AttachRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + harness.handlers[signal.SIGINT](signal.SIGINT, None) + guard.arm_pending_read = True + guard.attach(_AttachedChild()) + + assert captured.value.signum == signal.SIGINT + + +def test_posix_start_uses_new_session_without_shell_and_preserves_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[23]) + observed: dict[str, object] = {} + + def fake_popen(command, **kwargs): + observed.update(command=command, kwargs=kwargs) + return process + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 23, + ) + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + lambda _pgid, _leader_pid: False, + ) + command = ["python", "command-canary"] + environment = {"SECRET": "environment-canary"} + + child = supervisor_module.ForegroundChildSupervisor.start( + command, + env=environment, + cwd="/runtime", + ) + + assert child.wait() == 23 + child.close_remaining_tree(timeout=5.0) + child.ensure_closed(timeout=5.0) + child.close() + assert process.wait_timeouts == [0.0] + assert observed == { + "command": command, + "kwargs": { + "env": environment, + "cwd": "/runtime", + "start_new_session": True, + }, + } + + +def test_posix_start_failure_is_value_free( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + + def fail_popen(command, **kwargs): + raise OSError("setup-canary command-canary environment-canary") + + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fail_popen) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module.ForegroundChildSupervisor.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + ) + + assert "setup-canary" not in str(captured.value) + assert "command-canary" not in str(captured.value) + assert "environment-canary" not in str(captured.value) + + +def test_posix_start_fails_before_popen_without_nonreaping_wait_support( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + popen_called = False + + def fake_popen(command, **kwargs): + nonlocal popen_called + popen_called = True + return _FakePopen() + + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + monkeypatch.delattr(supervisor_module.os, "WNOWAIT") + monkeypatch.setattr(supervisor_module.subprocess, "Popen", fake_popen) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._PosixChild.start(["child"], env={}, cwd=None) + + assert popen_called is False + + +def test_darwin_without_os_waitid_uses_native_nonreaping_observer( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + observed: list[tuple[int, bool]] = [] + monkeypatch.setattr(supervisor_module.sys, "platform", "darwin") + monkeypatch.delattr(supervisor_module.os, "waitid") + monkeypatch.setattr( + supervisor_module, + "_darwin_waitid_no_reap", + lambda pid, *, nohang: observed.append((pid, nohang)) or 27, + raising=False, + ) + + assert supervisor_module._waitid_no_reap(4312, nohang=True) == 27 + assert observed == [(4312, True)] + + +@pytest.mark.skipif(sys.platform != "darwin", reason="Darwin libc waitid only") +def test_darwin_native_waitid_observes_exit_without_reaping() -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = subprocess.Popen( + [sys.executable, "-c", "raise SystemExit(27)"], + ) + try: + assert ( + supervisor_module._darwin_waitid_no_reap( + process.pid, + nohang=False, + ) + == 27 + ) + assert process.returncode is None + assert process.wait(timeout=1.0) == 27 + finally: + if process.returncode is None: + process.kill() + process.wait(timeout=1.0) + + +def test_posix_waitid_preserves_forwarded_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + forwarded = supervisor_module._ForwardedSignal(signal.SIGTERM) + monkeypatch.setattr( + supervisor_module.os, + "waitid", + lambda id_type, pid, options: (_ for _ in ()).throw(forwarded), + ) + + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + supervisor_module._waitid_no_reap(4312, nohang=False) + + assert captured.value is forwarded + + +def test_posix_persistent_observer_failure_still_attempts_final_direct_reap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + signals: list[int] = [] + deadlines: list[float] = [] + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda pid, *, nohang: (_ for _ in ()).throw( + supervisor_module.ProcessSupervisionError() + ), + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda _pgid, signum: signals.append(signum), + ) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + return False, True + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert signals == [signal.SIGTERM, signal.SIGKILL] + assert len(deadlines) == 2 + assert process.wait_timeouts == [0.0] + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.ensure_closed(timeout=5.0) + assert signals == [signal.SIGTERM, signal.SIGKILL] + + +def test_posix_group_forwarding_escalates_and_reaps_direct_child( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: sent.append((pgid, signum)), + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + outcomes = iter([(False, False), (True, False)]) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + outcome = next(outcomes) + if outcome[0]: + child._child._observed_exit_code = -signal.SIGKILL + return outcome + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert sent == [ + (process.pid, signal.SIGTERM), + (process.pid, signal.SIGKILL), + ] + assert process.wait_timeouts == [0.0] + + +def test_posix_hard_kill_wait_is_bounded_when_direct_child_does_not_reap( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[subprocess.TimeoutExpired("child", 0.0)]) + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr(supervisor_module.os, "killpg", lambda pgid, signum: None) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + deadlines: list[float] = [] + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + return False, False + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert len(deadlines) == 2 + assert all(deadline < float("inf") for deadline in deadlines) + assert process.wait_timeouts == [0.0] + + +def test_posix_hard_cleanup_uses_a_separate_finite_deadline( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[-signal.SIGKILL]) + monotonic_values = iter([0.0, 10.0]) + deadlines: list[float] = [] + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr(supervisor_module.os, "killpg", lambda pgid, signum: None) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: None, + ) + monkeypatch.setattr( + supervisor_module.time, + "monotonic", + lambda: next(monotonic_values), + ) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: + deadlines.append(deadline) + if len(deadlines) == 2: + child._child._observed_exit_code = -signal.SIGKILL + return True, False + return False, False + + monkeypatch.setattr(child._child, "_wait_for_owned_tree_exit", wait_for_tree) + + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + + assert deadlines == [5.0, 15.0] + assert process.wait_timeouts == [0.0] + + +def test_posix_normal_return_terminates_remaining_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[29]) + group_states = iter([True, False]) + sent: list[tuple[int, int]] = [] + monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", False) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 29, + ) + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + lambda _pgid, _leader_pid: next(group_states), + ) + monkeypatch.setattr(supervisor_module.os, "getpgid", lambda _pid: process.pid) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: sent.append((pgid, signum)), + ) + + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + assert child.wait() == 29 + child.close_remaining_tree(timeout=5.0) + assert sent == [(process.pid, signal.SIGTERM)] + assert process.wait_timeouts == [0.0] + + +def test_posix_normal_return_retains_leader_until_group_cleanup_then_reaps( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(wait_results=[41]) + events: list[tuple[str, object]] = [] + other_member_states = iter([True, False]) + monkeypatch.setattr( + supervisor_module.subprocess, + "Popen", + lambda command, **kwargs: process, + ) + + def waitid(id_type, pid, options): + events.append(("observe", (id_type, pid, options))) + return SimpleNamespace( + si_pid=pid, + si_code=supervisor_module.os.CLD_EXITED, + si_status=41, + ) + + monkeypatch.setattr(supervisor_module.os, "waitid", waitid) + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: (_ for _ in ()).throw(ProcessLookupError()), + ) + + def has_other_members(pgid: int, leader_pid: int) -> bool: + events.append(("members", (pgid, leader_pid))) + return next(other_member_states) + + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + has_other_members, + raising=False, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: events.append(("signal", (pgid, signum))), + ) + child = supervisor_module.ForegroundChildSupervisor.start( + ["child"], + env={}, + cwd=None, + ) + + assert child.wait() == 41 + assert process.wait_timeouts == [] + child.close_remaining_tree(timeout=5.0) + + assert process.wait_timeouts == [0.0] + assert [name for name, _value in events] == [ + "observe", + "members", + "signal", + "members", + ] + assert events[2] == ("signal", (process.pid, signal.SIGTERM)) + + +class _FakeWin32Api: + def __init__( + self, + *, + fail_at: str | None = None, + exit_code: int = 0, + job_empty_results: list[bool] | None = None, + wait_process_results: list[bool | BaseException] | None = None, + ) -> None: + self.fail_at = fail_at + self.failed = False + self.exit_code = exit_code + self.job_empty_results = list(job_empty_results or [True]) + self.wait_process_results = list(wait_process_results or [True]) + self.events: list[tuple[str, object]] = [] + + def _record(self, name: str, value: object = None) -> None: + self.events.append((name, value)) + if self.fail_at == name and not self.failed: + self.failed = True + raise OSError(f"{name}-setup-canary") + + def create_job(self): + self._record("create-job") + return "job-handle" + + def set_kill_on_close(self, job) -> None: + self._record("set-kill-on-close", job) + + def create_suspended_process(self, command, *, env, cwd): + self._record( + "create-suspended", + (list(command), dict(env), cwd), + ) + return "process-handle", "thread-handle", 8128 + + def assign_process_to_job(self, job, process) -> None: + self._record("assign-job", (job, process)) + + def resume_thread(self, thread) -> None: + self._record("resume-thread", thread) + + def terminate_process(self, process) -> None: + self._record("terminate-process", process) + + def terminate_job(self, job) -> None: + self._record("terminate-job", job) + + def wait_process(self, process, timeout: float | None) -> bool: + self._record("wait-process", (process, timeout)) + result = self.wait_process_results.pop(0) + if isinstance(result, BaseException): + raise result + return result + + def process_exit_code(self, process) -> int: + self._record("exit-code", process) + return self.exit_code + + def wait_for_job_empty(self, job, timeout: float) -> bool: + self._record("wait-job-empty", (job, timeout)) + return self.job_empty_results.pop(0) + + def send_ctrl_break(self, process_id: int) -> None: + self._record("ctrl-break", process_id) + + def close_handle(self, handle) -> None: + self._record(f"close-{handle}", handle) + + +class _FakeWindowsLaunchNative: + def __init__( + self, + *, + missing_streams: frozenset[int] = frozenset(), + fail_duplicate_at: int | None = None, + fail_delete: bool = False, + fail_close: frozenset[str] = frozenset(), + fail_abort: bool = False, + ) -> None: + self.unrelated_inheritable_handle = "sentinel-handle" + self.missing_streams = missing_streams + self.fail_duplicate_at = fail_duplicate_at + self.fail_delete = fail_delete + self.fail_close = fail_close + self.fail_abort = fail_abort + self.duplicate_calls = 0 + self.events: list[tuple[str, object]] = [] + + def get_standard_handle(self, stream: int): + if stream in self.missing_streams: + self.events.append(("get-standard", (stream, None))) + return None + handle = { + -10: "stdin-handle", + -11: "stdout-handle", + -12: "stderr-handle", + }[stream] + self.events.append(("get-standard", (stream, handle))) + return handle + + def open_null_handle(self, stream: int): + handle = f"null-handle-{stream}" + self.events.append(("open-null", (stream, handle))) + return handle + + def duplicate_inheritable_handle(self, handle): + duplicate_index = self.duplicate_calls + self.duplicate_calls += 1 + if duplicate_index == self.fail_duplicate_at: + self.events.append(("duplicate-failed", handle)) + raise OSError("duplicate-canary") + duplicate = f"duplicate-{handle}" + self.events.append(("duplicate", (handle, duplicate))) + return duplicate + + def create_handle_list(self, handles): + self.events.append(("create-handle-list", tuple(handles))) + return "attribute-list" + + def create_suspended_process( + self, + command, + *, + env, + cwd, + standard_handles, + attribute_list, + ): + self.events.append( + ( + "create-process", + { + "command": list(command), + "env": dict(env), + "cwd": cwd, + "standard_handles": tuple(standard_handles), + "attribute_list": attribute_list, + }, + ) + ) + return "process-handle", "thread-handle", 9127 + + def delete_handle_list(self, attribute_list) -> None: + self.events.append(("delete-handle-list", attribute_list)) + if self.fail_delete: + raise OSError("delete-canary") + + def close_handle(self, handle) -> None: + self.events.append(("close-duplicate", handle)) + if handle in self.fail_close: + raise OSError("close-canary") + + def abort_suspended_process(self, process, thread) -> None: + self.events.append(("abort-process", (process, thread))) + if self.fail_abort: + raise OSError("abort-canary") + + +def test_windows_launcher_inherits_only_inheritable_stdio_duplicates() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative() + launcher = supervisor_module._WindowsProcessLauncher(native) + + result = launcher.create( + ["python", "child.py"], + env={"TOKEN": "value"}, + cwd="C:\\runtime", + ) + + assert result == ("process-handle", "thread-handle", 9127) + create_event = next( + value for name, value in native.events if name == "create-process" + ) + expected_duplicates = ( + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ) + assert create_event["standard_handles"] == expected_duplicates + assert native.unrelated_inheritable_handle not in create_event["standard_handles"] + assert ("create-handle-list", expected_duplicates) in native.events + assert native.events[-4:] == [ + ("delete-handle-list", "attribute-list"), + ("close-duplicate", "duplicate-stdin-handle"), + ("close-duplicate", "duplicate-stdout-handle"), + ("close-duplicate", "duplicate-stderr-handle"), + ] + + +@pytest.mark.parametrize("failure_index", [0, 1, 2]) +def test_windows_launcher_closes_partial_standard_handle_duplicates( + failure_index: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(fail_duplicate_at=failure_index) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="duplicate-canary"): + launcher.create(["child"], env={}, cwd=None) + + closed_duplicates = [ + value + for name, value in native.events + if name == "close-duplicate" and str(value).startswith("duplicate-") + ] + assert ( + closed_duplicates + == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + ][:failure_index] + ) + + +def test_windows_launcher_substitutes_null_for_missing_stdin() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(missing_streams=frozenset({-10})) + launcher = supervisor_module._WindowsProcessLauncher(native) + + launcher.create(["child"], env={}, cwd=None) + + create_event = next( + value for name, value in native.events if name == "create-process" + ) + assert create_event["standard_handles"] == ( + "duplicate-null-handle--10", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ) + assert ( + "create-handle-list", + create_event["standard_handles"], + ) in native.events + assert ("close-duplicate", "null-handle--10") in native.events + + +def test_windows_launcher_attempts_all_cleanup_before_aborting_created_process() -> ( + None +): + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative( + fail_delete=True, + fail_close=frozenset({"duplicate-stdout-handle"}), + fail_abort=True, + ) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="delete-canary"): + launcher.create(["child"], env={}, cwd=None) + + assert ("abort-process", ("process-handle", "thread-handle")) in native.events + assert [value for name, value in native.events if name == "close-duplicate"] == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ] + + +class _FakeAbortKernel32: + def __init__(self, failure_point: str) -> None: + self.failure_point = failure_point + self.events: list[tuple[str, object]] = [] + + def TerminateProcess(self, process, exit_code: int) -> bool: + self.events.append(("terminate", (process, exit_code))) + return self.failure_point != "terminate" + + def WaitForSingleObject(self, process, timeout: int) -> int: + self.events.append(("wait", (process, timeout))) + if self.failure_point == "wait-timeout": + return 258 + if self.failure_point == "wait-failed": + return 0xFFFFFFFF + return 0 + + def CloseHandle(self, handle) -> bool: + self.events.append(("close", handle)) + return self.failure_point != f"close-{handle}" + + +@pytest.mark.parametrize( + "failure_point", + [ + "terminate", + "wait-timeout", + "wait-failed", + "close-thread-handle", + "close-process-handle", + ], +) +def test_windows_native_abort_reports_failures_after_attempting_all_cleanup( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAbortKernel32(failure_point) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.abort_suspended_process("process-handle", "thread-handle") + + assert kernel32.events == [ + ("terminate", ("process-handle", 1)), + ("wait", ("process-handle", 5000)), + ("close", "thread-handle"), + ("close", "process-handle"), + ] + + +def test_windows_child_is_assigned_to_kill_on_close_job_before_resume() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api() + child = supervisor_module._WindowsChild.start( + ["python", "command-canary"], + env={"SECRET": "environment-canary"}, + cwd="C:\\runtime", + api=api, + ) + child.close() + + names = [name for name, _value in api.events] + assert names[:6] == [ + "create-job", + "set-kill-on-close", + "create-suspended", + "assign-job", + "resume-thread", + "close-thread-handle", + ] + assert names.index("assign-job") < names.index("resume-thread") + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +@pytest.mark.parametrize( + "failure_point", + [ + "set-kill-on-close", + "create-suspended", + "assign-job", + "resume-thread", + "close-thread-handle", + ], +) +def test_windows_setup_failure_terminates_and_closes_every_acquired_handle( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(fail_at=failure_point) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._WindowsChild.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + api=api, + ) + + names = [name for name, _value in api.events] + assert "job-handle" not in str(captured.value) + assert "setup-canary" not in str(captured.value) + assert "close-job-handle" in names + if failure_point in { + "assign-job", + "resume-thread", + "close-thread-handle", + }: + assert "close-process-handle" in names + assert "close-thread-handle" in names + if failure_point == "assign-job": + assert "terminate-process" in names + if failure_point in {"resume-thread", "close-thread-handle"}: + assert "terminate-job" in names + + +def test_windows_interrupt_timeout_terminates_job_and_closes_handles() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(job_empty_results=[False, True]) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_and_reap(signal.SIGINT, timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert "ctrl-break" in names + assert "terminate-job" in names + assert names[-2:] == ["close-process-handle", "close-job-handle"] + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert wait_timeouts + assert all(timeout is not None for timeout in wait_timeouts) + + +def test_windows_unsupported_ctrl_break_falls_back_to_job_termination() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="ctrl-break", + job_empty_results=[True, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_signal(signal.SIGINT) + api.failed = False + child.forward_and_reap(signal.SIGINT, timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("ctrl-break") == 2 + assert names.count("terminate-job") == 2 + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_ctrl_break_fallback_preserves_default_runner_exit_130( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="ctrl-break", + job_empty_results=[True, True], + wait_process_results=[KeyboardInterrupt(), True], + ) + child = supervisor_module._WindowsChild.start( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + api=api, + ) + foreground = supervisor_module.ForegroundChildSupervisor(child) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: foreground, + ) + + assert ( + cli_module._default_runner( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd=None, + ) + == 130 + ) + + names = [name for name, _value in api.events] + assert "ctrl-break" in names + assert "terminate-job" in names + assert all( + value[1] is not None for name, value in api.events if name == "wait-process" + ) + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_normal_wait_polls_with_finite_intervals() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + exit_code=47, + wait_process_results=[False, False, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + assert child.wait() == 47 + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert len(wait_timeouts) == 3 + assert all(timeout is not None and 0 < timeout <= 0.1 for timeout in wait_timeouts) + child.close() + + +def test_windows_normal_return_terminates_remaining_job_members() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api(exit_code=31, job_empty_results=[False, True]) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + assert child.wait() == 31 + child.close_remaining_tree(timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("terminate-job") == 1 + assert names.count("wait-job-empty") == 2 + + +def test_windows_native_cleanup_failure_still_closes_every_handle() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at="terminate-job", + job_empty_results=[False], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + try: + child.ensure_closed(timeout=5.0) + finally: + child.close() + + names = [name for name, _value in api.events] + assert names[-2:] == ["close-process-handle", "close-job-handle"] From e8985ea18ba64f948179b7107378c8d92e9d0dbe Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 19:14:28 +0800 Subject: [PATCH 19/28] fix: close process supervision races --- .github/workflows/ci.yml | 10 +- src/agentseek_api/process_supervisor.py | 151 +++-- .../integration/test_cli_runtime_processes.py | 53 ++ tests/unit/test_process_supervisor.py | 610 ++++++++++++++++-- 4 files changed, 723 insertions(+), 101 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5eda70e..fe4a5d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,8 +90,14 @@ jobs: if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') run: uv run python scripts/test_cli_serve_smoke.py - - name: CLI config and Docker planning tests - run: uv run pytest tests/unit/test_cli.py tests/unit/test_graph_manifest.py -q + - name: CLI config, Docker planning, and runtime process tests + run: >- + uv run pytest + tests/unit/test_cli.py + tests/unit/test_graph_manifest.py + tests/unit/test_process_supervisor.py + tests/integration/test_cli_runtime_processes.py + -q - name: Minimum CLI dependency compatibility run: uv run python scripts/test_minimum_cli_dependencies.py diff --git a/src/agentseek_api/process_supervisor.py b/src/agentseek_api/process_supervisor.py index 58d6d7f..5e0a92c 100644 --- a/src/agentseek_api/process_supervisor.py +++ b/src/agentseek_api/process_supervisor.py @@ -109,9 +109,11 @@ def attach(self, child: _SignalTarget) -> None: self._child = child self._state = "waiting" pending_signal = self._pending_signal + if pending_signal is None: + return + self._state = "delivering" self._pending_signal = None - if pending_signal is not None: - raise _ForwardedSignal(pending_signal) + raise _ForwardedSignal(pending_signal) def begin_cleanup(self) -> None: if self._state != "closed": @@ -127,11 +129,12 @@ def _handle_signal( self._pending_signal = signum return if self._state == "waiting": + self._state = "delivering" if self._pending_signal is not None: signum = self._pending_signal self._pending_signal = None raise _ForwardedSignal(signum) - if self._state == "cleanup": + if self._state in {"delivering", "cleanup"}: if self._child is None: return try: @@ -151,8 +154,8 @@ def _block_for_handler_installation(self) -> None: def _restore_entry_mask(self) -> None: if not self._mask_is_blocked or self._original_mask is None: return - self._mask_is_blocked = False signal.pthread_sigmask(signal.SIG_SETMASK, self._original_mask) + self._mask_is_blocked = False def _restore_after_failed_entry(self) -> None: failed = False @@ -361,24 +364,37 @@ def _darwin_process_group_members(pgid: int) -> set[int]: ctypes.c_int, ] list_group_pids.restype = ctypes.c_int - capacity = list_group_pids(pgid, None, 0) except BaseException as exc: raise ProcessSupervisionError() from exc - if capacity < 0: - raise ProcessSupervisionError() - capacity = max(16, capacity) - for _attempt in range(3): - buffer = (ctypes.c_int * capacity)() - count = list_group_pids( - pgid, - ctypes.cast(buffer, ctypes.c_void_p), - ctypes.sizeof(buffer), - ) - if count < 0: + + def list_pids(buffer, size: int) -> int: + ctypes.set_errno(0) + count = list_group_pids(pgid, buffer, size) + call_errno = ctypes.get_errno() + if count < 0 or (count == 0 and call_errno != 0): raise ProcessSupervisionError() - if count < capacity: - return {int(pid) for pid in buffer[:count] if pid > 0} - capacity *= 2 + return count + + try: + capacity = list_pids(None, 0) + if capacity == 0: + return set() + capacity = max(16, capacity) + for _attempt in range(3): + buffer = (ctypes.c_int * capacity)() + count = list_pids( + ctypes.cast(buffer, ctypes.c_void_p), + ctypes.sizeof(buffer), + ) + if count < capacity: + return {int(pid) for pid in buffer[:count] if pid > 0} + capacity *= 2 + except (KeyboardInterrupt, _ForwardedSignal): + raise + except ProcessSupervisionError: + raise + except BaseException as exc: + raise ProcessSupervisionError() from exc raise ProcessSupervisionError() @@ -400,6 +416,7 @@ def __init__(self, process: subprocess.Popen[bytes]) -> None: self._pgid = process.pid self._observed_exit_code: int | None = None self._direct_reaped = False + self._group_signal_allowed = True self._cleanup_error = False @classmethod @@ -498,6 +515,8 @@ def _validate_process_group(self) -> None: raise ProcessSupervisionError() def _signal_group(self, signum: int) -> None: + if not self._group_signal_allowed or self._direct_reaped: + return self._validate_process_group() try: os.killpg(self._pgid, signum) @@ -547,13 +566,27 @@ def _reap_observed_child(self) -> None: raise ProcessSupervisionError() expected_exit_code = self._observed_exit_code try: - observed_exit_code = self._process.wait(timeout=0.0) + observed_exit_code = self._wait_and_mark_direct_reaped() + except (KeyboardInterrupt, _ForwardedSignal): + raise except BaseException as exc: raise ProcessSupervisionError() from exc - self._direct_reaped = True if observed_exit_code != expected_exit_code: raise ProcessSupervisionError() + def _wait_and_mark_direct_reaped(self) -> int: + previous_mask = signal.pthread_sigmask( + signal.SIG_BLOCK, + set(_MANAGED_SIGNALS), + ) + self._group_signal_allowed = False + try: + observed_exit_code = self._process.wait(timeout=0.0) + self._direct_reaped = True + return observed_exit_code + finally: + signal.pthread_sigmask(signal.SIG_SETMASK, previous_mask) + def _clean_and_reap( self, signum: int, @@ -618,13 +651,14 @@ def _clean_and_reap( failure = True else: try: - self._process.wait(timeout=0.0) + self._wait_and_mark_direct_reaped() except subprocess.TimeoutExpired: pass + except (KeyboardInterrupt, _ForwardedSignal): + raise except BaseException: failure = True else: - self._direct_reaped = True failure = True if failure or not complete or not self._direct_reaped: @@ -644,14 +678,11 @@ def create_suspended_process( *, env: dict[str, str], cwd: str | None, + job, ): ... - def assign_process_to_job(self, job, process) -> None: ... - def resume_thread(self, thread) -> None: ... - def terminate_process(self, process) -> None: ... - def terminate_job(self, job) -> None: ... def wait_process(self, process, timeout: float | None) -> bool: ... @@ -754,10 +785,11 @@ class _PROCESS_INFORMATION(ctypes.Structure): class _Win32AttributeList: - def __init__(self, *, buffer, pointer, handle_array) -> None: + def __init__(self, *, buffer, pointer, handle_array, job_array) -> None: self.buffer = buffer self.pointer = pointer self.handle_array = handle_array + self.job_array = job_array class _WindowsLaunchNativeProtocol(Protocol): @@ -767,7 +799,7 @@ def open_null_handle(self, stream: int): ... def duplicate_inheritable_handle(self, handle): ... - def create_handle_list(self, handles): ... + def create_attribute_list(self, handles, jobs): ... def create_suspended_process( self, @@ -796,6 +828,7 @@ def create( *, env: dict[str, str], cwd: str | None, + job, ): duplicates: list[object] = [] owned_sources: list[object] = [] @@ -812,7 +845,10 @@ def create( standard_handles.append(handle) for handle in standard_handles: duplicates.append(self._native.duplicate_inheritable_handle(handle)) - attribute_list = self._native.create_handle_list(duplicates) + attribute_list = self._native.create_attribute_list( + duplicates, + (job,), + ) result = self._native.create_suspended_process( command, env=env, @@ -860,6 +896,7 @@ class _CtypesWindowsLaunchNative: _EXTENDED_STARTUPINFO_PRESENT = 0x00080000 _STARTF_USESTDHANDLES = 0x00000100 _PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002 + _PROC_THREAD_ATTRIBUTE_JOB_LIST = 0x0002000D _DUPLICATE_SAME_ACCESS = 0x00000002 _WAIT_OBJECT_0 = 0x00000000 _GENERIC_READ = 0x80000000 @@ -908,11 +945,11 @@ def duplicate_inheritable_handle(self, handle): self._raise_error() return duplicate.value - def create_handle_list(self, handles): + def create_attribute_list(self, handles, jobs): size = ctypes.c_size_t() self._kernel32.InitializeProcThreadAttributeList( None, - 1, + 2, 0, ctypes.byref(size), ) @@ -922,7 +959,7 @@ def create_handle_list(self, handles): pointer = ctypes.cast(buffer, ctypes.c_void_p) if not self._kernel32.InitializeProcThreadAttributeList( pointer, - 1, + 2, 0, ctypes.byref(size), ): @@ -939,10 +976,23 @@ def create_handle_list(self, handles): ): self._kernel32.DeleteProcThreadAttributeList(pointer) self._raise_error() + job_array = (wintypes.HANDLE * len(jobs))(*jobs) + if not self._kernel32.UpdateProcThreadAttribute( + pointer, + 0, + self._PROC_THREAD_ATTRIBUTE_JOB_LIST, + ctypes.cast(job_array, ctypes.c_void_p), + ctypes.sizeof(job_array), + None, + None, + ): + self._kernel32.DeleteProcThreadAttributeList(pointer) + self._raise_error() return _Win32AttributeList( buffer=buffer, pointer=pointer, handle_array=handle_array, + job_array=job_array, ) def create_suspended_process( @@ -1069,8 +1119,6 @@ def __init__(self) -> None: ctypes.POINTER(_PROCESS_INFORMATION), ] kernel32.CreateProcessW.restype = wintypes.BOOL - kernel32.AssignProcessToJobObject.argtypes = [wintypes.HANDLE, wintypes.HANDLE] - kernel32.AssignProcessToJobObject.restype = wintypes.BOOL kernel32.ResumeThread.argtypes = [wintypes.HANDLE] kernel32.ResumeThread.restype = wintypes.DWORD kernel32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT] @@ -1168,22 +1216,20 @@ def create_suspended_process( *, env: dict[str, str], cwd: str | None, + job, ): - return self._process_launcher.create(command, env=env, cwd=cwd) - - def assign_process_to_job(self, job, process) -> None: - if not self._kernel32.AssignProcessToJobObject(job, process): - self._raise_error() + return self._process_launcher.create( + command, + env=env, + cwd=cwd, + job=job, + ) def resume_thread(self, thread) -> None: previous_count = self._kernel32.ResumeThread(thread) if previous_count != 1: self._raise_error() - def terminate_process(self, process) -> None: - if not self._kernel32.TerminateProcess(process, 1): - self._raise_error() - def terminate_job(self, job) -> None: if not self._kernel32.TerminateJobObject(job, 1): self._raise_error() @@ -1269,7 +1315,6 @@ def start( job = None process = None thread = None - assigned = False try: job = native.create_job() native.set_kill_on_close(job) @@ -1277,9 +1322,8 @@ def start( command, env=env, cwd=cwd, + job=job, ) - native.assign_process_to_job(job, process) - assigned = True native.resume_thread(thread) native.close_handle(thread) thread = None @@ -1295,7 +1339,6 @@ def start( job=job, process=process, thread=thread, - assigned=assigned, ) raise ProcessSupervisionError() from exc @@ -1306,10 +1349,9 @@ def _rollback_start( job, process, thread, - assigned: bool, ) -> None: if process is not None: - if assigned and job is not None: + if job is not None: try: api.terminate_job(job) except BaseException: @@ -1318,15 +1360,6 @@ def _rollback_start( api.wait_for_job_empty(job, 5.0) except BaseException: pass - else: - try: - api.terminate_process(process) - except BaseException: - pass - try: - api.wait_process(process, 5.0) - except BaseException: - pass for handle in (thread, process, job): if handle is None: continue diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 67662a1..e721148 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -518,6 +518,59 @@ def _start_supervisor_wrapper( ) +@pytest.mark.skipif(os.name != "nt", reason="Windows Job Object acquisition only") +def test_windows_hard_termination_during_creation_leaves_no_unassigned_child( + tmp_path: Path, +) -> None: + child_pid_path = tmp_path / "atomic-job-child.pid" + helper = """ +import os +import sys +import time +from pathlib import Path +from agentseek_api import process_supervisor as supervisor + +api = supervisor._Win32Api() +native = api._process_launcher._native +create_suspended_process = native.create_suspended_process + +def pause_after_create(*args, **kwargs): + result = create_suspended_process(*args, **kwargs) + Path(sys.argv[1]).write_text(str(result[2]), encoding="utf-8") + time.sleep(60) + return result + +native.create_suspended_process = pause_after_create +supervisor._WindowsChild.start( + [sys.executable, "-c", "import time; time.sleep(60)"], + env=dict(os.environ), + cwd=None, + api=api, +) +""" + process = subprocess.Popen( + [sys.executable, "-c", helper, str(child_pid_path)], + cwd=tmp_path, + env=dict(os.environ), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + child_pid = 0 + try: + child_pid = _read_observed_pid(child_pid_path, timeout_seconds=10.0) + assert _pid_is_alive(child_pid) + + process.kill() + process.communicate(timeout=5) + + _wait_for_pids_gone((child_pid,), timeout_seconds=8.0) + finally: + _stop_test_process(process) + if child_pid: + _cleanup_recorded_pids((child_pid,)) + + def test_external_sigterm_forwards_and_leaves_no_runtime_child( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_process_supervisor.py b/tests/unit/test_process_supervisor.py index 237792b..8820e71 100644 --- a/tests/unit/test_process_supervisor.py +++ b/tests/unit/test_process_supervisor.py @@ -1,5 +1,7 @@ from __future__ import annotations +import errno +import os import signal import subprocess import sys @@ -8,6 +10,9 @@ import pytest +_POSIX_ONLY = pytest.mark.skipif(os.name == "nt", reason="POSIX process groups only") + + class _FakePopen: def __init__( self, @@ -42,6 +47,7 @@ def __init__( deliver_on_restore: int | None = None, deliver_on_install: int | None = None, old_mask: frozenset[int] | None = None, + fail_restore_attempts: int = 0, ) -> None: self.previous = { signal.SIGINT: object(), @@ -59,6 +65,8 @@ def __init__( ) self.deliver_on_restore = deliver_on_restore self.deliver_on_install = deliver_on_install + self.fail_restore_attempts = fail_restore_attempts + self.current_mask = self.old_mask self.events: list[tuple[str, object]] = [] def getsignal(self, signum: int): @@ -76,15 +84,21 @@ def install(self, signum: int, handler): def pthread_sigmask(self, operation: int, mask): frozen_mask = frozenset(mask) self.events.append(("mask", (operation, frozen_mask))) + previous_mask = self.current_mask if operation == signal.SIG_BLOCK: - return self.old_mask + self.current_mask = previous_mask | frozen_mask + return previous_mask assert operation == signal.SIG_SETMASK assert frozen_mask == self.old_mask + if self.fail_restore_attempts: + self.fail_restore_attempts -= 1 + raise OSError("mask-restore-canary") + self.current_mask = frozen_mask if self.deliver_on_restore is not None: signum = self.deliver_on_restore self.deliver_on_restore = None self.handlers[signum](signum, None) - return frozenset({signal.SIGINT, signal.SIGTERM}) + return previous_mask class _AttachedChild: @@ -106,10 +120,23 @@ def _install_signal_harness( monkeypatch.setattr(supervisor_module, "_IS_WINDOWS", is_windows) monkeypatch.setattr(supervisor_module.signal, "getsignal", harness.getsignal) monkeypatch.setattr(supervisor_module.signal, "signal", harness.install) + monkeypatch.setattr( + supervisor_module.signal, + "SIG_BLOCK", + getattr(supervisor_module.signal, "SIG_BLOCK", 0), + raising=False, + ) + monkeypatch.setattr( + supervisor_module.signal, + "SIG_SETMASK", + getattr(supervisor_module.signal, "SIG_SETMASK", 2), + raising=False, + ) monkeypatch.setattr( supervisor_module.signal, "pthread_sigmask", harness.pthread_sigmask, + raising=False, ) return supervisor_module @@ -138,6 +165,23 @@ def test_forwarding_signal_guard_restores_exact_handlers_and_mask( ) +def test_fake_posix_signal_guard_supports_windows_signal_module( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.delattr(supervisor_module.signal, "SIG_BLOCK", raising=False) + monkeypatch.delattr(supervisor_module.signal, "SIG_SETMASK", raising=False) + _install_signal_harness(monkeypatch, harness) + + with pytest.raises(RuntimeError, match="body-canary"): + with supervisor_module.ForwardingSignalGuard(): + raise RuntimeError("body-canary") + + assert harness.handlers == harness.previous + + def test_pending_signal_is_delivered_only_after_child_attachment( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -213,6 +257,30 @@ def test_guard_fails_closed_when_callers_mask_blocks_forwarded_signal( ) in harness.events +def test_failed_guard_entry_retries_exact_mask_restore( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness(fail_restore_attempts=1) + supervisor_module = _install_signal_harness(monkeypatch, harness) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + with supervisor_module.ForwardingSignalGuard(): + pass + + restore_events = [ + event + for event in harness.events + if event + == ( + "mask", + (signal.SIG_SETMASK, harness.old_mask), + ) + ] + assert len(restore_events) == 2 + assert harness.current_mask == harness.old_mask + assert harness.handlers == harness.previous + + def test_signal_arriving_during_popen_is_pending_until_attachment( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -288,6 +356,80 @@ def __getattribute__(self, name: str): assert captured.value.signum == signal.SIGINT +def test_signal_after_pending_clear_cannot_displace_first_attach_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _AfterClearRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_after_clear = False + super().__init__() + + def __setattr__(self, name: str, value: object) -> None: + object.__setattr__(self, name, value) + if ( + name == "_pending_signal" + and value is None + and object.__getattribute__(self, "arm_after_clear") + ): + object.__setattr__(self, "arm_after_clear", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + + child = _AttachedChild() + guard = _AfterClearRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + harness.handlers[signal.SIGINT](signal.SIGINT, None) + guard.arm_after_clear = True + guard.attach(child) + + assert captured.value.signum == signal.SIGINT + assert child.forwarded == [signal.SIGTERM] + + +def test_reentrant_signal_after_handler_clear_cannot_displace_pending_signal( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness(monkeypatch, harness) + + class _HandlerClearRaceGuard(supervisor_module.ForwardingSignalGuard): + def __init__(self) -> None: + self.arm_handler_clear = False + super().__init__() + + def __setattr__(self, name: str, value: object) -> None: + object.__setattr__(self, name, value) + if ( + name == "_pending_signal" + and value is None + and object.__getattribute__(self, "arm_handler_clear") + ): + object.__setattr__(self, "arm_handler_clear", False) + object.__getattribute__(self, "_installed_handler")( + signal.SIGTERM, + None, + ) + + child = _AttachedChild() + guard = _HandlerClearRaceGuard() + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + with guard: + guard.attach(child) + guard._pending_signal = signal.SIGINT + guard.arm_handler_clear = True + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + + assert captured.value.signum == signal.SIGINT + assert child.forwarded == [signal.SIGTERM] + + +@_POSIX_ONLY def test_posix_start_uses_new_session_without_shell_and_preserves_inputs( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -336,6 +478,7 @@ def fake_popen(command, **kwargs): } +@_POSIX_ONLY def test_posix_start_failure_is_value_free( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -360,6 +503,7 @@ def fail_popen(command, **kwargs): assert "environment-canary" not in str(captured.value) +@_POSIX_ONLY def test_posix_start_fails_before_popen_without_nonreaping_wait_support( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -382,6 +526,7 @@ def fake_popen(command, **kwargs): assert popen_called is False +@_POSIX_ONLY def test_darwin_without_os_waitid_uses_native_nonreaping_observer( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -389,7 +534,7 @@ def test_darwin_without_os_waitid_uses_native_nonreaping_observer( observed: list[tuple[int, bool]] = [] monkeypatch.setattr(supervisor_module.sys, "platform", "darwin") - monkeypatch.delattr(supervisor_module.os, "waitid") + monkeypatch.delattr(supervisor_module.os, "waitid", raising=False) monkeypatch.setattr( supervisor_module, "_darwin_waitid_no_reap", @@ -424,16 +569,99 @@ def test_darwin_native_waitid_observes_exit_without_reaping() -> None: process.wait(timeout=1.0) +@pytest.mark.parametrize( + ("native_errno", "expected_members", "raises"), + [ + (0, set(), False), + (errno.EPERM, None, True), + ], +) +@_POSIX_ONLY +def test_darwin_group_enumeration_distinguishes_empty_from_native_error( + monkeypatch: pytest.MonkeyPatch, + native_errno: int, + expected_members: set[int] | None, + raises: bool, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ListGroupPids: + def __init__(self) -> None: + self.calls: list[tuple[int, object, int]] = [] + + def __call__(self, pgid: int, buffer, size: int) -> int: + self.calls.append((pgid, buffer, size)) + if len(self.calls) == 1: + supervisor_module.ctypes.set_errno(0) + return 16 + supervisor_module.ctypes.set_errno(native_errno) + return 0 + + list_group_pids = _ListGroupPids() + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda path, *, use_errno: SimpleNamespace( + proc_listpgrppids=list_group_pids, + ), + ) + + if raises: + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._darwin_process_group_members(4312) + else: + assert supervisor_module._darwin_process_group_members(4312) == expected_members + + assert len(list_group_pids.calls) == 2 + + +@_POSIX_ONLY +def test_darwin_group_enumeration_wraps_native_callable_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _FailingListGroupPids: + def __call__(self, pgid: int, buffer, size: int) -> int: + raise OSError("libproc-canary") + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda path, *, use_errno: SimpleNamespace( + proc_listpgrppids=_FailingListGroupPids(), + ), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._darwin_process_group_members(4312) + + assert "libproc-canary" not in str(captured.value) + + +@_POSIX_ONLY def test_posix_waitid_preserves_forwarded_signal( monkeypatch: pytest.MonkeyPatch, ) -> None: from agentseek_api import process_supervisor as supervisor_module forwarded = supervisor_module._ForwardedSignal(signal.SIGTERM) + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + }.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) monkeypatch.setattr( supervisor_module.os, "waitid", lambda id_type, pid, options: (_ for _ in ()).throw(forwarded), + raising=False, ) with pytest.raises(supervisor_module._ForwardedSignal) as captured: @@ -442,6 +670,7 @@ def test_posix_waitid_preserves_forwarded_signal( assert captured.value is forwarded +@_POSIX_ONLY def test_posix_persistent_observer_failure_still_attempts_final_direct_reap( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -491,6 +720,7 @@ def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: assert signals == [signal.SIGTERM, signal.SIGKILL] +@_POSIX_ONLY def test_posix_group_forwarding_escalates_and_reaps_direct_child( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -539,6 +769,7 @@ def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: assert process.wait_timeouts == [0.0] +@_POSIX_ONLY def test_posix_hard_kill_wait_is_bounded_when_direct_child_does_not_reap( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -579,6 +810,7 @@ def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: assert process.wait_timeouts == [0.0] +@_POSIX_ONLY def test_posix_hard_cleanup_uses_a_separate_finite_deadline( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -626,6 +858,7 @@ def wait_for_tree(*, deadline: float) -> tuple[bool, bool]: assert process.wait_timeouts == [0.0] +@_POSIX_ONLY def test_posix_normal_return_terminates_remaining_process_group( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -669,6 +902,7 @@ def test_posix_normal_return_terminates_remaining_process_group( assert process.wait_timeouts == [0.0] +@_POSIX_ONLY def test_posix_normal_return_retains_leader_until_group_cleanup_then_reaps( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -683,15 +917,11 @@ def test_posix_normal_return_retains_leader_until_group_cleanup_then_reaps( lambda command, **kwargs: process, ) - def waitid(id_type, pid, options): - events.append(("observe", (id_type, pid, options))) - return SimpleNamespace( - si_pid=pid, - si_code=supervisor_module.os.CLD_EXITED, - si_status=41, - ) - - monkeypatch.setattr(supervisor_module.os, "waitid", waitid) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda pid, *, nohang: events.append(("observe", (pid, nohang))) or 41, + ) monkeypatch.setattr( supervisor_module.os, "getpgid", @@ -733,6 +963,185 @@ def has_other_members(pgid: int, leader_pid: int) -> bool: assert events[2] == ("signal", (process.pid, signal.SIGTERM)) +@_POSIX_ONLY +@pytest.mark.parametrize("signum", [signal.SIGINT, signal.SIGTERM]) +def test_posix_signal_during_final_reap_preserves_signal_exit_without_reused_pgid( + monkeypatch: pytest.MonkeyPatch, + signum: int, +) -> None: + from agentseek_api import cli as cli_module + from agentseek_api import process_supervisor as supervisor_module + + harness = _SignalHarness() + _install_signal_harness(monkeypatch, harness) + process = _FakePopen(wait_results=[41]) + child = supervisor_module.ForegroundChildSupervisor( + supervisor_module._PosixChild(process) + ) + monkeypatch.setattr( + supervisor_module, + "_waitid_no_reap", + lambda _pid, *, nohang: 41, + ) + + def no_other_members(_pgid: int, _leader_pid: int) -> bool: + harness.deliver_on_restore = signum + return False + + monkeypatch.setattr( + supervisor_module, + "_process_group_has_other_members", + no_other_members, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + lambda command, *, env, cwd: child, + ) + + assert cli_module._default_runner(["child"], env={}, cwd=None) == 128 + signum + assert process.wait_timeouts == [0.0] + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_forward_signal_after_reap_never_targets_reused_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(pid=4312) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 0 + child._direct_reaped = True + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + child.forward_signal(signal.SIGTERM) + + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_reap_revokes_group_signaling_before_wait_returns( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + reused_group_signals: list[tuple[int, int]] = [] + + class _SignalDuringWaitPopen(_FakePopen): + def wait(self, timeout: float | None = None) -> int: + self.wait_timeouts.append(timeout) + self.returncode = 41 + child.forward_signal(signal.SIGTERM) + return 41 + + process = _SignalDuringWaitPopen(pid=4312) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + child._reap_observed_child() + + assert process.wait_timeouts == [0.0] + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_failed_final_reap_never_reauthorizes_numeric_process_group( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen( + pid=4312, + wait_results=[subprocess.TimeoutExpired("child", 0.0)], + ) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + reused_group_signals: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, delivered: reused_group_signals.append((pgid, delivered)), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child._reap_observed_child() + child.forward_signal(signal.SIGTERM) + + assert reused_group_signals == [] + + +@_POSIX_ONLY +def test_posix_reap_mask_block_failure_preserves_tree_cleanup_authority( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + process = _FakePopen(pid=4312, wait_results=[41]) + child = supervisor_module._PosixChild(process) + child._observed_exit_code = 41 + monkeypatch.setattr( + supervisor_module.signal, + "pthread_sigmask", + lambda operation, mask: (_ for _ in ()).throw(OSError("mask-canary")), + ) + monkeypatch.setattr( + supervisor_module.os, + "getpgid", + lambda _pid: process.pid, + ) + delivered: list[tuple[int, int]] = [] + monkeypatch.setattr( + supervisor_module.os, + "killpg", + lambda pgid, signum: delivered.append((pgid, signum)), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + child._reap_observed_child() + child.forward_signal(signal.SIGTERM) + + assert process.wait_timeouts == [] + assert delivered == [(process.pid, signal.SIGTERM)] + + class _FakeWin32Api: def __init__( self, @@ -762,22 +1171,16 @@ def create_job(self): def set_kill_on_close(self, job) -> None: self._record("set-kill-on-close", job) - def create_suspended_process(self, command, *, env, cwd): + def create_suspended_process(self, command, *, env, cwd, job=None): self._record( "create-suspended", - (list(command), dict(env), cwd), + (list(command), dict(env), cwd, job), ) return "process-handle", "thread-handle", 8128 - def assign_process_to_job(self, job, process) -> None: - self._record("assign-job", (job, process)) - def resume_thread(self, thread) -> None: self._record("resume-thread", thread) - def terminate_process(self, process) -> None: - self._record("terminate-process", process) - def terminate_job(self, job) -> None: self._record("terminate-job", job) @@ -810,6 +1213,7 @@ def __init__( missing_streams: frozenset[int] = frozenset(), fail_duplicate_at: int | None = None, fail_delete: bool = False, + fail_attribute_list: bool = False, fail_close: frozenset[str] = frozenset(), fail_abort: bool = False, ) -> None: @@ -817,6 +1221,7 @@ def __init__( self.missing_streams = missing_streams self.fail_duplicate_at = fail_duplicate_at self.fail_delete = fail_delete + self.fail_attribute_list = fail_attribute_list self.fail_close = fail_close self.fail_abort = fail_abort self.duplicate_calls = 0 @@ -849,8 +1254,15 @@ def duplicate_inheritable_handle(self, handle): self.events.append(("duplicate", (handle, duplicate))) return duplicate - def create_handle_list(self, handles): - self.events.append(("create-handle-list", tuple(handles))) + def create_attribute_list(self, handles, jobs): + self.events.append( + ( + "create-attribute-list", + (tuple(handles), tuple(jobs)), + ) + ) + if self.fail_attribute_list: + raise OSError("attribute-list-canary") return "attribute-list" def create_suspended_process( @@ -892,6 +1304,105 @@ def abort_suspended_process(self, process, thread) -> None: raise OSError("abort-canary") +class _FakeAttributeKernel32: + def __init__(self, *, fail_attribute: int | None = None) -> None: + self.fail_attribute = fail_attribute + self.initialize_counts: list[int] = [] + self.updated_attributes: list[tuple[int, int]] = [] + self.deleted: list[object] = [] + + def InitializeProcThreadAttributeList( + self, + pointer, + count: int, + flags: int, + size, + ) -> bool: + self.initialize_counts.append(count) + assert flags == 0 + if pointer is None: + size._obj.value = 128 + return False + return True + + def UpdateProcThreadAttribute( + self, + pointer, + flags: int, + attribute: int, + value, + size: int, + previous, + return_size, + ) -> bool: + assert pointer + assert flags == 0 + assert value + assert previous is None + assert return_size is None + self.updated_attributes.append((attribute, size)) + return attribute != self.fail_attribute + + def DeleteProcThreadAttributeList(self, pointer) -> None: + self.deleted.append(pointer) + + +def test_windows_native_attribute_list_includes_stdio_and_atomic_job() -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32() + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + attribute_list = native.create_attribute_list( + (11, 12, 13), + (99,), + ) + + handle_size = supervisor_module.ctypes.sizeof(supervisor_module.wintypes.HANDLE) + assert kernel32.initialize_counts == [2, 2] + assert kernel32.updated_attributes == [ + (0x00020002, 3 * handle_size), + (0x0002000D, handle_size), + ] + assert list(attribute_list.handle_array) == [11, 12, 13] + assert list(attribute_list.job_array) == [99] + + +def test_windows_native_job_attribute_failure_deletes_attribute_list() -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32(fail_attribute=0x0002000D) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.create_attribute_list((11, 12, 13), (99,)) + + assert [attribute for attribute, _size in kernel32.updated_attributes] == [ + 0x00020002, + 0x0002000D, + ] + assert len(kernel32.deleted) == 1 + + +def test_windows_child_has_no_unassigned_post_creation_window() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api() + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + child.close() + + create_event = next( + value for name, value in api.events if name == "create-suspended" + ) + assert create_event[-1] == "job-handle" + assert "assign-job" not in [name for name, _value in api.events] + + def test_windows_launcher_inherits_only_inheritable_stdio_duplicates() -> None: from agentseek_api import process_supervisor as supervisor_module @@ -902,6 +1413,7 @@ def test_windows_launcher_inherits_only_inheritable_stdio_duplicates() -> None: ["python", "child.py"], env={"TOKEN": "value"}, cwd="C:\\runtime", + job="job-handle", ) assert result == ("process-handle", "thread-handle", 9127) @@ -915,7 +1427,10 @@ def test_windows_launcher_inherits_only_inheritable_stdio_duplicates() -> None: ) assert create_event["standard_handles"] == expected_duplicates assert native.unrelated_inheritable_handle not in create_event["standard_handles"] - assert ("create-handle-list", expected_duplicates) in native.events + assert ( + "create-attribute-list", + (expected_duplicates, ("job-handle",)), + ) in native.events assert native.events[-4:] == [ ("delete-handle-list", "attribute-list"), ("close-duplicate", "duplicate-stdin-handle"), @@ -934,7 +1449,7 @@ def test_windows_launcher_closes_partial_standard_handle_duplicates( launcher = supervisor_module._WindowsProcessLauncher(native) with pytest.raises(OSError, match="duplicate-canary"): - launcher.create(["child"], env={}, cwd=None) + launcher.create(["child"], env={}, cwd=None, job="job-handle") closed_duplicates = [ value @@ -956,7 +1471,7 @@ def test_windows_launcher_substitutes_null_for_missing_stdin() -> None: native = _FakeWindowsLaunchNative(missing_streams=frozenset({-10})) launcher = supervisor_module._WindowsProcessLauncher(native) - launcher.create(["child"], env={}, cwd=None) + launcher.create(["child"], env={}, cwd=None, job="job-handle") create_event = next( value for name, value in native.events if name == "create-process" @@ -967,8 +1482,8 @@ def test_windows_launcher_substitutes_null_for_missing_stdin() -> None: "duplicate-stderr-handle", ) assert ( - "create-handle-list", - create_event["standard_handles"], + "create-attribute-list", + (create_event["standard_handles"], ("job-handle",)), ) in native.events assert ("close-duplicate", "null-handle--10") in native.events @@ -986,7 +1501,7 @@ def test_windows_launcher_attempts_all_cleanup_before_aborting_created_process() launcher = supervisor_module._WindowsProcessLauncher(native) with pytest.raises(OSError, match="delete-canary"): - launcher.create(["child"], env={}, cwd=None) + launcher.create(["child"], env={}, cwd=None, job="job-handle") assert ("abort-process", ("process-handle", "thread-handle")) in native.events assert [value for name, value in native.events if name == "close-duplicate"] == [ @@ -996,6 +1511,23 @@ def test_windows_launcher_attempts_all_cleanup_before_aborting_created_process() ] +def test_windows_launcher_job_attribute_failure_closes_stdio_without_creation() -> None: + from agentseek_api import process_supervisor as supervisor_module + + native = _FakeWindowsLaunchNative(fail_attribute_list=True) + launcher = supervisor_module._WindowsProcessLauncher(native) + + with pytest.raises(OSError, match="attribute-list-canary"): + launcher.create(["child"], env={}, cwd=None, job="job-handle") + + assert "create-process" not in [name for name, _value in native.events] + assert [value for name, value in native.events if name == "close-duplicate"] == [ + "duplicate-stdin-handle", + "duplicate-stdout-handle", + "duplicate-stderr-handle", + ] + + class _FakeAbortKernel32: def __init__(self, failure_point: str) -> None: self.failure_point = failure_point @@ -1047,7 +1579,7 @@ def test_windows_native_abort_reports_failures_after_attempting_all_cleanup( ] -def test_windows_child_is_assigned_to_kill_on_close_job_before_resume() -> None: +def test_windows_child_is_created_in_kill_on_close_job_before_resume() -> None: from agentseek_api import process_supervisor as supervisor_module api = _FakeWin32Api() @@ -1060,15 +1592,19 @@ def test_windows_child_is_assigned_to_kill_on_close_job_before_resume() -> None: child.close() names = [name for name, _value in api.events] - assert names[:6] == [ + assert names[:5] == [ "create-job", "set-kill-on-close", "create-suspended", - "assign-job", "resume-thread", "close-thread-handle", ] - assert names.index("assign-job") < names.index("resume-thread") + create_event = next( + value for name, value in api.events if name == "create-suspended" + ) + assert create_event[-1] == "job-handle" + assert "assign-job" not in names + assert names.index("create-suspended") < names.index("resume-thread") assert names[-2:] == ["close-process-handle", "close-job-handle"] @@ -1077,7 +1613,6 @@ def test_windows_child_is_assigned_to_kill_on_close_job_before_resume() -> None: [ "set-kill-on-close", "create-suspended", - "assign-job", "resume-thread", "close-thread-handle", ], @@ -1101,17 +1636,12 @@ def test_windows_setup_failure_terminates_and_closes_every_acquired_handle( assert "job-handle" not in str(captured.value) assert "setup-canary" not in str(captured.value) assert "close-job-handle" in names - if failure_point in { - "assign-job", - "resume-thread", - "close-thread-handle", - }: + if failure_point in {"resume-thread", "close-thread-handle"}: assert "close-process-handle" in names assert "close-thread-handle" in names - if failure_point == "assign-job": - assert "terminate-process" in names if failure_point in {"resume-thread", "close-thread-handle"}: assert "terminate-job" in names + assert "terminate-process" not in names def test_windows_interrupt_timeout_terminates_job_and_closes_handles() -> None: From a6c39ecf899077b0e5a012762ecaadb654b337b2 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 19:40:03 +0800 Subject: [PATCH 20/28] test: verify dotenv dependency floor --- .github/workflows/ci.yml | 7 +- pyproject.toml | 6 +- scripts/dotenv_conformance.py | 256 ----------------------- scripts/test_minimum_cli_dependencies.py | 90 ++++++-- tests/unit/test_cli.py | 41 ---- uv.lock | 6 +- 6 files changed, 83 insertions(+), 323 deletions(-) delete mode 100644 scripts/dotenv_conformance.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe4a5d5..4fcb799 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -90,16 +90,19 @@ jobs: if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') run: uv run python scripts/test_cli_serve_smoke.py - - name: CLI config, Docker planning, and runtime process tests + - name: CLI config, host environment, and process tests run: >- uv run pytest tests/unit/test_cli.py tests/unit/test_graph_manifest.py + tests/unit/test_dotenv_adapter.py + tests/unit/test_runtime_environment.py tests/unit/test_process_supervisor.py tests/integration/test_cli_runtime_processes.py -q - - name: Minimum CLI dependency compatibility + - name: Minimum direct dependency compatibility + if: runner.os == 'Linux' run: uv run python scripts/test_minimum_cli_dependencies.py embedded-seekdb-smoke: diff --git a/pyproject.toml b/pyproject.toml index e3c94f1..120e0f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,15 +9,15 @@ dependencies = [ "uvicorn>=0.30.0", "pydantic>=2.8.0", "pydantic-settings>=2.4.0", - "sqlalchemy>=2.0.0", + "sqlalchemy>=2.0.12", "greenlet>=3.1.0", "asyncpg>=0.29.0", "aiomysql>=0.2.0", "aiosqlite>=0.20.0", "redis>=5.0.0", - "langgraph>=1.0.3", + "langgraph>=1.0.6", "langgraph-sdk>=0.3.5", - "langchain-core>=1.0.0", + "langchain-core>=1.2.5", "langchain-openai>=1.0.0", "langchain-anthropic>=1.0.0", "langchain-oceanbase==0.6.0", diff --git a/scripts/dotenv_conformance.py b/scripts/dotenv_conformance.py deleted file mode 100644 index 8d93867..0000000 --- a/scripts/dotenv_conformance.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Shared dotenv interpolation cases for supported-version runtime tests.""" - -from __future__ import annotations - -import importlib.metadata -import io -import json -import os -import tempfile -from pathlib import Path - -from dotenv.parser import parse_stream -from dotenv.variables import Variable, parse_variables - -DOTENV_CONFORMANCE_CASES = ( - { - "name": "missing", - "contents": "CONF_MISSING=prefix-${CONF_UNSET_REFERENCE}-suffix\n", - "ambient": {}, - }, - { - "name": "duplicate-order", - "contents": ( - "CONF_ORIGIN=https://first.example\n" - "CONF_ORDERED=${CONF_ORIGIN}/v1\n" - "CONF_ORIGIN=https://second.example\n" - ), - "ambient": {}, - }, - { - "name": "broad-names", - "contents": "A.B=dotted\n1LEADING=digit\nCONF_BROAD=${A.B}-${1LEADING}\n", - "ambient": {}, - }, - { - "name": "multiline-default", - "contents": 'CONF_MULTILINE="${CONF_UNSET_REFERENCE:-first line\nsecond line}"\n', - "ambient": {}, - }, - { - "name": "bare-default", - "contents": "CONF_BARE=$CONF_UNSET_REFERENCE\nCONF_DEFAULT=${CONF_UNSET_REFERENCE:-fallback}\n", - "ambient": {}, - }, - { - "name": "empty-valueless", - "contents": ( - "CONF_EMPTY=\n" - "CONF_VALUELESS\n" - "CONF_FROM_EMPTY=${CONF_EMPTY:-fallback}\n" - "CONF_FROM_VALUELESS=${CONF_VALUELESS:-fallback}\n" - ), - "ambient": {}, - }, - { - "name": "ambient-references", - "contents": ( - "CONF_ALLOWED=${OPENAI_ALLOWED_SOURCE}\n" - "CONF_AMBIENT=${CONF_AMBIENT_SOURCE}\n" - ), - "ambient": { - "OPENAI_ALLOWED_SOURCE": "allowlisted-source", - "CONF_AMBIENT_SOURCE": "ambient-source", - }, - }, -) - -CROSS_LAYER_CONFORMANCE_CASES = ( - { - "name": "config-dotenv-reference", - "config_env": "./config.env", - "config_dotenv": "CONF_SOURCE=https://dotenv.example\n", - "cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n", - "shell_env": {}, - "expected": { - "CONF_SOURCE": "https://dotenv.example", - "CONF_RESULT": "https://dotenv.example/v1", - }, - "absent": (), - }, - { - "name": "config-mapping-reference", - "config_env": {"CONF_SOURCE": "https://mapping.example"}, - "config_dotenv": None, - "cli_dotenv": "CONF_RESULT=${CONF_SOURCE}/v1\n", - "shell_env": {}, - "expected": { - "CONF_SOURCE": "https://mapping.example", - "CONF_RESULT": "https://mapping.example/v1", - }, - "absent": (), - }, - { - "name": "final-allowlisted-shell-override", - "config_env": {"OPENAI_API_KEY": "from-config"}, - "config_dotenv": None, - "cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n", - "shell_env": {"OPENAI_API_KEY": "from-shell"}, - "expected": { - "CONF_RESULT": "from-config", - "OPENAI_API_KEY": "from-shell", - }, - "absent": (), - }, - { - "name": "config-dotenv-valueless", - "config_env": "./config.env", - "config_dotenv": "CONF_TOMBSTONE=from-config-dotenv\n", - "cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n", - "shell_env": {}, - "expected": { - "CONF_TOMBSTONE": "from-config-dotenv", - "CONF_RESULT": "", - }, - "absent": (), - }, - { - "name": "config-mapping-valueless", - "config_env": {"CONF_TOMBSTONE": "from-config-mapping"}, - "config_dotenv": None, - "cli_dotenv": "CONF_TOMBSTONE\nCONF_RESULT=${CONF_TOMBSTONE:-fallback}\n", - "shell_env": {}, - "expected": { - "CONF_TOMBSTONE": "from-config-mapping", - "CONF_RESULT": "", - }, - "absent": (), - }, - { - "name": "config-dotenv-valueless-does-not-mask-shell-for-next-file", - "config_env": "./config.env", - "config_dotenv": "OPENAI_API_KEY\n", - "cli_dotenv": "CONF_RESULT=${OPENAI_API_KEY}\n", - "shell_env": {"OPENAI_API_KEY": "from-shell"}, - "expected": { - "OPENAI_API_KEY": "from-shell", - "CONF_RESULT": "from-shell", - }, - "absent": (), - }, -) - -CONFORMANCE_AMBIENT_MODES = ( - ("clean", {}), - ( - "hostile", - { - "OPENAI_API_KEY": "ambient-provider-key", - "OPENAI_ALLOWED_SOURCE": "ambient-allowed-source", - "CONF_UNSET_REFERENCE": "ambient-missing-value", - "CONF_AMBIENT_SOURCE": "ambient-source", - "A.B": "ambient-dotted-value", - "1LEADING": "ambient-digit-value", - }, - ), -) - - -def _dotenv_keys(contents: str) -> set[str]: - keys: set[str] = set() - for binding in parse_stream(io.StringIO(contents)): - if binding.key is not None: - keys.add(binding.key) - if binding.value is not None: - keys.update(atom.name for atom in parse_variables(binding.value) if isinstance(atom, Variable)) - return keys - - -def _conformance_env_keys() -> frozenset[str]: - keys: set[str] = set() - for case in DOTENV_CONFORMANCE_CASES: - keys.update(_dotenv_keys(case["contents"])) - keys.update(case["ambient"]) - for case in CROSS_LAYER_CONFORMANCE_CASES: - config_env = case["config_env"] - if isinstance(config_env, dict): - keys.update(config_env) - if case["config_dotenv"] is not None: - keys.update(_dotenv_keys(case["config_dotenv"])) - keys.update(_dotenv_keys(case["cli_dotenv"])) - keys.update(case["shell_env"]) - keys.update(case["expected"]) - keys.update(case["absent"]) - return frozenset(keys) - - -DOTENV_CONFORMANCE_ENV_KEYS = _conformance_env_keys() - - -def assert_runtime_conformance(*, expected_dotenv_version: str | None = None) -> None: - """Compare the runtime loader with the installed python-dotenv version.""" - from dotenv import dotenv_values - - from agentseek_api.cli import build_runtime_env - - if expected_dotenv_version is not None: - assert importlib.metadata.version("python-dotenv") == expected_dotenv_version - - previous = {key: os.environ.get(key) for key in DOTENV_CONFORMANCE_ENV_KEYS} - try: - with tempfile.TemporaryDirectory(prefix="agentseek-dotenv-") as directory: - root = Path(directory) - for mode_name, mode_ambient in CONFORMANCE_AMBIENT_MODES: - for index, case in enumerate(DOTENV_CONFORMANCE_CASES): - for key in DOTENV_CONFORMANCE_ENV_KEYS: - os.environ.pop(key, None) - os.environ.update(mode_ambient) - os.environ.update(case["ambient"]) - env_file = root / f"{mode_name}-{index}.env" - env_file.write_text(case["contents"], encoding="utf-8") - upstream = dotenv_values(env_file) - expected = {key: value for key, value in upstream.items() if value is not None} - expected.update({key: os.environ[key] for key in upstream.keys() & os.environ.keys()}) - actual = build_runtime_env( - config_path=None, - env_file=str(env_file), - cwd=root, - base_env=dict(os.environ), - ) - assertion = f"{mode_name}/{case['name']}" - assert {key: actual[key] for key in expected} == expected, assertion - assert all( - key not in actual - for key, value in upstream.items() - if value is None and key not in os.environ - ), assertion - - for index, case in enumerate(CROSS_LAYER_CONFORMANCE_CASES): - for key in DOTENV_CONFORMANCE_ENV_KEYS: - os.environ.pop(key, None) - os.environ.update(mode_ambient) - os.environ.update(case["shell_env"]) - config_path = root / f"cross-layer-{mode_name}-{index}.json" - config_path.write_text( - json.dumps({"graphs": {"chat": "chat.graph:graph"}, "env": case["config_env"]}), - encoding="utf-8", - ) - if case["config_dotenv"] is not None: - (root / "config.env").write_text(case["config_dotenv"], encoding="utf-8") - env_file = root / f"cross-layer-{mode_name}-{index}.env" - env_file.write_text(case["cli_dotenv"], encoding="utf-8") - actual = build_runtime_env( - config_path=config_path, - env_file=str(env_file), - cwd=root, - base_env=dict(os.environ), - ) - assertion = f"{mode_name}/{case['name']}" - assert {key: actual[key] for key in case["expected"]} == case["expected"], assertion - assert all(key not in actual for key in case["absent"]), assertion - finally: - for key, value in previous.items(): - if value is None: - os.environ.pop(key, None) - else: - os.environ[key] = value diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index c048bbb..bda2143 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -1,19 +1,36 @@ -"""Verify the installed CLI runs with the declared minimum dotenv stack.""" +"""Verify host environment resolution with all direct dependency floors.""" from __future__ import annotations +import os import subprocess import sys import tempfile +import tomllib from pathlib import Path def main() -> None: repository = Path(__file__).resolve().parents[1] + project = tomllib.loads((repository / "pyproject.toml").read_text(encoding="utf-8")) + requirements = list(project["project"]["dependencies"]) + requirements.append("pytest>=8.0.0") + with tempfile.TemporaryDirectory(prefix="agentseek-minimum-") as directory: environment = Path(directory) / ".venv" - subprocess.run(["uv", "venv", "--python", sys.executable, str(environment)], check=True) - python = environment / ("Scripts/python.exe" if sys.platform == "win32" else "bin/python") + subprocess.run( + [ + "uv", + "venv", + "--python", + "3.12", + str(environment), + ], + check=True, + ) + python = environment / ( + "Scripts/python.exe" if sys.platform == "win32" else "bin/python" + ) subprocess.run( [ "uv", @@ -21,33 +38,70 @@ def main() -> None: "install", "--python", str(python), - "pydantic-settings==2.4.0", - "pydantic==2.8.0", - "python-dotenv==1.0.0", + "--resolution", + "lowest-direct", + *requirements, ], check=True, ) - subprocess.run(["uv", "pip", "install", "--python", str(python), "--no-deps", "-e", str(repository)], check=True) - cli = environment / ("Scripts/agentseek-api.exe" if sys.platform == "win32" else "bin/agentseek-api") - result = subprocess.run([str(cli), "version"], check=True, capture_output=True, text=True) - assert result.stdout.strip() == "agentseek-api 0.2.1" - conformance = subprocess.run( + subprocess.run( + [ + "uv", + "pip", + "install", + "--python", + str(python), + "--no-deps", + "-e", + str(repository), + ], + check=True, + ) + + version = subprocess.run( [ str(python), "-c", ( - "import sys; " - f"sys.path.insert(0, {str(repository / 'scripts')!r}); " - "from dotenv_conformance import assert_runtime_conformance; " - "assert_runtime_conformance(expected_dotenv_version='1.0.0')" + "import importlib.metadata; " + "print(importlib.metadata.version('python-dotenv'))" ), ], - check=False, + check=True, + capture_output=True, + text=True, + ) + assert version.stdout.strip() == "1.0.0" + + cli_env = dict(os.environ) + cli_env.pop("PYTHONPATH", None) + cli_env["PORT"] = "not-an-integer" + version_result = subprocess.run( + [str(python), "-m", "agentseek_api.cli", "version"], + cwd=repository, + env=cli_env, + check=True, capture_output=True, text=True, ) - if conformance.returncode != 0: - raise RuntimeError(f"Minimum dependency dotenv conformance failed: {conformance.stderr.strip()}") + expected_version = project["project"]["version"] + assert version_result.stdout.strip() == f"agentseek-api {expected_version}" + + test_env = dict(os.environ) + test_env.pop("PYTHONPATH", None) + subprocess.run( + [ + str(python), + "-m", + "pytest", + "tests/unit/test_dotenv_adapter.py", + "tests/unit/test_runtime_environment.py", + "-q", + ], + cwd=repository, + env=test_env, + check=True, + ) if __name__ == "__main__": diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 4d49e22..37f1a62 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -3,7 +3,6 @@ import argparse import importlib import io -import os import signal import tomllib from dataclasses import dataclass @@ -13,10 +12,6 @@ from pydantic import ValidationError from agentseek_api.services.langgraph_service import LangGraphService -from scripts.dotenv_conformance import ( - DOTENV_CONFORMANCE_CASES, - DOTENV_CONFORMANCE_ENV_KEYS, -) def test_python_dotenv_dependency_is_available() -> None: @@ -1224,42 +1219,6 @@ def test_build_runtime_env_shell_values_override_config_and_cli_dotenv(tmp_path: assert env["TOKEN"] == "from-shell" -@pytest.mark.parametrize( - "case", - DOTENV_CONFORMANCE_CASES, - ids=[case["name"] for case in DOTENV_CONFORMANCE_CASES], -) -def test_runtime_dotenv_interpolation_conforms_to_python_dotenv( - tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, - case: dict[str, object], -) -> None: - from dotenv import dotenv_values - - from agentseek_api.cli import build_runtime_env - - for key in DOTENV_CONFORMANCE_ENV_KEYS: - monkeypatch.delenv(key, raising=False) - ambient = case["ambient"] - assert isinstance(ambient, dict) - for key, value in ambient.items(): - monkeypatch.setenv(key, value) - env_file = tmp_path / ".env" - contents = case["contents"] - assert isinstance(contents, str) - env_file.write_text(contents, encoding="utf-8") - expected = {key: value for key, value in dotenv_values(env_file).items() if value is not None} - - actual = build_runtime_env( - config_path=None, - env_file=str(env_file), - cwd=tmp_path, - base_env=dict(os.environ), - ) - - assert {key: actual[key] for key in expected} == expected - - def test_higher_precedence_valueless_binding_keeps_lower_export(tmp_path: Path) -> None: from agentseek_api.cli import build_runtime_env diff --git a/uv.lock b/uv.lock index 2ec5ede..fe2fe65 100644 --- a/uv.lock +++ b/uv.lock @@ -83,11 +83,11 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.1.0" }, { name = "langchain", specifier = ">=0.3.9" }, { name = "langchain-anthropic", specifier = ">=1.0.0" }, - { name = "langchain-core", specifier = ">=1.0.0" }, + { name = "langchain-core", specifier = ">=1.2.5" }, { name = "langchain-oceanbase", specifier = "==0.6.0" }, { name = "langchain-oceanbase", extras = ["pyseekdb"], marker = "extra == 'embedded'", specifier = "==0.6.0" }, { name = "langchain-openai", specifier = ">=1.0.0" }, - { name = "langgraph", specifier = ">=1.0.3" }, + { name = "langgraph", specifier = ">=1.0.6" }, { name = "langgraph-sdk", specifier = ">=0.3.5" }, { name = "mcp", specifier = ">=1.27.1,<2" }, { name = "pydantic", specifier = ">=2.8.0" }, @@ -96,7 +96,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.0,<1.3" }, { name = "redis", specifier = ">=5.0.0" }, { name = "scalar-fastapi", specifier = ">=1.0.3" }, - { name = "sqlalchemy", specifier = ">=2.0.0" }, + { name = "sqlalchemy", specifier = ">=2.0.12" }, { name = "uvicorn", specifier = ">=0.30.0" }, ] provides-extras = ["embedded"] From 00b6236642bc8be054cfe8ac5acfd6c1fdd263df Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 19:47:25 +0800 Subject: [PATCH 21/28] test: add pytest asyncio floor coverage --- pyproject.toml | 2 +- scripts/test_minimum_cli_dependencies.py | 1 + uv.lock | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 120e0f9..961d4b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ agentseek-api = "agentseek_api.cli:main" [dependency-groups] dev = [ "pytest>=8.0.0", - "pytest-asyncio>=0.23.0", + "pytest-asyncio>=0.23.5", "pytest-cov>=5.0.0", "httpx>=0.27.0", "ruff>=0.6.0", diff --git a/scripts/test_minimum_cli_dependencies.py b/scripts/test_minimum_cli_dependencies.py index bda2143..206e618 100644 --- a/scripts/test_minimum_cli_dependencies.py +++ b/scripts/test_minimum_cli_dependencies.py @@ -15,6 +15,7 @@ def main() -> None: project = tomllib.loads((repository / "pyproject.toml").read_text(encoding="utf-8")) requirements = list(project["project"]["dependencies"]) requirements.append("pytest>=8.0.0") + requirements.append("pytest-asyncio>=0.23.5") with tempfile.TemporaryDirectory(prefix="agentseek-minimum-") as directory: environment = Path(directory) / ".venv" diff --git a/uv.lock b/uv.lock index fe2fe65..eae8bb9 100644 --- a/uv.lock +++ b/uv.lock @@ -108,7 +108,7 @@ dev = [ { name = "httpx", specifier = ">=0.27.0" }, { name = "langgraph-cli", extras = ["inmem"] }, { name = "pytest", specifier = ">=8.0.0" }, - { name = "pytest-asyncio", specifier = ">=0.23.0" }, + { name = "pytest-asyncio", specifier = ">=0.23.5" }, { name = "pytest-cov", specifier = ">=5.0.0" }, { name = "ruff", specifier = ">=0.6.0" }, ] From 49846e433193053fe13e6d9ef8b5b18d185cc5a4 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 19:56:12 +0800 Subject: [PATCH 22/28] docs: document host environment ownership --- CHANGELOG.md | 21 +++++++++++++++++++++ README.md | 30 +++++++++++++++++++++++++++++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 142937d..efabe93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ Notable changes to AgentSeek API are documented in this file. +## Unreleased + +### Fixed + +- Made inherited host environment keys, including explicit empty strings, + authoritative over config and CLI dotenv sources. +- Parse each dotenv source independently with strict malformed-file handling, + and distinguish valueless `KEY` from explicit empty `KEY=`. +- Start worker and scheduler roles in fresh child processes so their settings + are constructed after the resolved environment is installed. +- Kept version, help, and Dockerfile rendering independent of runtime settings + validation. + +### Upgrade notes + +- Dotenv values no longer interpolate from config mappings or other dotenv + files. Put dependent bindings in one physical file or pass the final literal + value. +- Malformed dotenv syntax now exits with status 2 instead of warning and + continuing with a partial runtime configuration. + ## 0.2.1 - 2026-07-14 ### Fixed diff --git a/README.md b/README.md index 5d2e7ee..e45a2ea 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,35 @@ When running from this repository, use `uv run agentseek-api ...`. - `-c, --config PATH`: explicit `agentseek.json`, `langgraph.json`, or manifest path -- `--env-file PATH`: dotenv-style file loaded into the runtime environment +- `--env-file PATH`: host-runtime dotenv source + +### Host runtime environment + +For `dev`, `serve`, `worker`, and `scheduler`, direct assignments are applied in +this order: + +1. the dotenv path named by config `env`; +2. the literal config `env` mapping and `auth.path`; +3. the CLI `--env-file`; +4. the environment inherited by `agentseek-api`. + +The inherited environment is authoritative by key presence. This includes an +explicit empty value. A lower source can fill an absent key, but cannot replace +an inherited `KEY=`. + +Each dotenv file is evaluated independently. It can reference the inherited +environment and earlier bindings in the same physical file; it cannot reference +a config mapping or another dotenv file. Later assignment does not recompute an +earlier interpolated value. + +In dotenv syntax, a bare `KEY` is valid but contributes no assignment, while +`KEY=` contributes an explicit empty string. Missing files, invalid UTF-8, and +malformed syntax stop the command before a runtime child starts. + +The CLI applies command-owned values after this merge: the selected config path +becomes `AGENTSEEK_GRAPHS`, and `dev` forces `STUDIO_AUTH_LOCAL_DEV=true`. +Host and port options are passed as child argv and do not rewrite environment +keys with similar names. ### Common usage From d6c589341a8c58f9729eb7f1c14b33af65990905 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 20:17:03 +0800 Subject: [PATCH 23/28] test: cover process supervision boundaries --- .github/workflows/ci.yml | 1 + src/agentseek_api/process_supervisor.py | 5 +- tests/unit/test_process_supervisor.py | 1029 ++++++++++++++++++++++- tests/unit/test_runtime_entrypoint.py | 171 ++++ 4 files changed, 1202 insertions(+), 4 deletions(-) create mode 100644 tests/unit/test_runtime_entrypoint.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4fcb799..0991be6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -97,6 +97,7 @@ jobs: tests/unit/test_graph_manifest.py tests/unit/test_dotenv_adapter.py tests/unit/test_runtime_environment.py + tests/unit/test_runtime_entrypoint.py tests/unit/test_process_supervisor.py tests/integration/test_cli_runtime_processes.py -q diff --git a/src/agentseek_api/process_supervisor.py b/src/agentseek_api/process_supervisor.py index 5e0a92c..118c258 100644 --- a/src/agentseek_api/process_supervisor.py +++ b/src/agentseek_api/process_supervisor.py @@ -70,7 +70,10 @@ def __enter__(self) -> Self: ): raise ProcessSupervisionError() for signum in _MANAGED_SIGNALS: - self._previous_handlers[signum] = signal.getsignal(signum) + previous_handler = signal.getsignal(signum) + if previous_handler is None: + raise ProcessSupervisionError() + self._previous_handlers[signum] = previous_handler for signum in _MANAGED_SIGNALS: signal.signal(signum, self._installed_handler) self._installed_signals.append(signum) diff --git a/tests/unit/test_process_supervisor.py b/tests/unit/test_process_supervisor.py index 8820e71..bab9727 100644 --- a/tests/unit/test_process_supervisor.py +++ b/tests/unit/test_process_supervisor.py @@ -240,6 +240,65 @@ def ignore_sigterm_install(signum: int, handler): assert harness.handlers == harness.previous +def test_guard_rejects_unknown_native_handler_before_any_installation( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + harness.previous[signal.SIGTERM] = None + harness.handlers = dict(harness.previous) + supervisor_module = _install_signal_harness(monkeypatch, harness) + guard = supervisor_module.ForwardingSignalGuard() + entered = False + + try: + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + guard.__enter__() + entered = True + finally: + if entered: + guard.__exit__(None, None, None) + + assert str(captured.value) == "Runtime child supervision failed." + assert harness.handlers == harness.previous + assert [event for event in harness.events if event[0] == "handler"] == [] + assert harness.current_mask == harness.old_mask + + +def test_default_runner_redacts_unknown_native_handler_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import cli as cli_module + + harness = _SignalHarness() + harness.previous[signal.SIGTERM] = None + harness.handlers = dict(harness.previous) + _install_signal_harness(monkeypatch, harness) + child_started = False + + def start_child(command, *, env, cwd): + nonlocal child_started + child_started = True + raise AssertionError(f"{command!r} {env!r} {cwd!r}") + + monkeypatch.setattr( + cli_module.ForegroundChildSupervisor, + "start", + start_child, + ) + + with pytest.raises(cli_module.CliError) as captured: + cli_module._default_runner( + ["command-canary"], + env={"SECRET": "environment-canary"}, + cwd="cwd-canary", + ) + + assert str(captured.value) == "Could not supervise the runtime child safely." + assert child_started is False + assert "canary" not in str(captured.value) + assert [event for event in harness.events if event[0] == "handler"] == [] + + def test_guard_fails_closed_when_callers_mask_blocks_forwarded_signal( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -639,6 +698,217 @@ def __call__(self, pgid: int, buffer, size: int) -> int: assert "libproc-canary" not in str(captured.value) +@_POSIX_ONLY +def test_linux_group_enumeration_reads_only_live_numeric_processes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ProcEntries: + def __init__(self) -> None: + self.entries = [ + SimpleNamespace(name="self"), + SimpleNamespace(name="100"), + SimpleNamespace(name="101"), + SimpleNamespace(name="102"), + SimpleNamespace(name="103"), + ] + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def __iter__(self): + return iter(self.entries) + + class _StatFile: + def __init__(self, text: str) -> None: + self.text = text + + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> str: + return self.text + + stat_text = { + "100": "100 (leader process) S 1 4312 0", + "102": "102 (worker with ) character) S 100 4312 0", + "103": "103 (other group) S 1 9000 0", + } + + def open_stat(path: str, *, encoding: str): + assert encoding == "utf-8" + pid = path.split("/")[2] + if pid == "101": + raise FileNotFoundError(path) + return _StatFile(stat_text[pid]) + + monkeypatch.setattr( + supervisor_module.os, + "scandir", + lambda path: _ProcEntries() if path == "/proc" else None, + ) + monkeypatch.setattr(supervisor_module, "open", open_stat, raising=False) + + assert supervisor_module._linux_process_group_members(4312) == {100, 102} + + +@pytest.mark.parametrize( + ("stat_result", "failure"), + [ + ("malformed", None), + ("123 (process) S parent invalid-pgid 0", None), + (None, OSError("stat-read-canary")), + ], +) +@_POSIX_ONLY +def test_linux_group_enumeration_fails_closed_on_untrusted_proc_data( + monkeypatch: pytest.MonkeyPatch, + stat_result: str | None, + failure: OSError | None, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _ProcEntries: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def __iter__(self): + return iter([SimpleNamespace(name="123")]) + + class _StatFile: + def __enter__(self): + return self + + def __exit__(self, *_args) -> None: + return None + + def read(self) -> str: + if failure is not None: + raise failure + assert stat_result is not None + return stat_result + + monkeypatch.setattr(supervisor_module.os, "scandir", lambda _path: _ProcEntries()) + monkeypatch.setattr( + supervisor_module, + "open", + lambda *_args, **_kwargs: _StatFile(), + raising=False, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._linux_process_group_members(4312) + + assert "canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_linux_group_enumeration_wraps_proc_scan_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr( + supervisor_module.os, + "scandir", + lambda _path: (_ for _ in ()).throw(OSError("proc-scan-canary")), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._linux_process_group_members(4312) + + assert "proc-scan-canary" not in str(captured.value) + + +@_POSIX_ONLY +def test_darwin_group_enumeration_accepts_zero_capacity_as_empty( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _EmptyGroup: + def __call__(self, _pgid: int, _buffer, _size: int) -> int: + supervisor_module.ctypes.set_errno(0) + return 0 + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda *_args, **_kwargs: SimpleNamespace(proc_listpgrppids=_EmptyGroup()), + ) + + assert supervisor_module._darwin_process_group_members(4312) == set() + + +@_POSIX_ONLY +def test_darwin_group_enumeration_fails_closed_when_members_keep_growing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + class _GrowingGroup: + def __call__(self, _pgid: int, _buffer, size: int) -> int: + supervisor_module.ctypes.set_errno(0) + if size == 0: + return 16 + return size // supervisor_module.ctypes.sizeof( + supervisor_module.ctypes.c_int + ) + + monkeypatch.setattr( + supervisor_module.ctypes, + "CDLL", + lambda *_args, **_kwargs: SimpleNamespace(proc_listpgrppids=_GrowingGroup()), + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._darwin_process_group_members(4312) + + +@pytest.mark.parametrize( + "failure", [OSError("libproc-load-canary"), KeyboardInterrupt()] +) +@_POSIX_ONLY +def test_darwin_group_enumeration_preserves_control_flow_and_redacts_native_errors( + monkeypatch: pytest.MonkeyPatch, + failure: BaseException, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + if isinstance(failure, KeyboardInterrupt): + + class _InterruptedGroup: + def __call__(self, _pgid: int, _buffer, _size: int) -> int: + raise failure + + def loader(*_args, **_kwargs): + return SimpleNamespace(proc_listpgrppids=_InterruptedGroup()) + + expected = KeyboardInterrupt + else: + + def loader(*_args, **_kwargs): + raise failure + + expected = supervisor_module.ProcessSupervisionError + monkeypatch.setattr(supervisor_module.ctypes, "CDLL", loader) + + with pytest.raises(expected) as captured: + supervisor_module._darwin_process_group_members(4312) + + assert "canary" not in str(captured.value) + + @_POSIX_ONLY def test_posix_waitid_preserves_forwarded_signal( monkeypatch: pytest.MonkeyPatch, @@ -670,6 +940,170 @@ def test_posix_waitid_preserves_forwarded_signal( assert captured.value is forwarded +@pytest.mark.parametrize( + ("wait_result", "expected"), + [ + (SimpleNamespace(si_pid=4312, si_code=1, si_status=27), 27), + (SimpleNamespace(si_pid=4312, si_code=2, si_status=9), -9), + (SimpleNamespace(si_pid=4312, si_code=3, si_status=6), -6), + ], +) +def test_waitid_exit_decoder_preserves_direct_child_status( + wait_result: SimpleNamespace, + expected: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + assert ( + supervisor_module._decode_waitid_exit(wait_result, expected_pid=4312) + == expected + ) + + +@pytest.mark.parametrize( + "wait_result", + [ + None, + SimpleNamespace(si_pid=9999, si_code=1, si_status=0), + SimpleNamespace(si_pid=4312, si_code=99, si_status=0), + ], +) +def test_waitid_exit_decoder_rejects_ambiguous_child_status( + wait_result: SimpleNamespace | None, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._decode_waitid_exit(wait_result, expected_pid=4312) + + +@_POSIX_ONLY +def test_generic_waitid_adapter_supports_polling_and_signal_exit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + constants = { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + } + calls: list[tuple[int, int, int]] = [] + results = iter( + [ + None, + SimpleNamespace(si_pid=4312, si_code=2, si_status=signal.SIGTERM), + ] + ) + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in constants.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) + + def waitid(id_type: int, pid: int, options: int): + calls.append((id_type, pid, options)) + return next(results) + + monkeypatch.setattr(supervisor_module.os, "waitid", waitid, raising=False) + + assert supervisor_module._waitid_no_reap(4312, nohang=True) is None + assert supervisor_module._waitid_no_reap(4312, nohang=False) == -signal.SIGTERM + assert calls == [ + (1, 4312, 0x01000005), + (1, 4312, 0x01000004), + ] + + +@_POSIX_ONLY +def test_generic_waitid_adapter_redacts_native_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module.sys, "platform", "linux") + for name, value in { + "P_PID": 1, + "WEXITED": 4, + "WNOWAIT": 0x01000000, + "WNOHANG": 1, + "CLD_EXITED": 1, + "CLD_KILLED": 2, + "CLD_DUMPED": 3, + }.items(): + monkeypatch.setattr(supervisor_module.os, name, value, raising=False) + monkeypatch.setattr( + supervisor_module.os, + "waitid", + lambda *_args: (_ for _ in ()).throw(OSError("waitid-canary")), + raising=False, + ) + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + supervisor_module._waitid_no_reap(4312, nohang=False) + + assert "waitid-canary" not in str(captured.value) + + +@pytest.mark.parametrize( + ("platform", "members", "expected"), + [ + ("darwin", {4312, 4313}, True), + ("linux", {4312}, False), + ], +) +@_POSIX_ONLY +def test_process_group_member_routing_uses_platform_enumerator( + monkeypatch: pytest.MonkeyPatch, + platform: str, + members: set[int], + expected: bool, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + calls: list[tuple[str, int]] = [] + monkeypatch.setattr(supervisor_module.sys, "platform", platform) + monkeypatch.setattr( + supervisor_module, + "_darwin_process_group_members", + lambda pgid: calls.append(("darwin", pgid)) or members, + ) + monkeypatch.setattr( + supervisor_module, + "_linux_process_group_members", + lambda pgid: calls.append(("linux", pgid)) or members, + ) + + assert supervisor_module._process_group_has_other_members(4312, 4312) is expected + assert calls == [(platform, 4312)] + + +@pytest.mark.parametrize( + ("pgid", "leader_pid", "platform"), + [ + (0, 4312, "linux"), + (4312, 0, "linux"), + (4312, 9999, "linux"), + (4312, 4312, "aix"), + ], +) +@_POSIX_ONLY +def test_process_group_member_routing_rejects_unsafe_identity_or_platform( + monkeypatch: pytest.MonkeyPatch, + pgid: int, + leader_pid: int, + platform: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + monkeypatch.setattr(supervisor_module.sys, "platform", platform) + + with pytest.raises(supervisor_module.ProcessSupervisionError): + supervisor_module._process_group_has_other_members(pgid, leader_pid) + + @_POSIX_ONLY def test_posix_persistent_observer_failure_still_attempts_final_direct_reap( monkeypatch: pytest.MonkeyPatch, @@ -1305,8 +1739,16 @@ def abort_suspended_process(self, process, thread) -> None: class _FakeAttributeKernel32: - def __init__(self, *, fail_attribute: int | None = None) -> None: + def __init__( + self, + *, + fail_attribute: int | None = None, + zero_size: bool = False, + fail_initialize: bool = False, + ) -> None: self.fail_attribute = fail_attribute + self.zero_size = zero_size + self.fail_initialize = fail_initialize self.initialize_counts: list[int] = [] self.updated_attributes: list[tuple[int, int]] = [] self.deleted: list[object] = [] @@ -1321,9 +1763,9 @@ def InitializeProcThreadAttributeList( self.initialize_counts.append(count) assert flags == 0 if pointer is None: - size._obj.value = 128 + size._obj.value = 0 if self.zero_size else 128 return False - return True + return not self.fail_initialize def UpdateProcThreadAttribute( self, @@ -1347,6 +1789,51 @@ def DeleteProcThreadAttributeList(self, pointer) -> None: self.deleted.append(pointer) +class _ConfiguredWin32Function: + def __init__(self, name: str, kernel) -> None: + self.name = name + self.kernel = kernel + self.argtypes: object = "unset" + self.restype: object = "unset" + + def __call__(self, *args): + self.kernel.calls.append((self.name, args)) + result = self.kernel.results.get(self.name, True) + if isinstance(result, list): + result = result.pop(0) + return result(*args) if callable(result) else result + + +class _ConfiguredWin32Kernel: + def __init__(self) -> None: + self.functions: dict[str, _ConfiguredWin32Function] = {} + self.results: dict[str, object] = {} + self.calls: list[tuple[str, tuple[object, ...]]] = [] + + def __getattr__(self, name: str) -> _ConfiguredWin32Function: + if name in self.functions: + return self.functions[name] + function = _ConfiguredWin32Function(name, self) + self.functions[name] = function + return function + + +def _make_configured_win32_api( + monkeypatch: pytest.MonkeyPatch, +): + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + monkeypatch.setattr( + supervisor_module.ctypes, + "WinDLL", + lambda name, *, use_last_error: kernel32, + raising=False, + ) + api = supervisor_module._Win32Api() + return supervisor_module, kernel32, api + + def test_windows_native_attribute_list_includes_stdio_and_atomic_job() -> None: from agentseek_api import process_supervisor as supervisor_module @@ -1384,6 +1871,423 @@ def test_windows_native_job_attribute_failure_deletes_attribute_list() -> None: assert len(kernel32.deleted) == 1 +@pytest.mark.parametrize( + ("kernel_options", "deleted_count"), + [ + ({"zero_size": True}, 0), + ({"fail_initialize": True}, 0), + ({"fail_attribute": 0x00020002}, 1), + ], +) +def test_windows_native_attribute_setup_failures_stop_before_process_creation( + kernel_options: dict[str, object], + deleted_count: int, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _FakeAttributeKernel32(**kernel_options) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + with pytest.raises(OSError): + native.create_attribute_list((11, 12, 13), (99,)) + + assert len(kernel32.deleted) == deleted_count + + +def test_win32_api_configures_native_ownership_functions_and_key_signatures( + monkeypatch: pytest.MonkeyPatch, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + + expected_functions = { + "CloseHandle", + "CreateFileW", + "CreateJobObjectW", + "CreateProcessW", + "DeleteProcThreadAttributeList", + "DuplicateHandle", + "GenerateConsoleCtrlEvent", + "GetCurrentProcess", + "GetExitCodeProcess", + "GetStdHandle", + "InitializeProcThreadAttributeList", + "QueryInformationJobObject", + "ResumeThread", + "SetInformationJobObject", + "TerminateJobObject", + "TerminateProcess", + "UpdateProcThreadAttribute", + "WaitForSingleObject", + } + + assert set(kernel32.functions) == expected_functions + assert all( + function.argtypes != "unset" and function.restype != "unset" + for function in kernel32.functions.values() + ) + expected_signatures = { + "CreateProcessW": ( + [ + supervisor_module.wintypes.LPCWSTR, + supervisor_module.wintypes.LPWSTR, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.BOOL, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.LPCWSTR, + supervisor_module.ctypes.POINTER(supervisor_module._STARTUPINFOW), + supervisor_module.ctypes.POINTER( + supervisor_module._PROCESS_INFORMATION + ), + ], + supervisor_module.wintypes.BOOL, + ), + "InitializeProcThreadAttributeList": ( + [ + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.POINTER(supervisor_module.ctypes.c_size_t), + ], + supervisor_module.wintypes.BOOL, + ), + "UpdateProcThreadAttribute": ( + [ + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.c_size_t, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_size_t, + supervisor_module.ctypes.c_void_p, + supervisor_module.ctypes.c_void_p, + ], + supervisor_module.wintypes.BOOL, + ), + "QueryInformationJobObject": ( + [ + supervisor_module.wintypes.HANDLE, + supervisor_module.ctypes.c_int, + supervisor_module.ctypes.c_void_p, + supervisor_module.wintypes.DWORD, + supervisor_module.ctypes.POINTER(supervisor_module.wintypes.DWORD), + ], + supervisor_module.wintypes.BOOL, + ), + } + for name, (argtypes, restype) in expected_signatures.items(): + assert kernel32.functions[name].argtypes == argtypes + assert kernel32.functions[name].restype is restype + assert api._process_launcher._native._kernel32 is kernel32 + + +def test_win32_api_performs_job_wait_and_exit_operations( + monkeypatch: pytest.MonkeyPatch, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + observed_limit_flags: list[int] = [] + query_results = [1, 0] + + kernel32.results.update( + { + "CreateJobObjectW": 41, + "SetInformationJobObject": lambda _job, _kind, information, _size: ( + observed_limit_flags.append( + information._obj.BasicLimitInformation.LimitFlags + ) + or True + ), + "ResumeThread": 1, + "TerminateJobObject": True, + "WaitForSingleObject": [ + supervisor_module._Win32Api._WAIT_TIMEOUT, + supervisor_module._Win32Api._WAIT_OBJECT_0, + ], + "GetExitCodeProcess": lambda _process, result: ( + setattr(result._obj, "value", 73) or True + ), + "QueryInformationJobObject": lambda _job, _kind, information, _size, _used: ( + setattr(information._obj, "ActiveProcesses", query_results.pop(0)) + or True + ), + "GenerateConsoleCtrlEvent": True, + "CloseHandle": True, + } + ) + launcher_calls: list[tuple[object, ...]] = [] + api._process_launcher = SimpleNamespace( + create=lambda command, *, env, cwd, job: ( + launcher_calls.append((command, env, cwd, job)) or ("process", "thread", 55) + ) + ) + + job = api.create_job() + api.set_kill_on_close(job) + assert api.create_suspended_process( + ["python", "child.py"], + env={"A": "1"}, + cwd="C:\\runtime", + job=job, + ) == ("process", "thread", 55) + api.resume_thread("thread") + api.terminate_job(job) + assert api.wait_process("process", 0.0001) is False + assert api.wait_process("process", None) is True + assert api.process_exit_code("process") == 73 + assert api.wait_for_job_empty(job, 0.0) is False + assert api.wait_for_job_empty(job, 0.0) is True + api.send_ctrl_break(55) + api.close_handle("process") + api.close_handle(None) + + assert observed_limit_flags == [0x00002000] + assert launcher_calls == [(["python", "child.py"], {"A": "1"}, "C:\\runtime", 41)] + wait_calls = [ + args for name, args in kernel32.calls if name == "WaitForSingleObject" + ] + assert [args[1] for args in wait_calls] == [1, 0xFFFFFFFF] + assert [ + args for name, args in kernel32.calls if name == "GenerateConsoleCtrlEvent" + ] == [(1, 55)] + + +@pytest.mark.parametrize( + "operation", + [ + "create-job", + "set-kill-on-close", + "resume-thread", + "terminate-job", + "wait-process", + "exit-code", + "query-job", + "ctrl-break", + "close-handle", + ], +) +def test_win32_api_native_failures_raise_os_error( + monkeypatch: pytest.MonkeyPatch, + operation: str, +) -> None: + supervisor_module, kernel32, api = _make_configured_win32_api(monkeypatch) + monkeypatch.setattr( + supervisor_module.ctypes, + "get_last_error", + lambda: 5, + raising=False, + ) + actions = { + "create-job": ("CreateJobObjectW", 0, lambda: api.create_job()), + "set-kill-on-close": ( + "SetInformationJobObject", + False, + lambda: api.set_kill_on_close(41), + ), + "resume-thread": ("ResumeThread", 2, lambda: api.resume_thread(42)), + "terminate-job": ( + "TerminateJobObject", + False, + lambda: api.terminate_job(41), + ), + "wait-process": ( + "WaitForSingleObject", + 0xFFFFFFFF, + lambda: api.wait_process(43, 0.0), + ), + "exit-code": ( + "GetExitCodeProcess", + False, + lambda: api.process_exit_code(43), + ), + "query-job": ( + "QueryInformationJobObject", + False, + lambda: api.wait_for_job_empty(41, 0.0), + ), + "ctrl-break": ( + "GenerateConsoleCtrlEvent", + False, + lambda: api.send_ctrl_break(55), + ), + "close-handle": ("CloseHandle", False, lambda: api.close_handle(43)), + } + function_name, result, action = actions[operation] + kernel32.results[function_name] = result + + with pytest.raises(OSError): + action() + + +def test_windows_native_stdio_and_process_creation_preserve_explicit_inputs( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + create_file_calls: list[tuple[object, ...]] = [] + duplicate_calls: list[tuple[object, ...]] = [] + process_call: dict[str, object] = {} + standard_handles = iter([0, 77]) + + def create_file(*args): + create_file_calls.append(args) + return 88 + + def duplicate_handle(*args): + duplicate_calls.append(args) + args[3]._obj.value = 99 + return True + + def create_process( + _application, + command_line, + _process_attributes, + _thread_attributes, + inherit_handles, + creation_flags, + environment, + cwd, + startup_pointer, + process_information, + ) -> bool: + startup = supervisor_module.ctypes.cast( + startup_pointer, + supervisor_module.ctypes.POINTER(supervisor_module._STARTUPINFOEXW), + ).contents + environment_text = environment[:] + process_call.update( + command=command_line.value, + environment_entries=environment_text.rstrip("\0").split("\0"), + environment_terminated=environment_text.endswith("\0\0"), + cwd=cwd, + inherit_handles=inherit_handles, + creation_flags=creation_flags, + stdio=( + startup.StartupInfo.hStdInput, + startup.StartupInfo.hStdOutput, + startup.StartupInfo.hStdError, + ), + attribute_pointer=startup.lpAttributeList, + ) + process_information._obj.hProcess = 501 + process_information._obj.hThread = 502 + process_information._obj.dwProcessId = 503 + return True + + kernel32.results.update( + { + "GetStdHandle": lambda _stream: next(standard_handles), + "CreateFileW": create_file, + "GetCurrentProcess": 17, + "DuplicateHandle": duplicate_handle, + "CreateProcessW": create_process, + "CloseHandle": True, + "DeleteProcThreadAttributeList": None, + } + ) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + + assert native.get_standard_handle(-10) is None + assert native.get_standard_handle(-11) == 77 + assert native.open_null_handle(-10) == 88 + assert native.open_null_handle(-11) == 88 + assert native.duplicate_inheritable_handle(88) == 99 + attribute_list = supervisor_module._Win32AttributeList( + buffer=object(), + pointer=1234, + handle_array=(11, 12, 13), + job_array=(41,), + ) + + assert native.create_suspended_process( + ["python", "child canary.py"], + env={"z": "last", "A": "first"}, + cwd="C:\\runtime", + standard_handles=(11, 12, 13), + attribute_list=attribute_list, + ) == (501, 502, 503) + native.delete_handle_list(attribute_list) + native.close_handle(501) + native.close_handle(None) + + assert create_file_calls[0][1] == 0x80000000 + assert create_file_calls[1][1] == 0x40000000 + assert duplicate_calls[0][0:3] == (17, 88, 17) + assert process_call == { + "command": 'python "child canary.py"', + "environment_entries": ["A=first", "z=last"], + "environment_terminated": True, + "cwd": "C:\\runtime", + "inherit_handles": True, + "creation_flags": 0x00080604, + "stdio": (11, 12, 13), + "attribute_pointer": 1234, + } + + +@pytest.mark.parametrize( + ("function_name", "native_call"), + [ + ("CreateFileW", "open-null"), + ("DuplicateHandle", "duplicate"), + ("CreateProcessW", "create-process"), + ("CloseHandle", "close"), + ], +) +def test_windows_native_launch_failures_remain_internal_os_errors( + monkeypatch: pytest.MonkeyPatch, + function_name: str, + native_call: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + kernel32 = _ConfiguredWin32Kernel() + kernel32.results.update( + { + "CreateFileW": supervisor_module.ctypes.c_void_p(-1).value, + "GetCurrentProcess": 17, + "DuplicateHandle": False, + "CreateProcessW": False, + "CloseHandle": False, + } + ) + monkeypatch.setattr( + supervisor_module.ctypes, + "get_last_error", + lambda: 5, + raising=False, + ) + native = supervisor_module._CtypesWindowsLaunchNative(kernel32) + attribute_list = supervisor_module._Win32AttributeList( + buffer=object(), + pointer=1234, + handle_array=(11, 12, 13), + job_array=(41,), + ) + calls = { + "open-null": lambda: native.open_null_handle(-10), + "duplicate": lambda: native.duplicate_inheritable_handle(88), + "create-process": lambda: native.create_suspended_process( + ["child"], + env={}, + cwd=None, + standard_handles=(11, 12, 13), + attribute_list=attribute_list, + ), + "close": lambda: native.close_handle(501), + } + + with pytest.raises(OSError): + calls[native_call]() + + expected_calls = ( + ["GetCurrentProcess", "DuplicateHandle"] + if native_call == "duplicate" + else [function_name] + ) + assert [name for name, _args in kernel32.calls] == expected_calls + + def test_windows_child_has_no_unassigned_post_creation_window() -> None: from agentseek_api import process_supervisor as supervisor_module @@ -1797,3 +2701,122 @@ def test_windows_native_cleanup_failure_still_closes_every_handle() -> None: names = [name for name, _value in api.events] assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +def test_windows_sigterm_and_forced_cleanup_wait_for_job_and_direct_process() -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + job_empty_results=[True, True], + wait_process_results=[True, True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + + child.forward_signal(signal.SIGTERM) + child.forward_and_reap(signal.SIGTERM, timeout=5.0) + child.terminate_and_reap(timeout=5.0) + child.close() + + names = [name for name, _value in api.events] + assert names.count("terminate-job") == 3 + assert names.count("wait-job-empty") == 2 + assert names.count("wait-process") == 2 + assert names[-2:] == ["close-process-handle", "close-job-handle"] + + +@pytest.mark.parametrize( + "failure_point", + [ + "forward-job-timeout", + "forward-process-timeout", + "terminate-job-timeout", + "terminate-process-timeout", + "remaining-tree-timeout", + ], +) +def test_windows_cleanup_timeouts_fail_closed_after_bounded_wait( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + if failure_point == "remaining-tree-timeout": + job_empty_results = [False, False] + else: + job_empty_results = [failure_point.endswith("process-timeout")] + wait_process_results = [False] + api = _FakeWin32Api( + job_empty_results=job_empty_results, + wait_process_results=wait_process_results, + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + operations = { + "forward-job-timeout": lambda: child.forward_and_reap( + signal.SIGTERM, + timeout=0.0, + ), + "forward-process-timeout": lambda: child.forward_and_reap( + signal.SIGTERM, + timeout=0.0, + ), + "terminate-job-timeout": lambda: child.terminate_and_reap(timeout=0.0), + "terminate-process-timeout": lambda: child.terminate_and_reap(timeout=0.0), + "remaining-tree-timeout": lambda: child.close_remaining_tree(timeout=0.0), + } + + with pytest.raises(supervisor_module.ProcessSupervisionError): + operations[failure_point]() + + child.close() + job_waits = [value for name, value in api.events if name == "wait-job-empty"] + wait_timeouts = [value[1] for name, value in api.events if name == "wait-process"] + assert len(job_waits) == (2 if failure_point == "remaining-tree-timeout" else 1) + if failure_point.endswith("process-timeout"): + assert wait_timeouts == [0.0] + else: + assert wait_timeouts == [] + + +@pytest.mark.parametrize("failure_point", ["wait", "forward", "close"]) +def test_windows_child_native_failures_are_value_free_and_attempt_handle_cleanup( + failure_point: str, +) -> None: + from agentseek_api import process_supervisor as supervisor_module + + api = _FakeWin32Api( + fail_at={ + "wait": "wait-process", + "forward": "terminate-job", + "close": "close-process-handle", + }[failure_point], + wait_process_results=[True], + ) + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + actions = { + "wait": child.wait, + "forward": lambda: child.forward_signal(signal.SIGTERM), + "close": child.close, + } + + with pytest.raises(supervisor_module.ProcessSupervisionError) as captured: + actions[failure_point]() + + if failure_point != "close": + child.close() + names = [name for name, _value in api.events] + assert "setup-canary" not in str(captured.value) + assert names[-2:] == ["close-process-handle", "close-job-handle"] diff --git a/tests/unit/test_runtime_entrypoint.py b/tests/unit/test_runtime_entrypoint.py new file mode 100644 index 0000000..58d5c09 --- /dev/null +++ b/tests/unit/test_runtime_entrypoint.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import runpy +import sys + +import pytest +from pydantic import ValidationError + + +@pytest.mark.parametrize("arguments", [[], ["unknown-target"]]) +def test_runtime_entrypoint_rejects_unknown_internal_targets( + arguments: list[str], + capsys: pytest.CaptureFixture[str], +) -> None: + from agentseek_api.runtime_entrypoint import main + + assert main(arguments) == 2 + assert capsys.readouterr().err == "Invalid internal runtime target.\n" + + +def test_runtime_entrypoint_dispatches_target_with_isolated_argv( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + original_argv = ["parent", "parent-canary"] + observed: list[tuple[str, str, list[str]]] = [] + monkeypatch.setattr(sys, "argv", original_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, + "import_module", + lambda name: observed.append(("import", name, list(sys.argv))), + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda name, *, run_name: observed.append((name, run_name, list(sys.argv))), + ) + + assert runtime_entrypoint.main(["uvicorn", "--", "app:api", "--port", "8080"]) == 0 + + assert observed == [ + ( + "import", + "agentseek_api.settings", + ["uvicorn.__main__", "app:api", "--port", "8080"], + ), + ( + "uvicorn.__main__", + "__main__", + ["uvicorn.__main__", "app:api", "--port", "8080"], + ), + ] + assert sys.argv is original_argv + + +def test_runtime_entrypoint_uses_process_argv_when_arguments_are_omitted( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + observed: list[tuple[str, list[str]]] = [] + monkeypatch.setattr(sys, "argv", ["entrypoint", "scheduler", "--once"]) + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda name, *, run_name: observed.append((name, list(sys.argv))), + ) + + assert runtime_entrypoint.main() == 0 + assert observed == [ + ("agentseek_api.scheduler", ["agentseek_api.scheduler", "--once"]) + ] + assert sys.argv == ["entrypoint", "scheduler", "--once"] + + +def test_runtime_entrypoint_redacts_settings_validation_input( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + from agentseek_api import runtime_entrypoint + from agentseek_api.settings import Settings + + with pytest.raises(ValidationError) as captured: + Settings.model_validate({"PORT": "settings-input-canary"}) + + run_called = False + + def fail_settings_import(_name: str) -> None: + raise captured.value + + def record_run(*_args, **_kwargs) -> None: + nonlocal run_called + run_called = True + + parent_argv = ["parent"] + monkeypatch.setattr(sys, "argv", parent_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, + "import_module", + fail_settings_import, + ) + monkeypatch.setattr(runtime_entrypoint.runpy, "run_module", record_run) + + assert runtime_entrypoint.main(["worker"]) == 2 + + stderr = capsys.readouterr().err + assert stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert "settings-input-canary" not in stderr + assert run_called is False + assert sys.argv is parent_argv + + +@pytest.mark.parametrize( + ("system_exit_code", "expected"), + [(37, 37), (None, 0), ("non-integer-canary", 1)], +) +def test_runtime_entrypoint_normalizes_target_system_exit( + system_exit_code: object, + expected: int, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda *_args, **_kwargs: (_ for _ in ()).throw(SystemExit(system_exit_code)), + ) + + assert runtime_entrypoint.main(["worker"]) == expected + + +def test_runtime_entrypoint_restores_argv_when_target_crashes( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from agentseek_api import runtime_entrypoint + + parent_argv = ["parent", "argv-canary"] + monkeypatch.setattr(sys, "argv", parent_argv) + monkeypatch.setattr( + runtime_entrypoint.importlib, "import_module", lambda _name: None + ) + monkeypatch.setattr( + runtime_entrypoint.runpy, + "run_module", + lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("target-canary")), + ) + + with pytest.raises(RuntimeError, match="target-canary"): + runtime_entrypoint.main(["scheduler"]) + + assert sys.argv is parent_argv + + +def test_runtime_entrypoint_module_execution_uses_cli_exit_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(sys, "argv", ["runtime-entrypoint", "invalid-target"]) + monkeypatch.delitem(sys.modules, "agentseek_api.runtime_entrypoint", raising=False) + + with pytest.raises(SystemExit) as captured: + runpy.run_module("agentseek_api.runtime_entrypoint", run_name="__main__") + + assert captured.value.code == 2 From 5c2867d177261ddbe8dad5a1f6a15abecb240888 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 20:19:18 +0800 Subject: [PATCH 24/28] style: format new runtime tests --- .../runtime_settings_probe/sitecustomize.py | 5 ++++- tests/unit/test_runtime_environment.py | 14 ++++---------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/tests/fixtures/runtime_settings_probe/sitecustomize.py b/tests/fixtures/runtime_settings_probe/sitecustomize.py index e830082..394fc10 100644 --- a/tests/fixtures/runtime_settings_probe/sitecustomize.py +++ b/tests/fixtures/runtime_settings_probe/sitecustomize.py @@ -22,9 +22,12 @@ termination_probe_path = os.environ.get("AGENTSEEK_TERMINATION_PROBE_PATH") probe_path = os.environ.get("AGENTSEEK_SETTINGS_PROBE_PATH") if termination_probe_path: + def _block_runtime_role(awaitable) -> int: awaitable.close() - termination_fixture = Path(__file__).resolve().parents[1] / "termination_tree.py" + termination_fixture = ( + Path(__file__).resolve().parents[1] / "termination_tree.py" + ) grandchild = subprocess.Popen( [sys.executable, str(termination_fixture), "--grandchild"] ) diff --git a/tests/unit/test_runtime_environment.py b/tests/unit/test_runtime_environment.py index 2d21686..d4d1286 100644 --- a/tests/unit/test_runtime_environment.py +++ b/tests/unit/test_runtime_environment.py @@ -303,8 +303,7 @@ def test_each_dotenv_file_uses_an_independent_interpolation_context( config_env = tmp_path / "config.env" config_env.write_text( - "ORIGIN=https://config.example\n" - "CONFIG_RESULT=${ORIGIN}/v1\n", + "ORIGIN=https://config.example\nCONFIG_RESULT=${ORIGIN}/v1\n", encoding="utf-8", ) config_path = _write_config(tmp_path, env="./config.env") @@ -359,8 +358,7 @@ def test_inherited_override_does_not_recompute_earlier_file_value( config_env = tmp_path / "config.env" config_env.write_text( - "ORIGIN=https://config.example\n" - "BASE_URL=${ORIGIN}/v1\n", + "ORIGIN=https://config.example\nBASE_URL=${ORIGIN}/v1\n", encoding="utf-8", ) config_path = _write_config(tmp_path, env="./config.env") @@ -519,16 +517,12 @@ def test_shared_lifecycle_dotenv_mutation_cannot_replace_inherited_present_value shared_env = tmp_path / ".env" shared_env.write_text( - "PRESENT=initial\n" - "EMPTY=\n" - "CHILD_ONLY=initial\n", + "PRESENT=initial\nEMPTY=\nCHILD_ONLY=initial\n", encoding="utf-8", ) snapshot = {"PRESENT": "initial", "EMPTY": ""} shared_env.write_text( - "PRESENT=mutated\n" - "EMPTY=mutated\n" - "CHILD_ONLY=added-later\n", + "PRESENT=mutated\nEMPTY=mutated\nCHILD_ONLY=added-later\n", encoding="utf-8", ) config_path = _write_config(tmp_path, env="./.env") From 28faa2f4495a51d78cb8e7055263e70b447e3085 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 20:37:41 +0800 Subject: [PATCH 25/28] ci: isolate host runtime process tests --- .github/workflows/ci.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0991be6..66e8425 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -82,14 +82,6 @@ jobs: 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" - - name: Sync embedded SeekDB extra for serve smoke - if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') - run: uv sync --dev --extra embedded - - - name: CLI serve smoke boots a live API - if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') - run: uv run python scripts/test_cli_serve_smoke.py - - name: CLI config, host environment, and process tests run: >- uv run pytest @@ -102,6 +94,14 @@ jobs: tests/integration/test_cli_runtime_processes.py -q + - name: Sync embedded SeekDB extra for serve smoke + if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') + run: uv sync --dev --extra embedded + + - name: CLI serve smoke boots a live API + if: runner.os == 'Linux' || (runner.os == 'macOS' && runner.arch == 'ARM64') + run: uv run python scripts/test_cli_serve_smoke.py + - name: Minimum direct dependency compatibility if: runner.os == 'Linux' run: uv run python scripts/test_minimum_cli_dependencies.py From 6f4d5abd5c4cf85b6d0f6532ad59adb12cdc9981 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 20:41:11 +0800 Subject: [PATCH 26/28] test: make runtime process proofs platform-specific --- .../integration/test_cli_runtime_processes.py | 76 ++++++++++++------- tests/unit/test_process_supervisor.py | 38 ++++++++++ 2 files changed, 87 insertions(+), 27 deletions(-) diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index e721148..316e405 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -315,24 +315,15 @@ def test_dockerfile_rendering_ignores_invalid_runtime_settings( ) def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( tmp_path: Path, - monkeypatch: pytest.MonkeyPatch, role: str, invalid_field: str, invalid_value: str, error_type: str, ) -> None: - monkeypatch.setenv( - invalid_field, - "2024" if invalid_field == "PORT" else "10", - ) - validation_child_pid_path = tmp_path / f"{role}-validation-child.pid" config_path = _write_runtime_config( tmp_path, f"invalid-{role}", - { - invalid_field: invalid_value, - VALIDATION_CHILD_PID_PATH_ENV: str(validation_child_pid_path), - }, + {invalid_field: invalid_value}, ) arguments = [ "-m", @@ -344,22 +335,11 @@ def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( if role == "dev": arguments.append("--no-reload") - observed_pid: int | None = None - child_alive_after_cli: bool | None = None - try: - result = _run_python( - *arguments, - cwd=tmp_path, - extra_env={"PYTHONPATH": _probe_pythonpath()}, - removed_env=(invalid_field, VALIDATION_CHILD_PID_PATH_ENV), - ) - observed_pid = _read_observed_pid(validation_child_pid_path) - child_alive_after_cli = _pid_is_alive(observed_pid) - finally: - if observed_pid is None and validation_child_pid_path.exists(): - observed_pid = int(validation_child_pid_path.read_text(encoding="utf-8")) - if observed_pid is not None: - _terminate_observed_pid(observed_pid) + result = _run_python( + *arguments, + cwd=tmp_path, + removed_env=(invalid_field, VALIDATION_CHILD_PID_PATH_ENV), + ) assert result.returncode == 2 assert result.stderr == ( @@ -369,7 +349,49 @@ def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( assert "ValidationError" not in result.stderr assert "input_value" not in result.stderr assert "Traceback" not in result.stderr - assert child_alive_after_cli is False + + +def test_invalid_uvicorn_runtime_setting_is_redacted_and_process_exits( + tmp_path: Path, +) -> None: + invalid_value = "invalid-port-canary" + environment = dict(os.environ) + environment["PORT"] = invalid_value + environment.pop("PYTHONPATH", None) + environment.pop(VALIDATION_CHILD_PID_PATH_ENV, None) + process = subprocess.Popen( + [ + sys.executable, + "-m", + "agentseek_api.runtime_entrypoint", + "uvicorn", + "--", + "agentseek_api.main:app", + "--host", + "127.0.0.1", + "--port", + "2024", + ], + cwd=tmp_path, + env=environment, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ) + runtime_pid = process.pid + try: + stdout, stderr = process.communicate(timeout=20) + assert _pid_is_alive(runtime_pid) is False + finally: + _stop_test_process(process) + + assert process.returncode == 2 + assert stdout == "" + assert stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert invalid_value not in stderr + assert "ValidationError" not in stderr + assert "input_value" not in stderr + assert "Traceback" not in stderr def test_invalid_internal_runtime_target_returns_fixed_error( diff --git a/tests/unit/test_process_supervisor.py b/tests/unit/test_process_supervisor.py index bab9727..3b01bfc 100644 --- a/tests/unit/test_process_supervisor.py +++ b/tests/unit/test_process_supervisor.py @@ -340,6 +340,7 @@ def test_failed_guard_entry_retries_exact_mask_restore( assert harness.handlers == harness.previous +@_POSIX_ONLY def test_signal_arriving_during_popen_is_pending_until_attachment( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -1640,6 +1641,43 @@ def close_handle(self, handle) -> None: self._record(f"close-{handle}", handle) +def test_windows_signal_during_process_creation_is_pending_until_attachment( + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness = _SignalHarness() + supervisor_module = _install_signal_harness( + monkeypatch, + harness, + is_windows=True, + ) + api = _FakeWin32Api() + create_suspended_process = api.create_suspended_process + + def create_with_sigterm(command, *, env, cwd, job=None): + result = create_suspended_process(command, env=env, cwd=cwd, job=job) + harness.handlers[signal.SIGTERM](signal.SIGTERM, None) + return result + + monkeypatch.setattr(api, "create_suspended_process", create_with_sigterm) + child = None + try: + with supervisor_module.ForwardingSignalGuard() as guard: + child = supervisor_module._WindowsChild.start( + ["child"], + env={}, + cwd=None, + api=api, + ) + assert "terminate-job" not in [name for name, _value in api.events] + with pytest.raises(supervisor_module._ForwardedSignal) as captured: + guard.attach(child) + finally: + if child is not None: + child.close() + + assert captured.value.signum == signal.SIGTERM + + class _FakeWindowsLaunchNative: def __init__( self, From 56e06f930fbdfa17b154ced7db72bde668b6954b Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 21:16:19 +0800 Subject: [PATCH 27/28] fix: render CLI banner on legacy consoles --- src/agentseek_api/cli.py | 56 +++++- .../integration/test_cli_runtime_processes.py | 39 ++++ tests/unit/test_cli.py | 178 ++++++++++++++++++ 3 files changed, 267 insertions(+), 6 deletions(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index 5b30101..e800333 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -37,6 +37,15 @@ " AgentSeek v{version}\n" ) +AGENTSEEK_ONBOARD_BANNER_ASCII = ( + "\n" + " Welcome to\n" + "\n" + "========================\n" + " AgentSeek v{version}\n" + "========================\n" +) + __all__ = [ "CliError", "build_container_env", @@ -88,6 +97,31 @@ class DevServerUrls: studio_url: str +def _write_banner( + stdout: TextIO, + *, + unicode_text: str, + ascii_text: str, +) -> None: + text = unicode_text + encoding = getattr(stdout, "encoding", None) + if isinstance(encoding, str) and encoding: + try: + unicode_text.encode(encoding, errors="strict") + except (UnicodeEncodeError, LookupError): + text = ascii_text + stdout.write(text) + stdout.flush() + + +def _write_onboard_banner(stdout: TextIO) -> None: + _write_banner( + stdout, + unicode_text=AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n", + ascii_text=AGENTSEEK_ONBOARD_BANNER_ASCII.format(version=__version__) + "\n", + ) + + def _resolve_path(path_text: str, *, cwd: Path) -> Path: path = Path(path_text).expanduser() if not path.is_absolute(): @@ -416,6 +450,15 @@ def _render_dev_ready_banner(urls: DevServerUrls) -> str: ) +def _render_ascii_dev_ready_banner(urls: DevServerUrls) -> str: + return ( + f"- API: {urls.api_url}\n" + f"- Docs: {urls.docs_url}\n" + f"- Studio UI: {urls.studio_url}\n" + "\n\n" + ) + + def _wait_for_dev_server_ready( api_url: str, *, @@ -472,8 +515,11 @@ def _terminate_child(_signum, _frame) -> None: continue try: - stdout.write(_render_dev_ready_banner(urls)) - stdout.flush() + _write_banner( + stdout, + unicode_text=_render_dev_ready_banner(urls), + ascii_text=_render_ascii_dev_ready_banner(urls), + ) wait_for_ready(urls.api_url, process=process, sleep=sleep) if open_browser: if browser_opener is None: @@ -519,8 +565,7 @@ def _execute_dev_command( cwd: Path, stdout: TextIO, ) -> int: - stdout.write(AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n") - stdout.flush() + _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) @@ -1023,8 +1068,7 @@ def run_namespace( ) return _execute_dev_command(args, runner=run, cwd=workdir, stdout=out) if command == "serve": - out.write(AGENTSEEK_ONBOARD_BANNER.format(version=__version__) + "\n") - out.flush() + _write_onboard_banner(out) args.reload = False return _execute_runtime_command(args, runner=run, cwd=workdir) if command == "worker": diff --git a/tests/integration/test_cli_runtime_processes.py b/tests/integration/test_cli_runtime_processes.py index 316e405..568bcc4 100644 --- a/tests/integration/test_cli_runtime_processes.py +++ b/tests/integration/test_cli_runtime_processes.py @@ -10,6 +10,8 @@ import pytest +from agentseek_api import __version__ + PROBE_SITE_DIR = ( Path(__file__).resolve().parents[1] / "fixtures" / "runtime_settings_probe" @@ -351,6 +353,43 @@ def test_invalid_runtime_setting_is_redacted_and_fresh_child_exits( assert "Traceback" not in result.stderr +@pytest.mark.parametrize("role", ["dev", "serve"]) +def test_invalid_port_reaches_runtime_child_with_cp1252_stdout( + tmp_path: Path, + role: str, +) -> None: + invalid_value = "invalid-port-canary" + config_path = _write_runtime_config( + tmp_path, + f"invalid-cp1252-{role}", + {"PORT": invalid_value}, + ) + arguments = [ + "-m", + "agentseek_api.cli", + role, + "--config", + str(config_path), + ] + if role == "dev": + arguments.append("--no-reload") + + result = _run_python( + *arguments, + cwd=tmp_path, + extra_env={"PYTHONIOENCODING": "cp1252:strict"}, + removed_env=("PORT", VALIDATION_CHILD_PID_PATH_ENV), + ) + + assert result.returncode == 2 + assert result.stderr == "Invalid runtime setting(s): PORT (int_parsing).\n" + assert invalid_value not in result.stderr + assert "UnicodeEncodeError" not in result.stderr + assert "Traceback" not in result.stderr + assert f"AgentSeek v{__version__}" in result.stdout + result.stdout.encode("ascii", errors="strict") + + def test_invalid_uvicorn_runtime_setting_is_redacted_and_process_exits( tmp_path: Path, ) -> None: diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 37f1a62..2e7904d 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -11,6 +11,7 @@ import pytest from pydantic import ValidationError +from agentseek_api import __version__ from agentseek_api.services.langgraph_service import LangGraphService @@ -39,6 +40,52 @@ def __call__(self, command: list[str], *, env: dict[str, str], cwd: str | None = return 0 +class _EncodingTextStream: + def __init__(self, encoding: str) -> None: + self.encoding = encoding + self.writes: list[str] = [] + self.flush_count = 0 + + def write(self, value: str) -> int: + value.encode(self.encoding, errors="strict") + self.writes.append(value) + return len(value) + + def flush(self) -> None: + self.flush_count += 1 + + +class _RecordingTextStream: + def __init__(self, encoding: str) -> None: + self.encoding = encoding + self.writes: list[str] = [] + self.flush_count = 0 + + def write(self, value: str) -> int: + self.writes.append(value) + return len(value) + + def flush(self) -> None: + self.flush_count += 1 + + +class _PartialWriteFailureStream: + encoding = "utf-8" + + def __init__(self) -> None: + self.write_calls = 0 + self.value = "" + self.flush_count = 0 + + def write(self, value: str) -> int: + self.write_calls += 1 + self.value += value[:8] + raise UnicodeEncodeError("utf-8", value, 8, 9, "write-canary") + + def flush(self) -> None: + self.flush_count += 1 + + class _FakeForegroundSupervisor: def __init__( self, @@ -328,6 +375,137 @@ def _write_basic_manifest_config(root: Path) -> Path: return manifest_path +def test_onboard_banner_preserves_unicode_for_stringio(tmp_path: Path) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = io.StringIO() + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.getvalue() == ( + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ) + + +def test_onboard_banner_uses_one_write_and_flush_for_utf8_stream( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("utf-8") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "╔═╗┌─┐┌─┐┌┐┌┌┬┐╔═╗┌─┐┌─┐┬┌─\n" + "╠═╣│ ┬├┤ │││ │ ╚═╗├┤ ├┤ ├┴┐\n" + "╩ ╩└─┘└─┘┘└┘ ┴ ╚═╝└─┘└─┘┴ ┴\n" + "\n" + f" AgentSeek v{__version__}\n" + "\n" + ] + assert stdout.flush_count == 1 + + +@pytest.mark.parametrize("role", ["dev", "serve"]) +def test_onboard_banner_falls_back_before_writing_to_cp1252_stream( + tmp_path: Path, + role: str, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _EncodingTextStream("cp1252") + arguments = [role] + if role == "dev": + arguments.append("--no-reload") + + exit_code = main( + arguments, + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "========================\n" + f" AgentSeek v{__version__}\n" + "========================\n" + "\n" + ] + assert stdout.flush_count == 1 + + +def test_onboard_banner_uses_ascii_fallback_for_unknown_named_encoding( + tmp_path: Path, +) -> None: + from agentseek_api.cli import main + + _write_basic_langgraph_config(tmp_path) + stdout = _RecordingTextStream("unknown-codec-canary") + + exit_code = main( + ["serve"], + runner=_RunCapture(), + stdout=stdout, + cwd=tmp_path, + ) + + assert exit_code == 0 + assert stdout.writes == [ + "\n" + " Welcome to\n" + "\n" + "========================\n" + f" AgentSeek v{__version__}\n" + "========================\n" + "\n" + ] + assert stdout.flush_count == 1 + + +def test_onboard_banner_does_not_retry_or_flush_after_partial_write() -> None: + from agentseek_api import cli as cli_module + + stdout = _PartialWriteFailureStream() + + with pytest.raises(UnicodeEncodeError, match="write-canary"): + cli_module._write_onboard_banner(stdout) + + assert stdout.write_calls == 1 + assert stdout.value == "\n " + assert stdout.flush_count == 0 + + def test_dev_command_prefers_agentseek_json_over_langgraph_json(tmp_path: Path) -> None: from agentseek_api.cli import main From e57d400830d5733bf95e586f8e48d6f6dedabd94 Mon Sep 17 00:00:00 2001 From: Haili Zhang Date: Sun, 16 Aug 2026 21:22:09 +0800 Subject: [PATCH 28/28] fix: normalize legacy console banner fallback --- src/agentseek_api/cli.py | 2 +- tests/unit/test_cli.py | 54 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) diff --git a/src/agentseek_api/cli.py b/src/agentseek_api/cli.py index e800333..60971cf 100644 --- a/src/agentseek_api/cli.py +++ b/src/agentseek_api/cli.py @@ -109,7 +109,7 @@ def _write_banner( try: unicode_text.encode(encoding, errors="strict") except (UnicodeEncodeError, LookupError): - text = ascii_text + text = ascii_text.encode("ascii", errors="replace").decode("ascii") stdout.write(text) stdout.flush() diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 2e7904d..519bf84 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -909,6 +909,60 @@ def terminate(self) -> None: 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( + tmp_path: Path, +) -> None: + from agentseek_api import cli as cli_module + + class FakeProcess: + def __init__(self) -> None: + self.returncode: int | None = None + self.wait_calls = 0 + self.terminate_calls = 0 + + def poll(self) -> int | None: + return self.returncode + + def wait(self) -> int: + self.wait_calls += 1 + self.returncode = 23 + return self.returncode + + def terminate(self) -> None: + self.terminate_calls += 1 + self.returncode = -1 + + process = FakeProcess() + stdout = _EncodingTextStream("cp1252") + + exit_code = cli_module._run_managed_dev_server( + command=["uvicorn", "agentseek_api.main:app"], + env={}, + cwd=tmp_path, + urls=cli_module._resolve_dev_urls( + host="例子", + port=2024, + studio_url="https://例子.test", + ), + stdout=stdout, + process_factory=lambda command, *, env, cwd: process, + wait_for_ready=lambda *_args, **_kwargs: None, + open_browser=False, + sleep=lambda _seconds: None, + ) + + assert exit_code == 23 + assert process.wait_calls == 1 + assert process.terminate_calls == 0 + assert stdout.writes == [ + "- API: http://??:2024\n" + "- Docs: http://??:2024/docs\n" + "- Studio UI: https://??.test/studio/?baseUrl=http://??:2024\n" + "\n\n" + ] + assert stdout.flush_count == 1 + + def test_run_managed_dev_server_honors_no_browser(tmp_path: Path) -> None: from agentseek_api import cli as cli_module