diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index df69b1fa..e989c789 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -25,6 +25,28 @@ jobs: - name: Run lint and type check run: make lint typecheck + minimum-supported-cli: + name: Minimum supported CLI import + runs-on: ubuntu-latest + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.13" + + - name: Run CLI with the declared dependency floor + run: | + set -euo pipefail + export UV_CACHE_DIR="$(mktemp -d)/uv-cache" + uv run --python 3.13 --isolated --no-project \ + --with-editable . \ + --with pydantic-settings==2.0.0 \ + --with python-dotenv==1.0.0 \ + agentseek --help + cross-platform-tests-and-type-check: runs-on: ${{ matrix.os }} strategy: @@ -74,6 +96,31 @@ jobs: - name: Check typing run: make typecheck + agentseek-api-lifecycle-contract: + name: Published agentseek-api lifecycle contract + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Check out + uses: actions/checkout@v4 + + - name: Set up the environment + uses: ./.github/actions/setup-python-env + with: + python-version: "3.12" + + - name: Verify the exact published API floor through agentseek dev + run: | + set -euo pipefail + api_version="$(uv run --frozen python -c 'from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION; print(MINIMUM_AGENTSEEK_API_VERSION)')" + test "${api_version}" = "0.2.2" + export PYTHONPATH= + export UV_CACHE_DIR="${RUNNER_TEMP}/agentseek-api-contract-cache" + uv run --python 3.12 --isolated --no-project \ + --with-editable . \ + --with "agentseek-api==${api_version}" \ + python scripts/check_agentseek_api_lifecycle_contract.py + legacy-template-compatibility: if: ${{ github.event_name == 'push' || startsWith(github.head_ref, 'release/') }} runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index 2ee2640e..3262b356 100644 --- a/.gitignore +++ b/.gitignore @@ -169,6 +169,11 @@ docs/hub.zh.md /.superpowers/ /specs/plans/ +# Local rendered templates and runtime artifacts +/my_*/ +/.cookiecutter-replay/ +/catalog-lock-sync-analysis.md + # mypy .mypy_cache/ .dmypy.json diff --git a/docs/get-started/index.md b/docs/get-started/index.md index 933ed663..374472a4 100644 --- a/docs/get-started/index.md +++ b/docs/get-started/index.md @@ -44,8 +44,11 @@ agentseek task frontend Set the model and provider credentials required by the selected template in `.env` or the environment used to run AgentSeek. -`.env` is used by AgentSeek only for lifecycle environment checks declared by -the template. It is not automatically passed to child processes. +For non-dry-run `agentseek dev`, AgentSeek reads the project `env_file` once, overlays +non-empty launch variables once, and reuses that immutable snapshot for +readiness and every long-running process. Lifecycle defaults are checks only. +One-shot `agentseek task` commands keep their normal launch environment and do +not inherit `env_file`. ## Check and run diff --git a/docs/get-started/index.zh.md b/docs/get-started/index.zh.md index 58e4d162..6d86bad5 100644 --- a/docs/get-started/index.zh.md +++ b/docs/get-started/index.zh.md @@ -41,8 +41,9 @@ agentseek task frontend 在 `.env` 或运行 AgentSeek 的环境里,设置所选模板需要的模型和 provider 凭证。 -AgentSeek 只把 `.env` 用作模板声明的生命周期环境检查来源。 -它不会把 `.env` 自动传给子进程。 +对于非 dry-run 的 `agentseek dev`,AgentSeek 只读取一次项目 `env_file`,只覆盖一次非空启动变量, +并将同一个不可变快照(immutable snapshot)复用于就绪检查和每个长运行进程。生命周期 +默认值仅用于检查。一次性的 `agentseek task` 命令保留其正常启动环境,不继承 `env_file`。 ## 检查并运行 diff --git a/docs/guides/create-template.md b/docs/guides/create-template.md index f40d6eb1..00ec1669 100644 --- a/docs/guides/create-template.md +++ b/docs/guides/create-template.md @@ -97,8 +97,12 @@ adapters. Add provider-specific keys only when the selected SDK requires them. Document how runtime code maps aliases and which value wins. Declare the same required names under `[env.*]` in the lifecycle file. AgentSeek -uses those declarations for readiness checks; it does not inject `.env` into -child processes. +uses those declarations for readiness checks. For non-dry-run `agentseek dev`, +it reads `env_file` once, overlays non-empty launch values once, and reuses one +immutable snapshot for readiness and all long-running child processes. +Lifecycle defaults remain checks only. A dotenv `KEY=` is present and empty, +while bare `KEY` assigns nothing. One-shot `agentseek task` commands keep their +normal launch environment and do not inherit `env_file`. ## 5. Define The Lifecycle @@ -149,6 +153,15 @@ Use `sync` for Python or backend dependencies and `frontend` for a separate frontend dependency tree. Put all long-running local processes under `[processes.*]` so `agentseek dev` owns the documented development stack. +### Released API contract + +Templates that run agentseek-api require `agentseek-api >= 0.2.2` and pin one +exact published version in the generated dependency file. Lifecycle process +commands use direct argv. Shell wrappers, duplicated dotenv loading, and +editable or local API checkouts do not satisfy the release contract. The exact +version pin and catalog digest are delivered in the later template/catalog +stage, not by AgentSeek core. + Servers bind to loopback by default. If remote development is supported, add documented host overrides. A browser frontend must derive the backend host from the browser location or accept an explicit public API URL. diff --git a/docs/guides/create-template.zh.md b/docs/guides/create-template.zh.md index 86fdad2c..c72eadf1 100644 --- a/docs/guides/create-template.zh.md +++ b/docs/guides/create-template.zh.md @@ -21,7 +21,7 @@ sources: ## 前置条件 -- 本地已有独立 catalog checkout,并已完成 `uv sync`。 +- 本地已有独立 catalog 检出副本,并已完成 `uv sync`。 - 已明确生成应用的目标,并找到一个运行时相近的现有模板。 - 已选择唯一的 `type/name` spec。除非同时扩展 CLI 的类型支持,否则复用 `bub`、`deepagents` 或 `langchain`。 @@ -88,7 +88,11 @@ AGENTSEEK_API_BASE= 应用在多个原生 provider adapter 之间切换时,增加 `AGENTSEEK_MODEL_PROVIDER`。只有所选 SDK 确实要求时,才增加 provider 专属密钥。文档必须说明运行时代码如何映射别名,以及冲突时谁优先。 -在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查就绪状态,不会把 `.env` 注入子进程。 +在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查 +就绪状态。对于非 dry-run 的 `agentseek dev`,它只读取一次 `env_file`,只覆盖一次 +非空启动值,并将同一个不可变快照(immutable snapshot)复用于就绪检查和所有长运行 +子进程。生命周期默认值只用于检查;dotenv 中的 `KEY=` 表示存在但为空,裸 `KEY` 不产生 +赋值。一次性的 `agentseek task` 命令保留正常启动环境,不继承 `env_file`。 ## 5. 定义生命周期 @@ -136,6 +140,14 @@ command = ["uv", "sync"] Python 或 backend 依赖统一使用 `sync`,独立 frontend 依赖树使用 `frontend`。所有长时间运行的本地进程都放在 `[processes.*]` 下,让 `agentseek dev` 管理文档中的完整开发环境。 +### 已发布 API 契约 + +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 +已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 +(direct argv)。Shell 包装、重复 dotenv 加载,以及可编辑安装或本地 API 检出副本都不 +满足发布契约。精确版本固定与模板目录摘要在后续模板目录阶段交付,不由 AgentSeek +core 提供。 + Server 默认绑定 loopback。支持远程开发时,增加并说明 host override。浏览器 frontend 必须根据浏览器地址推导 backend host,或接受显式 public API URL。 ## 6. 编写两层 README diff --git a/docs/reference/lifecycle-spec.md b/docs/reference/lifecycle-spec.md index af3d19b0..d56b570c 100644 --- a/docs/reference/lifecycle-spec.md +++ b/docs/reference/lifecycle-spec.md @@ -3,10 +3,16 @@ title: Lifecycle Spec type: reference audience: [A2] runs: no -verified_on: 2026-07-28 +verified_on: 2026-08-17 sources: - src/agentseek/cli/lifecycle/spec.py + - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/dotenv_adapter.py + - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py + - src/agentseek/cli/commands/dev.py + - src/agentseek/cli/commands/doctor.py + - src/agentseek/cli/commands/info.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py - src/agentseek/cli/lifecycle/json_output.py @@ -91,7 +97,7 @@ command = ["npm", "install", "--prefix", "frontend"] | Section | Purpose | | --- | --- | -| `env_file` | Optional project-local env file used only for declared environment checks. It is not injected into child processes. | +| `env_file` | Optional project-local dotenv file resolved once by non-dry-run `agentseek dev` for declared checks and long-running child processes. | | `tools` | Required executables used by the project. | | `paths` | Required local files or directories. | | `env.` | Environment variables AgentSeek should check. Defaults are lower priority than `env_file` and shell variables. | @@ -105,24 +111,48 @@ than `0` and no greater than `300`; `attempts` is a positive integer. ## Environment Checks -AgentSeek checks environment requirements from lifecycle defaults, the optional -`env_file`, and the current process environment: +AgentSeek resolves one immutable snapshot per non-dry-run `agentseek dev` +invocation. It captures the launch environment once, then creates the snapshot +from the project `env_file` and non-empty captured launch environment values: ```text -lifecycle default < env_file < shell environment +lifecycle env_file < non-empty captured launch environment ``` -Only keys declared under `[env.]` and their aliases are read from -`env_file`. Templates do not need to declare every runtime variable a project -may use. AgentSeek does not pass the env file or lifecycle defaults to child -processes. +Bounded python-dotenv resolves physical bindings in order and falls back to the +captured launch environment. In a lifecycle dotenv, `KEY=` is a present empty +assignment, while bare `KEY` assigns nothing. An empty raw launch value is +omitted before the snapshot is created, so a dotenv value can fill it. + +Readiness, the internal preflight, and every long-running child consume the +same snapshot. Lifecycle defaults may satisfy readiness but never enter the +snapshot. Only declared `[env.]` keys and aliases participate in +readiness checks. AgentSeek guarantees only the initial child environment/snapshot, +which contains resolved values, not source paths, provenance, or instructions +to repeat resolution. Compatible child configuration completion may fill absent +keys but must not replace inherited present keys. Arbitrary child code can +mutate its own process environment; the prohibition against +duplicated override-loading is an authoring rule, not an AgentSeek enforcement +claim. `agentseek task` does not inherit lifecycle `env_file`; its behavior is +unchanged. + +Lifecycle processes using the API completion contract require +`agentseek-api >= 0.2.2`. `agentseek dev --dry-run` prints the plan without +reading the lifecycle dotenv. The missing, undecodable, or malformed dotenv +guarantee applies only to non-dry-run `agentseek dev`: it creates no partial +snapshot, starts no child, and returns `exit 2` with a value-free diagnostic; +bare `KEY` remains valid syntax. Standalone `agentseek info` reports dotenv status without +creating a snapshot. Standalone `agentseek doctor --strict` +renders readiness failures, such as a missing dotenv, and returns `exit 1`. ## Lifecycle v1 first-phase scope Version 1 supports required tools, required paths, project environment requirements, HTTP live checks, long-running processes, and one-shot tasks. It does not support optional tool/path checks, TCP checks, process env -overrides, multiple env files, or env interpolation. +overrides, or multiple env files. It adds no lifecycle-schema interpolation +mode: for a configured `env_file`, bounded python-dotenv resolves physical +bindings in order and falls back to the captured launch environment. ## Lifecycle v2 authored fields diff --git a/docs/reference/lifecycle-spec.zh.md b/docs/reference/lifecycle-spec.zh.md index d904d47a..e37c3951 100644 --- a/docs/reference/lifecycle-spec.zh.md +++ b/docs/reference/lifecycle-spec.zh.md @@ -3,10 +3,16 @@ title: 生命周期规范 type: reference audience: [A2] runs: no -verified_on: 2026-07-28 +verified_on: 2026-08-17 sources: - src/agentseek/cli/lifecycle/spec.py + - src/agentseek/cli/lifecycle/environment.py + - src/agentseek/cli/lifecycle/dotenv_adapter.py + - src/agentseek/cli/lifecycle/compatibility.py - src/agentseek/cli/lifecycle/core.py + - src/agentseek/cli/commands/dev.py + - src/agentseek/cli/commands/doctor.py + - src/agentseek/cli/commands/info.py - src/agentseek/cli/lifecycle/authored.py - src/agentseek/cli/lifecycle/normalize.py - src/agentseek/cli/lifecycle/json_output.py @@ -91,7 +97,7 @@ command = ["npm", "install", "--prefix", "frontend"] | 段落 | 作用 | | --- | --- | -| `env_file` | 可选项目本地 env 文件,只用于声明的环境检查。它不会注入子进程。 | +| `env_file` | 可选的项目本地 dotenv 文件,仅由非 dry-run 的 `agentseek dev` 解析一次,用于声明的环境检查和长运行子进程。 | | `tools` | 项目需要的可执行文件。 | | `paths` | 必需的本地文件或目录。 | | `env.` | AgentSeek 应检查的环境变量。默认值优先级低于 `env_file` 和 shell 变量。 | @@ -105,20 +111,40 @@ command = ["npm", "install", "--prefix", "frontend"] ## 环境检查 -AgentSeek 从生命周期默认值、可选 `env_file` 和当前进程环境检查环境需求: +每次非 dry-run 的 `agentseek dev` 调用都会只创建一次不可变快照(immutable snapshot)。 +它会先捕获启动环境,再用项目 `env_file` 与已捕获启动环境中的非空值创建该快照: ```text -lifecycle default < env_file < shell environment +lifecycle env_file < non-empty captured launch environment ``` -只有 `[env.]` 下声明的 key 及其 aliases 会从 `env_file` 读取。 -模板不需要声明项目可能使用的每一个运行时变量。AgentSeek 不会把 env 文件或 -生命周期默认值传给子进程。 +受限的 python-dotenv 会按文件中物理绑定出现的顺序解析, +并在文件内没有值时回退到已捕获的启动环境。在生命周期 dotenv 中,`KEY=` 表示一个 +存在但为空的赋值;裸 `KEY` 不产生赋值。原始启动值为空时,会在创建快照前省略,因此 +dotenv 值可以补上它。 + +就绪检查、内部预检与每个长运行子进程都使用同一个快照。生命周期默认值可以满足 +就绪检查,但绝不会进入快照。只有声明在 `[env.]` 的 key 及其 aliases 会参与 +就绪检查。AgentSeek 只保证初始子进程环境/快照: +其中只有已解析的值,不包含源路径、provenance 或要求再次解析的指令。兼容的子进程配置 +补全可以填入缺失 key,但不得替换继承的已有 key。任意子进程代码 +仍可自行修改其进程环境;禁止重复加载覆盖配置只是模板编写约束,不是 AgentSeek 的 +强制保证。`agentseek task` 不继承生命周期 `env_file`,其行为保持不变。 + +使用 API completion contract 的生命周期进程需要 +`agentseek-api >= 0.2.2`。`agentseek dev --dry-run` 只打印计划,不读取生命周期 +dotenv。缺失、无法解码或 malformed dotenv 的严格保证只适用于非 dry-run 的 +`agentseek dev`:它会在任何子进程启动前返回 `exit 2`,不创建部分快照,且诊断不得 +包含值;裸 `KEY` 仍是有效语法。单独运行 `agentseek info` 仍会报告 dotenv 状态, +不会创建快照。单独严格运行 `agentseek doctor --strict` 会渲染 +就绪失败(例如 dotenv 缺失),并返回 `exit 1`。 ## 生命周期 v1 第一阶段范围 Version 1 支持必需工具、必需路径、项目环境需求、HTTP live 检查、长运行进程和一次性任务。 -它不支持可选 tool/path 检查、TCP 检查、进程级环境覆盖、多个 env 文件或 env 插值。 +它不支持可选 tool/path 检查、TCP 检查、进程级环境覆盖或多个 env 文件。生命周期 schema +不新增独立插值模式:配置的 `env_file` 使用受限的 python-dotenv,按文件中物理绑定出现的 +顺序解析,并回退到已捕获的启动环境。 ## 生命周期 v2 编写字段 diff --git a/docs/reference/template-authoring-contract.md b/docs/reference/template-authoring-contract.md index 2d42d0e8..ff61905a 100644 --- a/docs/reference/template-authoring-contract.md +++ b/docs/reference/template-authoring-contract.md @@ -81,15 +81,28 @@ core repository and exact dependency snapshot recorded by the catalog release; normal template changes must not replace them with the catalog repository or a mutable branch. -Environment resolution for lifecycle checks: +Readiness-only environment resolution: ```text lifecycle default < env_file < shell environment ``` -Lifecycle defaults and `.env` values validate readiness. AgentSeek does not -inject them into child processes. Process commands must load their runtime -environment themselves. +For non-dry-run `agentseek dev`, AgentSeek resolves `env_file` once, overlays non-empty +launch values once, and passes one immutable snapshot to readiness and every +long-running child. Lifecycle defaults validate readiness only and never enter +the child snapshot; `agentseek task` keeps its normal launch environment and +does not inherit lifecycle `env_file`. AgentSeek guarantees only the initial +child environment/snapshot; compatible child configuration completion may fill +absent keys but must not replace inherited present keys. Arbitrary child code can +mutate its own process environment. + +Templates that run agentseek-api require `agentseek-api >= 0.2.2` and pin one +exact published version in the generated dependency file. Lifecycle process +commands use direct argv. Shell wrappers, duplicated dotenv or override +loading, and editable or local API checkouts do not satisfy the release +contract. The duplicated override-loading prohibition is an authoring rule, +not an AgentSeek enforcement claim. The exact version pin and catalog digest +are delivered in the later template/catalog stage, not by AgentSeek core. ## Task Names diff --git a/docs/reference/template-authoring-contract.zh.md b/docs/reference/template-authoring-contract.zh.md index 3ce80f1a..287ca292 100644 --- a/docs/reference/template-authoring-contract.zh.md +++ b/docs/reference/template-authoring-contract.zh.md @@ -73,13 +73,24 @@ sources: `_agentseek_source_ref`。它们必须指向 catalog release 配对的 core 仓库与精确 依赖快照;常规模板修改不能把它们替换为 catalog 仓库或可变分支。 -生命周期检查的环境变量优先级: +仅用于 readiness 的环境变量优先级: ```text lifecycle default < env_file < shell environment ``` -生命周期默认值和 `.env` 只用于检查就绪状态。AgentSeek 不会把它们注入子进程,process command 必须自行加载运行环境。 +对于非 dry-run 的 `agentseek dev`,AgentSeek 只解析一次 `env_file`,只覆盖一次非空启动值,并将一个 +不可变快照(immutable snapshot)传给就绪检查和所有长运行子进程。生命周期默认值只验证 +就绪检查,不会进入子进程快照;`agentseek task` 保留其正常启动环境,不继承生命周期 +`env_file`。AgentSeek 只保证初始子进程环境/快照:兼容的子进程配置补全可以填入缺失 key, +但不得替换继承的已有 key;任意子进程代码仍可自行修改其进程环境。 + +运行 agentseek-api 的模板需要 `agentseek-api >= 0.2.2`,并在生成的依赖文件中固定一个 +已发布的精确版本(exact published version)。生命周期进程命令使用直接参数数组 +(direct argv)。Shell 包装、重复 dotenv 或覆盖加载,以及可编辑安装或本地 API 检出副本 +都不满足发布契约。禁止重复覆盖加载是模板编写约束,不表示 AgentSeek 会对任意子进程 +强制执行。精确版本固定与模板目录摘要在后续模板目录阶段交付,不由 AgentSeek +core 提供。 ## Task 命名 @@ -135,11 +146,11 @@ lifecycle default < env_file < shell environment | 检查 | 命令或依据 | | --- | --- | -| 完整 catalog 契约 | 在独立 catalog checkout 中运行 `make check`。 | +| 完整 catalog 契约 | 在独立 catalog 检出副本中运行 `make check`。 | | 注册表与自包含 | Catalog 测试要求注册表与目录完全一致、只含普通文件/目录,并确保每个模板子树自包含。 | | 默认渲染和生命周期 smoke | Catalog 测试渲染每个注册模板,并用配对 core 快照验证严格 lifecycle v2。 | | 生成项目检查 | 使用 `agentseek create --no-input` 渲染本地模板。 | -| Core 文档 | 本规范变化时,在 AgentSeek core checkout 中运行 `make docs-test`。 | +| Core 文档 | 本规范变化时,在 AgentSeek core 检出副本中运行 `make docs-test`。 | ## 相关页面 diff --git a/pyproject.toml b/pyproject.toml index ef14def4..b0c4c928 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ classifiers = [ dependencies = [ "bub==0.3.9", "cookiecutter>=2.5", + "python-dotenv>=1.0,<1.3", "duty>=1.9", "filelock>=3.20.3", "jinja2>=3.1", diff --git a/scripts/check_agentseek_api_lifecycle_contract.py b/scripts/check_agentseek_api_lifecycle_contract.py new file mode 100644 index 00000000..46c2c123 --- /dev/null +++ b/scripts/check_agentseek_api_lifecycle_contract.py @@ -0,0 +1,259 @@ +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import tempfile +import time +from contextlib import suppress +from importlib.metadata import version +from pathlib import Path + +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION + +_AGENTSEEK_TIMEOUT_SECONDS = 30.0 +_AGENTSEEK_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS = 15.0 +_HELPER_PROCESS_GROUP_GRACE_SECONDS = 1.0 +_FALLBACK_REAP_TIMEOUT_SECONDS = 5.0 +_PROCESS_GROUP_POLL_SECONDS = 0.05 +_POSIX_ONLY_CONTRACT_DIAGNOSTIC = "published agentseek-api lifecycle contract requires POSIX process-group support" + + +def _toml_string(value: str | Path) -> str: + return json.dumps(str(value), ensure_ascii=False) + + +def _require_posix_contract_platform() -> None: + if os.name != "posix": + raise RuntimeError(_POSIX_ONLY_CONTRACT_DIAGNOSTIC) + + +def _write_api_capture_helper(root: Path, output: Path, process_marker: Path) -> Path: + helper = root / "capture_api_environment.py" + changed_dotenv = ( + "DIRECT_SENTINEL=changed-after-snapshot\n" + "DEPENDENT_SENTINEL=changed-after-snapshot\n" + "EXPLICIT_EMPTY=changed-after-snapshot\n" + "CHILD_ONLY=added-after-snapshot\n" + ) + helper.write_text( + "\n".join([ + "from __future__ import annotations", + "import json", + "import os", + "from importlib.metadata import version", + "from pathlib import Path", + "from agentseek_api.cli import main", + f"OUTPUT = Path({_toml_string(output)})", + f"ENV_FILE = Path({_toml_string(root / '.env')})", + f"PROCESS_MARKER = Path({_toml_string(process_marker)})", + "PROCESS_MARKER.write_text(json.dumps({'pid': os.getpid(), 'pgid': os.getpgid(0)}), encoding='utf-8')", + "def capture(command, *, env, cwd=None):", + " OUTPUT.write_text(json.dumps({", + " 'api_version': version('agentseek-api'),", + " 'direct': env['DIRECT_SENTINEL'],", + " 'dependent': env['DEPENDENT_SENTINEL'],", + " 'explicit_empty_present': 'EXPLICIT_EMPTY' in env,", + " 'explicit_empty': env['EXPLICIT_EMPTY'],", + " 'child_only': env['CHILD_ONLY'],", + " 'graphs': env['AGENTSEEK_GRAPHS'],", + " }, sort_keys=True), encoding='utf-8')", + " return 0", + f"ENV_FILE.write_text({_toml_string(changed_dotenv)}, encoding='utf-8')", + "raise SystemExit(main(['dev', '--config', 'langgraph.json', '--no-reload', '--no-browser'], runner=capture, cwd=Path.cwd()))", + ]) + + "\n", + encoding="utf-8", + ) + return helper + + +def _tracked_posix_process_group(process_marker: Path | None) -> int | None: + if os.name == "nt" or process_marker is None: + return None + try: + observed = json.loads(process_marker.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return None + pid = observed.get("pid") if isinstance(observed, dict) else None + pgid = observed.get("pgid") if isinstance(observed, dict) else None + if type(pid) is not int or type(pgid) is not int or pid <= 0 or pid != pgid: + return None + try: + if os.getpgid(pid) != pgid: + return None + except (ProcessLookupError, PermissionError): + return None + return pgid + + +def _process_group_exists(pgid: int) -> bool: + try: + os.killpg(pgid, 0) + except (ProcessLookupError, PermissionError): + return False + return True + + +def _terminate_tracked_posix_process_group( + process_marker: Path | None, + *, + grace_seconds: float, + reap_timeout_seconds: float, +) -> None: + pgid = _tracked_posix_process_group(process_marker) + if pgid is None: + return + with suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGTERM) + deadline = time.monotonic() + grace_seconds + while _process_group_exists(pgid) and time.monotonic() < deadline: + time.sleep(_PROCESS_GROUP_POLL_SECONDS) + if not _process_group_exists(pgid): + return + with suppress(ProcessLookupError): + os.killpg(pgid, signal.SIGKILL) + deadline = time.monotonic() + reap_timeout_seconds + while _process_group_exists(pgid) and time.monotonic() < deadline: + time.sleep(_PROCESS_GROUP_POLL_SECONDS) + + +def _kill_and_reap_agentseek(process: subprocess.Popen[bytes], *, timeout_seconds: float) -> None: + with suppress(ProcessLookupError): + process.kill() + try: + process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + message = "agentseek dev fallback could not reap its parent" + raise TimeoutError(message) from None + + +def _run_agentseek( + command: list[str], + *, + cwd: Path, + env: dict[str, str], + timeout_seconds: float = _AGENTSEEK_TIMEOUT_SECONDS, + graceful_shutdown_timeout_seconds: float = _AGENTSEEK_GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS, + helper_process_marker: Path | None = None, + helper_process_group_grace_seconds: float = _HELPER_PROCESS_GROUP_GRACE_SECONDS, + fallback_reap_timeout_seconds: float = _FALLBACK_REAP_TIMEOUT_SECONDS, +) -> int: + process = subprocess.Popen( # noqa: S603 - command is constructed by this contract script + command, + cwd=cwd, + env=env, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + return process.wait(timeout=timeout_seconds) + except subprocess.TimeoutExpired: + if os.name == "nt": + _kill_and_reap_agentseek(process, timeout_seconds=fallback_reap_timeout_seconds) + message = "agentseek lifecycle timeout fallback requires POSIX process-group support" + raise RuntimeError(message) from None + with suppress(ProcessLookupError): + process.send_signal(signal.SIGTERM) + requires_force_kill = False + try: + process.wait(timeout=graceful_shutdown_timeout_seconds) + except subprocess.TimeoutExpired: + requires_force_kill = True + _terminate_tracked_posix_process_group( + helper_process_marker, + grace_seconds=helper_process_group_grace_seconds, + reap_timeout_seconds=fallback_reap_timeout_seconds, + ) + if requires_force_kill: + _kill_and_reap_agentseek(process, timeout_seconds=fallback_reap_timeout_seconds) + message = "agentseek dev exceeded the lifecycle-contract timeout" + raise TimeoutError(message) from None + + +def main() -> int: + _require_posix_contract_platform() + actual_api_version = version("agentseek-api") + if actual_api_version != MINIMUM_AGENTSEEK_API_VERSION: + message = f"expected agentseek-api {MINIMUM_AGENTSEEK_API_VERSION}, got {actual_api_version}" + raise AssertionError(message) + + with tempfile.TemporaryDirectory(prefix="agentseek-api-lifecycle-contract-") as raw_root: + root = Path(raw_root) + lifecycle_dir = root / ".agentseek" + lifecycle_dir.mkdir() + output = root / "observed.json" + helper_process_marker = root / ".agentseek-api-helper-process.json" + helper = _write_api_capture_helper(root, output, helper_process_marker) + + (root / ".env").write_text( + "DIRECT_SENTINEL=from-dotenv\nDEPENDENT_SENTINEL=${DIRECT_SENTINEL}:resolved-in-file\nEXPLICIT_EMPTY=\n", + encoding="utf-8", + ) + (root / "unused.py").write_text("graph = object()\n", encoding="utf-8") + (root / "langgraph.json").write_text( + json.dumps({ + "dependencies": [], + "graphs": {"contract": "./unused.py:graph"}, + "env": ".env", + }), + encoding="utf-8", + ) + (lifecycle_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "contract/agentseek-api"', + 'name = "Published API contract"', + 'env_file = ".env"', + "", + "[env.DIRECT_SENTINEL]", + "required = true", + "", + "[processes.api]", + f"command = [{_toml_string(sys.executable)}, {_toml_string(helper)}]", + 'cwd = "."', + ]) + + "\n", + encoding="utf-8", + ) + + launch_environment = dict(os.environ) + launch_environment["DIRECT_SENTINEL"] = "from-shell" + launch_environment.pop("DEPENDENT_SENTINEL", None) + launch_environment.pop("EXPLICIT_EMPTY", None) + launch_environment.pop("CHILD_ONLY", None) + launch_environment.pop("PYTHONPATH", None) + + returncode = _run_agentseek( + [sys.executable, "-m", "agentseek", "dev"], + cwd=root, + env=launch_environment, + helper_process_marker=helper_process_marker, + ) + if returncode != 0: + message = "agentseek dev failed" + raise AssertionError(message) + + if not output.is_file(): + message = "published API capture runner did not execute" + raise AssertionError(message) + observed = json.loads(output.read_text(encoding="utf-8")) + expected = { + "api_version": MINIMUM_AGENTSEEK_API_VERSION, + "child_only": "added-after-snapshot", + "dependent": "from-dotenv:resolved-in-file", + "direct": "from-shell", + "explicit_empty": "", + "explicit_empty_present": True, + "graphs": str((root / "langgraph.json").resolve()), + } + if observed != expected: + message = "published API capture did not preserve the lifecycle environment contract" + raise AssertionError(message) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/agentseek/cli/commands/dev.py b/src/agentseek/cli/commands/dev.py index e7a2700a..1d8ff3d1 100644 --- a/src/agentseek/cli/commands/dev.py +++ b/src/agentseek/cli/commands/dev.py @@ -6,7 +6,13 @@ import typer -from agentseek.cli.lifecycle import load_lifecycle_project, run_lifecycle_task +from agentseek.cli.lifecycle import ( + LifecycleDotenvError, + load_lifecycle_project, + resolve_project_environment, + run_lifecycle_task, +) +from agentseek.cli.lifecycle.errors import exit_project_error app = typer.Typer( name="dev", @@ -29,9 +35,17 @@ def dev( ) -> None: """Run the local app defined by the lifecycle spec.""" project = load_lifecycle_project() - if not skip_check and not dry_run: - run_lifecycle_task(project, "doctor", strict=True) - run_lifecycle_task(project, "dev", dry_run=dry_run) + if dry_run: + run_lifecycle_task(project, "dev", dry_run=True) + return + + try: + environment = resolve_project_environment(project) + except LifecycleDotenvError as exc: + exit_project_error("Invalid lifecycle environment.", str(exc)) + if not skip_check: + run_lifecycle_task(project, "doctor", strict=True, environment=environment) + run_lifecycle_task(project, "dev", dry_run=False, environment=environment) __all__ = ["app"] diff --git a/src/agentseek/cli/lifecycle/__init__.py b/src/agentseek/cli/lifecycle/__init__.py index e6e6e3f5..9d52eb7f 100644 --- a/src/agentseek/cli/lifecycle/__init__.py +++ b/src/agentseek/cli/lifecycle/__init__.py @@ -1,13 +1,20 @@ """Lifecycle public API.""" +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION from agentseek.cli.lifecycle.core import ( LifecycleProject, lifecycle_spec_exists, load_lifecycle_project, + resolve_project_environment, run_lifecycle_task, run_task_cli, ) from agentseek.cli.lifecycle.discovery import NormalizationWarning, NormalizedLifecycleProject +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, +) from agentseek.cli.lifecycle.normalize import normalize_lifecycle from agentseek.cli.lifecycle.spec import ( LIFECYCLE_SPEC_FILE, @@ -19,16 +26,21 @@ __all__ = [ "LIFECYCLE_SPEC_FILE", + "MINIMUM_AGENTSEEK_API_VERSION", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", "AuthoredLifecycleSpec", + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", "LifecycleProject", "NormalizationWarning", "NormalizedLifecycleProject", "lifecycle_spec_exists", "load_lifecycle_project", "normalize_lifecycle", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/src/agentseek/cli/lifecycle/authored.py b/src/agentseek/cli/lifecycle/authored.py index b8b02096..03cc553f 100644 --- a/src/agentseek/cli/lifecycle/authored.py +++ b/src/agentseek/cli/lifecycle/authored.py @@ -69,6 +69,7 @@ class _EnvRequirementV2(EnvRequirement): class ServiceV1(SpecModel): url: str + tech: str | None = None class ProcessV1(SpecModel): diff --git a/src/agentseek/cli/lifecycle/compatibility.py b/src/agentseek/cli/lifecycle/compatibility.py new file mode 100644 index 00000000..03eabb45 --- /dev/null +++ b/src/agentseek/cli/lifecycle/compatibility.py @@ -0,0 +1,5 @@ +"""Versioned compatibility boundaries for optional lifecycle runtimes.""" + +MINIMUM_AGENTSEEK_API_VERSION = "0.2.2" + +__all__ = ["MINIMUM_AGENTSEEK_API_VERSION"] diff --git a/src/agentseek/cli/lifecycle/core.py b/src/agentseek/cli/lifecycle/core.py index 0f594443..8503fe7c 100644 --- a/src/agentseek/cli/lifecycle/core.py +++ b/src/agentseek/cli/lifecycle/core.py @@ -22,6 +22,12 @@ from pydantic import Field, create_model from pydantic_settings import BaseSettings, SettingsConfigDict +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, + resolve_lifecycle_environment, +) from agentseek.cli.lifecycle.errors import ( LifecycleNotFoundError, LifecycleTomlError, @@ -121,6 +127,8 @@ def run_lifecycle_task(project: LifecycleProject, name: str, **kwargs: object) - ) try: task.run(**kwargs) + except LifecycleDotenvError as exc: + exit_project_error("Invalid lifecycle environment.", str(exc)) except _UnsafeOperationalPathError as exc: exit_project_error( f"Invalid lifecycle {exc.field} path.", @@ -166,21 +174,34 @@ def _lifecycle_collection(project: LifecycleProject) -> Collection: Duty( name="info", description="Print project summary.", - function=lambda _ctx, verbose=False: print_info(project, verbose=verbose), + function=lambda _ctx, verbose=False, environment=None: print_info( + project, + verbose=verbose, + environment=environment, + ), ) ) collection.add( Duty( name="doctor", description="Check local project readiness.", - function=lambda _ctx, live=False, strict=False: doctor(project, live=live, strict=strict), + function=lambda _ctx, live=False, strict=False, environment=None: doctor( + project, + live=live, + strict=strict, + environment=environment, + ), ) ) collection.add( Duty( name="dev", description="Run local development.", - function=lambda _ctx, dry_run=False: dev(project, dry_run=dry_run), + function=lambda _ctx, dry_run=False, environment=None: dev( + project, + dry_run=dry_run, + environment=environment, + ), ) ) return collection @@ -215,7 +236,12 @@ def _display_name(name: str) -> str: return name.title() -def print_info(project: LifecycleProject, *, verbose: bool) -> None: +def print_info( + project: LifecycleProject, + *, + verbose: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Print a project summary derived from the lifecycle spec.""" spec = project.spec print("Project") @@ -227,7 +253,8 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: print("Entrypoints") print(" Dev: agentseek dev") for name, service in spec.services.items(): - print(f" {_display_name(name)}: {service.url}") + runtime = f" (runtime: {service.tech})" if service.tech else "" + print(f" {_display_name(name)}: {service.url}{runtime}") print() print("Environment") if spec.env_file: @@ -235,7 +262,12 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: present = env_file.is_file() print(f" Env file: {spec.env_file} ({'present' if present else 'missing'})") for name, requirement in spec.env.items(): - source = _env_requirement_source(project, name, requirement) + source = _env_requirement_source( + project, + name, + requirement, + environment=environment, + ) print(f" {name}: {f'set ({source})' if source else 'missing'}") print() if spec.tasks: @@ -252,9 +284,15 @@ def print_info(project: LifecycleProject, *, verbose: bool) -> None: _print_verbose_info(project) -def doctor(project: LifecycleProject, *, live: bool, strict: bool) -> None: +def doctor( + project: LifecycleProject, + *, + live: bool, + strict: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Run local readiness checks derived from the lifecycle spec.""" - results = _static_checks(project) + results = _static_checks(project, environment=environment) if live: results.extend(_live_checks(project)) _print_checks(results) @@ -264,23 +302,30 @@ def doctor(project: LifecycleProject, *, live: bool, strict: bool) -> None: raise SystemExit(1) -def dev(project: LifecycleProject, *, dry_run: bool) -> None: +def dev( + project: LifecycleProject, + *, + dry_run: bool, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> None: """Start local development processes declared in the lifecycle spec.""" print("Startup plan") for name, process in project.spec.processes.items(): print(f" {_display_name(name)}: {_render_command(process.command)}") for name, service in project.spec.services.items(): - print(f" {_display_name(name)}: {service.url}") + runtime = f" (runtime: {service.tech})" if service.tech else "" + print(f" {_display_name(name)}: {service.url}{runtime}") if dry_run: return - _ensure_required_inputs(project) + environment = environment if environment is not None else resolve_project_environment(project) + _ensure_required_inputs(project, environment=environment) for name, process in project.spec.processes.items(): _operational_path(project, process.cwd, allow_dot=True, field=f"processes.{name}.cwd") processes: list[ManagedProcess] = [] with _supervise_processes(processes): for process in project.spec.processes.values(): - processes.append(_spawn_process(process, project=project)) + processes.append(_spawn_process(process, project=project, environment=environment)) _wait_for_processes(processes) @@ -292,14 +337,18 @@ def _discover_spec(root: Path) -> tuple[Path, Path] | None: return None -def _static_checks(project: LifecycleProject) -> list[CheckResult]: +def _static_checks( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> list[CheckResult]: checks = [ _check("ok" if project.path.is_file() else "fail", project.path.name, "Lifecycle spec is present."), ] checks.extend(_tool_checks(project.spec.required_tools)) checks.extend(_path_checks(project)) checks.extend(_env_file_checks(project)) - checks.extend(_env_checks(project)) + checks.extend(_env_checks(project, environment=environment)) checks.extend(_process_cwd_checks(project)) return checks @@ -350,10 +399,22 @@ def _env_file_checks(project: LifecycleProject) -> list[CheckResult]: ] -def _env_checks(project: LifecycleProject) -> list[CheckResult]: +def _env_checks( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> list[CheckResult]: results: list[CheckResult] = [] for name, requirement in project.spec.env.items(): - configured = _env_requirement_source(project, name, requirement) is not None + configured = ( + _env_requirement_source( + project, + name, + requirement, + environment=environment, + ) + is not None + ) if not requirement.required and not configured: continue status = "ok" if configured else ("fail" if requirement.required else "ok") @@ -411,19 +472,41 @@ def _check_target(check: CheckV1 | CheckV2) -> bool: return 200 <= response.status_code < 400 -def _ensure_required_inputs(project: LifecycleProject) -> None: - failing = [item for item in _static_checks(project) if item.status == "fail"] +def _ensure_required_inputs( + project: LifecycleProject, + *, + environment: LifecycleEnvironmentSnapshot, +) -> None: + failing = [item for item in _static_checks(project, environment=environment) if item.status == "fail"] if failing: _print_checks(failing) exit_project_error("Project is not ready to run.", "Fix failing checks or use `agentseek doctor` for details.") -def _env_requirement_source(project: LifecycleProject, name: str, requirement: EnvRequirement) -> str | None: - environment = _env_settings_values(project, env_file=None, defaults=False) - if environment.get(name): +def _env_requirement_source( + project: LifecycleProject, + name: str, + requirement: EnvRequirement, + *, + environment: LifecycleEnvironmentSnapshot | None = None, +) -> str | None: + if environment is not None: + for key in requirement.keys(name): + if not environment.values.get(key): + continue + origin = environment.origins[key] + if origin is EnvironmentOrigin.LAUNCH_ENVIRONMENT: + return "environment" + return project.spec.env_file or "env_file" + if requirement.default: + return "default" + return None + + launch_values = _env_settings_values(project, env_file=None, defaults=False) + if launch_values.get(name): return "environment" - env_file = _env_settings_values(project, env_file=_env_file_path(project), defaults=False) - if env_file.get(name): + dotenv_values = _env_settings_values(project, env_file=_env_file_path(project), defaults=False) + if dotenv_values.get(name): return project.spec.env_file or "env_file" if requirement.default: return "default" @@ -465,6 +548,19 @@ def _env_file_path(project: LifecycleProject) -> Path | None: return _operational_path(project, project.spec.env_file, allow_dot=False, field="env_file") +def resolve_project_environment(project: LifecycleProject) -> LifecycleEnvironmentSnapshot: + """Resolve the one environment snapshot owned by this lifecycle invocation.""" + + try: + env_file = _env_file_path(project) + except _UnsafeOperationalPathError as exc: + exit_project_error( + f"Invalid lifecycle {exc.field} path.", + f"Update {exc.field} in {LIFECYCLE_SPEC_FILE}.", + ) + return resolve_lifecycle_environment(env_file=env_file) + + def _resolve_operational_path(project: LifecycleProject, value: str, *, allow_dot: bool) -> Path: """Resolve a runtime lifecycle path while preserving v1 joins.""" if isinstance(project.spec, LifecycleSpecV2): @@ -503,7 +599,12 @@ def _render_command(command: Sequence[str]) -> str: return " ".join(shlex.quote(part) for part in command) -def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) -> ManagedProcess: +def _spawn_process( + process: ProcessV1 | ProcessV2, + *, + project: LifecycleProject, + environment: LifecycleEnvironmentSnapshot, +) -> ManagedProcess: executable = shutil.which(process.command[0]) if executable is None: exit_project_error( @@ -519,6 +620,7 @@ def _spawn_process(process: ProcessV1 | ProcessV2, *, project: LifecycleProject) popen( command, cwd=str(cwd), + env=environment.as_subprocess_env(), **spawn_kwargs(), ), ) @@ -622,6 +724,7 @@ def _print_task_help(project: LifecycleProject) -> None: "discover_lifecycle_project", "lifecycle_spec_exists", "load_lifecycle_project", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/src/agentseek/cli/lifecycle/discovery.py b/src/agentseek/cli/lifecycle/discovery.py index 44ac251e..1bdeb170 100644 --- a/src/agentseek/cli/lifecycle/discovery.py +++ b/src/agentseek/cli/lifecycle/discovery.py @@ -671,7 +671,6 @@ def _v1_postconditions_hold(project: NormalizedLifecycleProject) -> bool: or service.kind is not None or service.display is not None or service.primary is not None - or service.tech is not None or service.providers or service.check_ids or service.links diff --git a/src/agentseek/cli/lifecycle/dotenv_adapter.py b/src/agentseek/cli/lifecycle/dotenv_adapter.py new file mode 100644 index 00000000..8cc1e5a1 --- /dev/null +++ b/src/agentseek/cli/lifecycle/dotenv_adapter.py @@ -0,0 +1,66 @@ +"""Strict lifecycle adapter around the bounded 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 LifecycleDotenvError(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"Lifecycle env file '{path}' {message}{location}.") + + +def parse_lifecycle_dotenv( + 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 LifecycleDotenvError(path, "does not exist") from exc + except UnicodeDecodeError as exc: + raise LifecycleDotenvError(path, "is not valid UTF-8") from exc + except OSError as exc: + reason = exc.strerror or type(exc).__name__ + raise LifecycleDotenvError(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 LifecycleDotenvError( + 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 "\x00" in binding.key or "=" in binding.key: + raise LifecycleDotenvError( + path, + "has an invalid variable name", + line=binding.original.line, + ) + value = ( + None if binding.value is None else "".join(atom.resolve(context) for atom in parse_variables(binding.value)) + ) + if value is not None and "\x00" in value: + raise LifecycleDotenvError( + path, + "contains a NUL character in a resolved value", + line=binding.original.line, + ) + values[binding.key] = value + context[binding.key] = value + return values diff --git a/src/agentseek/cli/lifecycle/environment.py b/src/agentseek/cli/lifecycle/environment.py new file mode 100644 index 00000000..00107958 --- /dev/null +++ b/src/agentseek/cli/lifecycle/environment.py @@ -0,0 +1,80 @@ +"""Immutable environment boundary for AgentSeek-managed lifecycle processes.""" + +from __future__ import annotations + +import os +from collections.abc import Mapping +from dataclasses import dataclass, field +from enum import StrEnum +from pathlib import Path +from types import MappingProxyType + +from agentseek.cli.lifecycle.dotenv_adapter import ( + LifecycleDotenvError, + parse_lifecycle_dotenv, +) + +_MISMATCHED_SNAPSHOT_KEYS_ERROR = "Snapshot values and origins must contain the same keys." + + +class EnvironmentOrigin(StrEnum): + """Value-free provenance retained by the lifecycle owner.""" + + ENV_FILE = "env_file" + LAUNCH_ENVIRONMENT = "launch_environment" + + +@dataclass(frozen=True) +class LifecycleEnvironmentSnapshot: + """Resolved child values that cannot be changed after construction.""" + + values: Mapping[str, str] = field(repr=False) + origins: Mapping[str, EnvironmentOrigin] + + def __post_init__(self) -> None: + values = dict(self.values) + origins = dict(self.origins) + if values.keys() != origins.keys(): + raise ValueError(_MISMATCHED_SNAPSHOT_KEYS_ERROR) + object.__setattr__(self, "values", MappingProxyType(values)) + object.__setattr__(self, "origins", MappingProxyType(origins)) + + def as_subprocess_env(self) -> dict[str, str]: + """Return an isolated mutable mapping accepted by subprocess APIs.""" + + return dict(self.values) + + +def resolve_lifecycle_environment( + *, + env_file: Path | None, + launch_environment: Mapping[str, str] | None = None, +) -> LifecycleEnvironmentSnapshot: + """Resolve dotenv plus the non-empty launch overlay exactly once.""" + + captured_launch = dict(os.environ if launch_environment is None else launch_environment) + file_values = parse_lifecycle_dotenv(env_file, ambient=captured_launch) if env_file is not None else {} + values: dict[str, str] = {} + origins: dict[str, EnvironmentOrigin] = {} + + for key, value in file_values.items(): + if value is None: + continue + values[key] = value + origins[key] = EnvironmentOrigin.ENV_FILE + + for key, value in captured_launch.items(): + if value == "": + continue + values[key] = value + origins[key] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + return LifecycleEnvironmentSnapshot(values=values, origins=origins) + + +__all__ = [ + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", + "resolve_lifecycle_environment", +] diff --git a/src/agentseek/cli/lifecycle/normalize.py b/src/agentseek/cli/lifecycle/normalize.py index e453690c..3753cf5f 100644 --- a/src/agentseek/cli/lifecycle/normalize.py +++ b/src/agentseek/cli/lifecycle/normalize.py @@ -141,7 +141,7 @@ def _v1_services_and_checks( kind=None, display=None, primary=None, - tech=None, + tech=service.tech, ) ) check_targets: dict[str, str | None] = {} diff --git a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md index e935b5ca..ecdcd427 100644 --- a/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md +++ b/src/skills/agentseek-lifecycle/references/agentseek-lifecycle.md @@ -24,13 +24,23 @@ Projects may expose additional spec tasks. Run them through `agentseek task`. - Declare tools under `[tools]` with a `required` list. - Declare file and directory prerequisites under `[paths]` with a `required` list. - Declare only environment variables AgentSeek should check under `[env.]`. Defaults are lower priority than `env_file` and shell variables. -- Use top-level `env_file` only when AgentSeek should read a project-local env file for declared env checks. AgentSeek does not inject that file into child processes. +- Only non-dry-run `agentseek dev` resolves `env_file` once, overlays non-empty captured launch environment values, and reuses one immutable snapshot for checks and all long-running child processes. +- Lifecycle defaults are readiness-only. A dotenv `KEY=` remains present and empty; bare `KEY` contributes no child assignment. +- Bounded python-dotenv resolves physical bindings in order and falls back to the captured launch environment; the lifecycle schema adds no interpolation mode. +- For non-dry-run `agentseek dev`, a missing, undecodable, or malformed dotenv returns `exit 2` before any child starts; no partial snapshot or value-bearing diagnostic is allowed. Standalone `agentseek info` reports dotenv status, while standalone `agentseek doctor --strict` renders readiness failures and returns `exit 1`. +- `agentseek task` does not inherit lifecycle `env_file`. +- AgentSeek guarantees only the initial child environment/snapshot: it has resolved values, not source instructions. Compatible child configuration completion may fill absent keys but must not replace inherited present keys; arbitrary child code can mutate its own process environment. +- Keep process commands as direct argv arrays; do not add shell wrappers to repair precedence. +- Do not add duplicated override-loading: it is an authoring prohibition, not an AgentSeek enforcement claim. An agentseek-api child using this contract requires `agentseek-api >= 0.2.2`. - Put public service URLs under `[services.]`. - Put long-running process commands under `[processes.]`. Do not declare process-level environment overrides. - Put task commands under `[tasks.]`. Task `cwd` values are project-relative and must exist before the task starts. Version 1 deliberately does not support optional tool/path checks, TCP checks, -process env overrides, multiple env files, env file injection, or env interpolation. +process env overrides, or multiple env files. It adds no lifecycle-schema +interpolation mode; configured `env_file` parsing uses bounded python-dotenv, +which resolves physical bindings in order and falls back to the captured launch +environment. ## Command Semantics diff --git a/tests/cli_commands/test_dev_supervision.py b/tests/cli_commands/test_dev_supervision.py index be688004..b0347b11 100644 --- a/tests/cli_commands/test_dev_supervision.py +++ b/tests/cli_commands/test_dev_supervision.py @@ -21,6 +21,7 @@ import agentseek.cli.lifecycle.core as lifecycle_core import agentseek.cli.lifecycle.process_group as process_group +from agentseek.cli.lifecycle.environment import LifecycleEnvironmentSnapshot from agentseek.cli.lifecycle.process_group import ManagedProcess, manage, spawn_kwargs, terminate from tests.cli_commands.helpers import build_command_app @@ -267,12 +268,13 @@ def spawn_then_interrupt(*_args: object, **_kwargs: object) -> ManagedProcess: return started raise KeyboardInterrupt - monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", lambda _project: None) + environment = LifecycleEnvironmentSnapshot(values={}, origins={}) + monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", lambda _project, *, environment: None) monkeypatch.setattr(lifecycle_core, "_operational_path", lambda *_args, **_kwargs: tmp_path) monkeypatch.setattr(lifecycle_core, "_spawn_process", spawn_then_interrupt) try: with pytest.raises(KeyboardInterrupt): - lifecycle_core.dev(project, dry_run=False) + lifecycle_core.dev(project, dry_run=False, environment=environment) _assert_tree_stopped(started, child_pid) finally: terminate(started, grace_seconds=0.0) diff --git a/tests/cli_commands/test_lifecycle.py b/tests/cli_commands/test_lifecycle.py index c351c2fe..372d8ddc 100644 --- a/tests/cli_commands/test_lifecycle.py +++ b/tests/cli_commands/test_lifecycle.py @@ -8,11 +8,14 @@ import tomllib from pathlib import Path from typing import Any, cast +from unittest.mock import Mock import pytest from typer.testing import CliRunner import agentseek.cli.lifecycle.core as lifecycle_core +import agentseek.cli.lifecycle.environment as lifecycle_environment +from agentseek.cli.lifecycle.environment import EnvironmentOrigin, LifecycleEnvironmentSnapshot from tests.cli_commands.helpers import build_command_app pytestmark = pytest.mark.usefixtures("create_symlink") @@ -50,6 +53,7 @@ def _write_lifecycle_spec(root: Path) -> None: [services.app] url = "http://127.0.0.1:5173" +tech = "agentseek-api" [services.seekdb] url = "mysql://127.0.0.1:2884/phoenix" @@ -216,6 +220,27 @@ def test_info_lists_lifecycle_tasks_and_task_discovery_hint(tmp_path: Path, monk assert "agentseek task --list" in result.stdout +def test_info_succeeds_before_configured_optional_dotenv_exists(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file="missing.env") + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["info"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "Env file: missing.env (missing)" in result.stdout + + +def test_info_describes_agentseek_api_runtime(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["info"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "App: http://127.0.0.1:5173 (runtime: agentseek-api)" in result.stdout + assert "langgraph dev" not in result.stdout + + def test_doctor_dispatches_lifecycle_spec(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) _write_project_inputs(tmp_path) @@ -275,6 +300,8 @@ def test_doctor_reports_missing_required_inputs(tmp_path: Path, monkeypatch) -> assert "fail .env: .env is missing." in result.stdout assert "fail BUB_API_KEY: BUB_API_KEY or BUB_OPENAI_API_KEY is not configured." in result.stdout assert "fail frontend/node_modules: frontend/node_modules is missing." in result.stdout + assert "Invalid lifecycle environment" not in result.stderr + assert "Traceback" not in result.stdout + result.stderr def test_doctor_live_accepts_2xx_and_3xx_statuses(tmp_path: Path, monkeypatch) -> None: @@ -297,6 +324,29 @@ def __init__(self, status_code: int) -> None: assert "ok app: http://127.0.0.1:5173 is reachable." in result.stdout +def test_doctor_live_reports_migrated_service_health(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + _write_project_inputs(tmp_path) + monkeypatch.chdir(tmp_path) + + class FakeResponse: + status_code = 204 + + requested: list[str] = [] + + def get(url: str, *, timeout: float) -> FakeResponse: + del timeout + requested.append(url) + return FakeResponse() + + monkeypatch.setattr(lifecycle_core.httpx, "get", get) + result = CliRunner().invoke(build_command_app(), ["doctor", "--live"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert requested == ["http://127.0.0.1:5173"] + assert "ok app: http://127.0.0.1:5173 is reachable." in result.stdout + + @pytest.mark.parametrize( "error", [ValueError("invalid timeout"), OverflowError("timestamp out of range")], @@ -400,19 +450,32 @@ def test_dev_dry_run_dispatches_lifecycle_spec(tmp_path: Path, monkeypatch) -> N assert "Startup plan" in result.stdout assert "Web: python -m http.server 5173" in result.stdout assert "App: http://127.0.0.1:5173" in result.stdout + assert "App: http://127.0.0.1:5173 (runtime: agentseek-api)" in result.stdout assert "seekdb: mysql://127.0.0.1:2884/phoenix" in result.stdout assert "Seekdb:" not in result.stdout +def test_dev_dry_run_uses_agentseek_api_as_backend(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + monkeypatch.chdir(tmp_path) + + result = CliRunner().invoke(build_command_app(), ["dev", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "runtime: agentseek-api" in result.stdout + assert "langgraph dev" not in result.stdout + + def test_dev_skip_check_still_enforces_required_inputs(tmp_path: Path, monkeypatch) -> None: _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text("", encoding="utf-8") monkeypatch.chdir(tmp_path) result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) assert result.exit_code == 2 assert "Project is not ready to run." in result.stderr - assert "fail .env: .env is missing." in result.stdout + assert "fail .env" not in result.stdout assert "fail BUB_API_KEY: BUB_API_KEY or BUB_OPENAI_API_KEY is not configured." in result.stdout @@ -527,6 +590,7 @@ def test_task_child_process_does_not_inherit_env_file(tmp_path: Path, monkeypatc def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: nonlocal captured_child_environ del command, cwd + assert "env" not in kwargs captured_child_environ = dict(kwargs.get("env", os.environ)) return 0 @@ -547,9 +611,334 @@ def fake_call(command: object, *, cwd: object, **kwargs: Any) -> int: assert captured_child_environ["BUB_OPENAI_API_KEY"] == "shell-key" assert "EXTRA_DOTENV" not in captured_child_environ assert "AGENTSEEK_SECRET" not in captured_child_environ + + +def test_dev_resolves_once_for_readiness_and_every_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + spec_path.write_text( + spec_path.read_text(encoding="utf-8") + + f""" +[processes.worker] +command = [{_toml_string(sys.executable)}, "-c", "print('worker')"] +cwd = "." +""", + encoding="utf-8", + ) + env_file = tmp_path / ".env" + env_file.write_text("API_KEY=initial\nSNAPSHOT_SENTINEL=initial-dependent\n", encoding="utf-8") + parse_calls: list[Path] = [] + child_environments: list[dict[str, str]] = [] + snapshots: list[LifecycleEnvironmentSnapshot] = [] + real_parse = lifecycle_environment.parse_lifecycle_dotenv + real_ensure = lifecycle_core._ensure_required_inputs + real_spawn = lifecycle_core._spawn_process + real_static_checks = lifecycle_core._static_checks + + def counting_parse(path: Path, *, ambient): + parse_calls.append(path) + return real_parse(path, ambient=ambient) + + def ensure_then_change(project, *, environment) -> None: + snapshots.append(environment) + real_ensure(project, environment=environment) + env_file.write_text("API_KEY=changed-after-readiness\nSNAPSHOT_SENTINEL=changed\n", encoding="utf-8") + + def static_checks_with_identity(project, *, environment): + snapshots.append(environment) + return real_static_checks(project, environment=environment) + + def spawn_with_identity(process, *, project, environment): + snapshots.append(environment) + return real_spawn(process, project=project, environment=environment) + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + child_environments.append(dict(env)) + if len(child_environments) == 1: + env_file.write_text("API_KEY=changed-between-children\n", encoding="utf-8") + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("API_KEY", raising=False) + monkeypatch.delenv("SNAPSHOT_SENTINEL", raising=False) + monkeypatch.setattr(lifecycle_environment, "parse_lifecycle_dotenv", counting_parse) + monkeypatch.setattr(lifecycle_core, "_ensure_required_inputs", ensure_then_change) + monkeypatch.setattr(lifecycle_core, "_spawn_process", spawn_with_identity) + monkeypatch.setattr(lifecycle_core, "_static_checks", static_checks_with_identity) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert parse_calls == [env_file] + assert len(snapshots) == 5 + assert all(snapshot is snapshots[0] for snapshot in snapshots) + assert len(child_environments) == 2 + assert [env["API_KEY"] for env in child_environments] == ["initial", "initial"] + assert [env["SNAPSHOT_SENTINEL"] for env in child_environments] == [ + "initial-dependent", + "initial-dependent", + ] + + +def test_dotenv_explicit_empty_remains_present_in_dev_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + (tmp_path / ".env").write_text("API_KEY=configured\nEXPLICIT_EMPTY=\n", encoding="utf-8") + captured: list[dict[str, str]] = [] + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + captured.append(dict(env)) + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("EXPLICIT_EMPTY", raising=False) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert captured[0]["EXPLICIT_EMPTY"] == "" + + +def test_lifecycle_default_is_readiness_only_and_absent_from_dev_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path) + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + spec_path.write_text( + spec_path.read_text(encoding="utf-8").replace( + "[env.API_KEY]\nrequired = true", + '[env.API_KEY]\nrequired = true\ndefault = "readiness-default"', + ), + encoding="utf-8", + ) + captured: list[dict[str, str]] = [] + + class FinishedProcess: + def poll(self) -> int: + return 0 + + def capture_popen(command, *, cwd, env, **kwargs): + del command, cwd, kwargs + captured.append(dict(env)) + return FinishedProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("API_KEY", raising=False) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + monkeypatch.setattr(lifecycle_core, "_terminate", lambda process: None) + + result = CliRunner().invoke(build_command_app(), ["dev"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "API_KEY is configured" in result.stdout + assert "API_KEY" not in captured[0] + + +def test_dev_dry_run_does_not_resolve_environment(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + monkeypatch.chdir(tmp_path) + + def fail_resolve(project): + del project + raise AssertionError("dry-run resolved environment") # noqa: TRY003 + + monkeypatch.setattr(lifecycle_core, "resolve_project_environment", fail_resolve) + monkeypatch.setattr("agentseek.cli.commands.dev.resolve_project_environment", fail_resolve) + + result = CliRunner().invoke(build_command_app(), ["dev", "--dry-run"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "Startup plan" in result.stdout + + +@pytest.mark.parametrize( + ("contents", "binary"), + [ + ('SECRET=must-not-leak\nBROKEN "value"\nAFTER=value\n', False), + ('SECRET=must-not-leak\nUNTERMINATED="value\n', False), + (b"SECRET=must-not-leak\nTOKEN=\xff\n", True), + ], +) +def test_invalid_lifecycle_dotenv_exits_2_before_any_child( + tmp_path: Path, + monkeypatch, + contents, + binary: bool, +) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + env_file = tmp_path / ".env" + if binary: + env_file.write_bytes(contents) + else: + env_file.write_text(contents, encoding="utf-8") + popen_calls: list[object] = [] + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + lifecycle_core.subprocess, + "Popen", + lambda *args, **kwargs: popen_calls.append((args, kwargs)), + ) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + rendered = result.stdout + result.stderr + + assert result.exit_code == 2 + assert popen_calls == [] + assert "must-not-leak" not in rendered + assert "Traceback" not in rendered + + +def test_missing_lifecycle_dotenv_exits_2_before_any_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file="missing.env") + monkeypatch.chdir(tmp_path) + popen = Mock(side_effect=AssertionError("child started")) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", popen) + + result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) + rendered = result.stdout + result.stderr + + assert result.exit_code == 2 + assert popen.call_count == 0 + assert "missing.env" in rendered + assert "Traceback" not in rendered + + +def test_spawn_process_uses_direct_argv_and_snapshot_without_shell(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path) + project = lifecycle_core.discover_lifecycle_project(tmp_path) + process = project.spec.processes["web"].model_copy( + update={"command": ("python tool", "--label", "value with spaces")} + ) + snapshot = LifecycleEnvironmentSnapshot( + values={"UNICODE_VALUE": "值"}, + origins={"UNICODE_VALUE": EnvironmentOrigin.ENV_FILE}, + ) + captured: dict[str, object] = {} + + def capture_popen(command, *, cwd, env, **kwargs): + captured.update(command=command, cwd=cwd, env=env, kwargs=kwargs) + return cast("subprocess.Popen[bytes]", object()) + + monkeypatch.setattr(lifecycle_core.shutil, "which", lambda _executable: "/tools/python tool") + monkeypatch.setattr(lifecycle_core, "spawn_kwargs", lambda: {"creationflags": 512}) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", capture_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda child: child) + + lifecycle_core._spawn_process(process, project=project, environment=snapshot) + + assert captured["command"] == ("/tools/python tool", "--label", "value with spaces") + assert captured["env"] == {"UNICODE_VALUE": "值"} + assert cast("dict[str, object]", captured["kwargs"])["creationflags"] == 512 + assert "shell" not in cast("dict[str, object]", captured["kwargs"]) + + +def test_dev_child_process_inherits_env_file_with_shell_precedence(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text( + 'SEEKDB_URL=mysql+aiomysql://dotenv.example/test\nDOTENV_ONLY="from dotenv # value\\nnext"\nOVERRIDE=from-dotenv # comment\nexport EXPORTED=value\n', + encoding="utf-8", + ) + captured_child_environ: dict[str, str] | None = None + + class FakeProcess: + def poll(self) -> int | None: + return None + + def fake_popen(command: object, *, cwd: object, env: dict[str, str], **kwargs: object) -> FakeProcess: + nonlocal captured_child_environ + del command, cwd, kwargs + captured_child_environ = env + return FakeProcess() + + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OVERRIDE", "from-shell") + monkeypatch.setattr(lifecycle_core.shutil, "which", lambda _tool: sys.executable) + monkeypatch.setattr(lifecycle_core.subprocess, "Popen", fake_popen) + monkeypatch.setattr(lifecycle_core, "manage", lambda process: process) + + project = lifecycle_core.discover_lifecycle_project(tmp_path) + environment = lifecycle_core.resolve_project_environment(project) + lifecycle_core._spawn_process( + project.spec.processes["web"], + project=project, + environment=environment, + ) + + assert captured_child_environ is not None + assert captured_child_environ["SEEKDB_URL"] == "mysql+aiomysql://dotenv.example/test" + assert captured_child_environ["DOTENV_ONLY"] == "from dotenv # value\nnext" + assert captured_child_environ["EXPORTED"] == "value" + assert captured_child_environ["OVERRIDE"] == "from-shell" assert "BUB_SECRET" not in captured_child_environ +def test_dev_child_process_applies_dotenv_values_to_a_real_process(tmp_path: Path, monkeypatch) -> None: + _write_lifecycle_spec(tmp_path) + (tmp_path / ".env").write_text( + 'CHILD_VALUE="from dotenv # value\\nnext"\n', + encoding="utf-8", + ) + output = tmp_path / "child-value.txt" + monkeypatch.chdir(tmp_path) + project = lifecycle_core.discover_lifecycle_project(tmp_path) + process = project.spec.processes["web"].model_copy( + update={ + "command": ( + sys.executable, + "-c", + "from pathlib import Path; import os; Path('child-value.txt').write_text(os.environ['CHILD_VALUE'])", + ) + } + ) + environment = lifecycle_core.resolve_project_environment(project) + + child = lifecycle_core._spawn_process(process, project=project, environment=environment) + + assert child.wait(timeout=5) == 0 + assert output.read_text(encoding="utf-8") == "from dotenv # value\nnext" + + +def test_empty_shell_value_falls_back_to_dotenv_for_readiness_and_spawned_child(tmp_path: Path, monkeypatch) -> None: + _write_v2_lifecycle_spec(tmp_path, env_file=".env") + (tmp_path / ".env").write_text("API_KEY=from-dotenv\n", encoding="utf-8") + output = tmp_path / "child-api-key.txt" + child_script = ( + "from pathlib import Path; import os; " + "Path('child-api-key.txt').write_text(os.environ['API_KEY'], encoding='utf-8')" + ) + spec_path = tmp_path / ".agentseek" / "lifecycle.toml" + original_command = f'command = [{_toml_string(sys.executable)}, "-c", "print(\'unreachable\')"]' + replacement_command = f'command = [{_toml_string(sys.executable)}, "-c", {_toml_string(child_script)}]' + lifecycle_text = spec_path.read_text(encoding="utf-8") + assert original_command in lifecycle_text + spec_path.write_text( + lifecycle_text.replace(original_command, replacement_command), + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("API_KEY", "") + + result = CliRunner().invoke(build_command_app(), ["dev"]) + + assert result.exit_code == 0, result.stdout + result.stderr + assert "API_KEY is configured" in result.stdout + assert output.read_text(encoding="utf-8") == "from-dotenv" + + @pytest.mark.parametrize("command", (["info"], ["doctor"])) def test_v2_operational_path_env_file_symlink_swap_rejects_before_file_access( tmp_path: Path, @@ -574,7 +963,7 @@ def test_v2_operational_path_env_file_symlink_swap_rejects_before_file_access( assert escaped not in accessed -def test_v2_operational_path_dev_env_settings_symlink_swap_rejects_before_reader( +def test_v2_operational_path_dev_env_snapshot_symlink_swap_rejects_before_reader( tmp_path: Path, monkeypatch, ) -> None: @@ -582,26 +971,25 @@ def test_v2_operational_path_dev_env_settings_symlink_swap_rejects_before_reader env_dir = tmp_path / "settings" env_dir.mkdir() (env_dir / ".env").write_text("API_KEY=inside\n", encoding="utf-8") - outside = tmp_path.parent / f"{tmp_path.name}-outside-env-settings" + outside = tmp_path.parent / f"{tmp_path.name}-outside-env-snapshot" outside.mkdir() escaped = outside / ".env" escaped.write_text("API_KEY=outside\n", encoding="utf-8") _swap_after_lifecycle_load(monkeypatch, env_dir, outside) read_paths: list[Path | None] = [] - def record_settings(project, *, env_file: Path | None, defaults: bool) -> dict[str, str]: - del project, defaults - read_paths.append(env_file) + def record_dotenv_read(path: Path | None, *, ambient): + del ambient + read_paths.append(path) return {} - monkeypatch.setattr(lifecycle_core, "_env_file_checks", lambda project: []) - monkeypatch.setattr(lifecycle_core, "_env_settings_values", record_settings) + monkeypatch.setattr(lifecycle_environment, "parse_lifecycle_dotenv", record_dotenv_read) monkeypatch.chdir(tmp_path) result = CliRunner().invoke(build_command_app(), ["dev", "--skip-check"]) _assert_confined_rejection(result, escaped, "env_file") - assert read_paths == [None] + assert read_paths == [] @pytest.mark.parametrize("command", (["doctor"], ["dev", "--skip-check"])) @@ -674,8 +1062,8 @@ def test_v2_operational_path_process_cwd_symlink_swap_after_readiness_rejects_be outside.mkdir() original = lifecycle_core._ensure_required_inputs - def ensure_then_swap(project) -> None: - original(project) + def ensure_then_swap(project, *, environment) -> None: + original(project, environment=environment) _swap_with_outside_symlink(runtime, outside) popen_called = False @@ -784,9 +1172,9 @@ def test_v2_operational_path_preflights_all_process_cwds_before_starting_childre sentinel_ran = False calls: list[object] = [] - def ensure_then_swap(project) -> None: + def ensure_then_swap(project, *, environment) -> None: nonlocal sentinel_ran - original(project) + original(project, environment=environment) sentinel_ran = True _swap_with_outside_symlink(second, outside) @@ -905,12 +1293,16 @@ def fake_call(command: object, *, cwd: Path, **kwargs: object) -> int: monkeypatch.delenv("BUB_MODEL", raising=False) assert required[0].status == "ok" assert env_file == outside_env - assert lifecycle_core._env_requirement_source(project, "BUB_MODEL", project.spec.env["BUB_MODEL"]) == str( - outside_env - ) + environment = lifecycle_core.resolve_project_environment(project) + assert lifecycle_core._env_requirement_source( + project, + "BUB_MODEL", + project.spec.env["BUB_MODEL"], + environment=environment, + ) == str(outside_env) assert lifecycle_core._resolve_operational_path(project, process.cwd, allow_dot=True) == tmp_path / process.cwd assert lifecycle_core._run_command(task.command, project=project, cwd=task.cwd) == 0 - lifecycle_core._spawn_process(process, project=project) + lifecycle_core._spawn_process(process, project=project, environment=environment) assert seen == [tmp_path / "frontend", tmp_path / f"../{outside.name}"] diff --git a/tests/cli_commands/test_lifecycle_authored.py b/tests/cli_commands/test_lifecycle_authored.py index 57257f6b..89b010ce 100644 --- a/tests/cli_commands/test_lifecycle_authored.py +++ b/tests/cli_commands/test_lifecycle_authored.py @@ -153,7 +153,7 @@ def test_v2_constants_and_public_exports_are_versioned_and_typed() -> None: assert package_spec is AuthoredLifecycleSpec -def test_lifecycle_package_exports_only_the_safe_normalization_boundary() -> None: +def test_lifecycle_package_exports_the_lifecycle_environment_boundary() -> None: import agentseek.cli.lifecycle as lifecycle assert lifecycle.NormalizedLifecycleProject is NormalizedLifecycleProject @@ -161,16 +161,21 @@ def test_lifecycle_package_exports_only_the_safe_normalization_boundary() -> Non assert lifecycle.normalize_lifecycle is normalize_lifecycle assert lifecycle.__all__ == [ "LIFECYCLE_SPEC_FILE", + "MINIMUM_AGENTSEEK_API_VERSION", "REQUIRED_COMMANDS", "SUPPORTED_LIFECYCLE_VERSION", "SUPPORTED_LIFECYCLE_VERSIONS", "AuthoredLifecycleSpec", + "EnvironmentOrigin", + "LifecycleDotenvError", + "LifecycleEnvironmentSnapshot", "LifecycleProject", "NormalizationWarning", "NormalizedLifecycleProject", "lifecycle_spec_exists", "load_lifecycle_project", "normalize_lifecycle", + "resolve_project_environment", "run_lifecycle_task", "run_task_cli", ] diff --git a/tests/cli_commands/test_lifecycle_environment.py b/tests/cli_commands/test_lifecycle_environment.py new file mode 100644 index 00000000..1076cff9 --- /dev/null +++ b/tests/cli_commands/test_lifecycle_environment.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +from typing import cast + +import pytest + +import agentseek.cli.lifecycle.environment as environment_module +from agentseek.cli.lifecycle.dotenv_adapter import parse_lifecycle_dotenv +from agentseek.cli.lifecycle.environment import ( + EnvironmentOrigin, + LifecycleDotenvError, + LifecycleEnvironmentSnapshot, + resolve_lifecycle_environment, +) + + +@pytest.mark.parametrize( + ("contents", "category", "canary"), + [ + ("NUL_KEY_CANARY\x00TAIL=value\n", "variable name", "NUL_KEY_CANARY"), + ("'EQUALS_KEY_CANARY=TAIL'=value\n", "variable name", "EQUALS_KEY_CANARY"), + ("SAFE=${AMBIENT}\n", "resolved value", "NUL_VALUE_CANARY"), + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_dotenv_adapter_rejects_subprocess_incompatible_bindings_without_echoing_content( + tmp_path, + contents, + category, + canary, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text(contents, encoding="utf-8") + + with pytest.raises(LifecycleDotenvError) as raised: + parse_lifecycle_dotenv( + env_file, + ambient={"AMBIENT": f"prefix\x00{canary}"}, + ) + + diagnostic = str(raised.value) + assert raised.value.line == 1 + assert category in diagnostic + assert canary not in diagnostic + assert "\x00" not in diagnostic + assert "\\x00" not in diagnostic + + +def test_dotenv_adapter_preserves_physical_order_empty_and_unicode_values(tmp_path) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "BASE=模型\nDEPENDENT=${BASE}/路径\nEQUALS_VALUE=left=right\nEXPLICIT_EMPTY=\nVALUELESS\n", + encoding="utf-8", + ) + + values = parse_lifecycle_dotenv(env_file, ambient={"BASE": "ambient"}) + + assert values == { + "BASE": "模型", + "DEPENDENT": "模型/路径", + "EQUALS_VALUE": "left=right", + "EXPLICIT_EMPTY": "", + "VALUELESS": None, + } + + +def test_snapshot_applies_only_nonempty_launch_values_over_dotenv(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text( + "OVERRIDE=from-dotenv\nEMPTY_FALLBACK=from-dotenv\nDOTENV_ONLY=dotenv\n", + encoding="utf-8", + ) + monkeypatch.setenv("OVERRIDE", "from-shell") + monkeypatch.setenv("EMPTY_FALLBACK", "") + monkeypatch.setenv("EMPTY_LAUNCH_ONLY", "") + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert snapshot.values["OVERRIDE"] == "from-shell" + assert snapshot.origins["OVERRIDE"] is EnvironmentOrigin.LAUNCH_ENVIRONMENT + assert snapshot.values["EMPTY_FALLBACK"] == "from-dotenv" + assert snapshot.origins["EMPTY_FALLBACK"] is EnvironmentOrigin.ENV_FILE + assert snapshot.values["DOTENV_ONLY"] == "dotenv" + assert "EMPTY_LAUNCH_ONLY" not in snapshot.values + + +def test_snapshot_preserves_dotenv_empty_and_omits_valueless_binding(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text("EXPLICIT_EMPTY=\nUNICODE=模型/路径\nVALUELESS\n", encoding="utf-8") + monkeypatch.delenv("EXPLICIT_EMPTY", raising=False) + monkeypatch.delenv("UNICODE", raising=False) + monkeypatch.delenv("VALUELESS", raising=False) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert "EXPLICIT_EMPTY" in snapshot.values + assert snapshot.values["EXPLICIT_EMPTY"] == "" + assert snapshot.origins["EXPLICIT_EMPTY"] is EnvironmentOrigin.ENV_FILE + assert snapshot.values["UNICODE"] == "模型/路径" + assert snapshot.origins["UNICODE"] is EnvironmentOrigin.ENV_FILE + assert "VALUELESS" not in snapshot.values + assert "VALUELESS" not in snapshot.origins + + +def test_snapshot_uses_file_local_physical_order_and_parses_once(tmp_path, monkeypatch) -> None: + env_file = tmp_path / ".env" + env_file.write_text("BASE=file\nDEPENDENT=${BASE}/v1\n", encoding="utf-8") + monkeypatch.setenv("BASE", "shell") + calls: list[object] = [] + real_parse = environment_module.parse_lifecycle_dotenv + + def counting_parse(path, *, ambient): + calls.append(path) + return real_parse(path, ambient=ambient) + + monkeypatch.setattr(environment_module, "parse_lifecycle_dotenv", counting_parse) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert calls == [env_file] + assert snapshot.values["BASE"] == "shell" + assert snapshot.values["DEPENDENT"] == "file/v1" + assert snapshot.origins["DEPENDENT"] is EnvironmentOrigin.ENV_FILE + + +def test_snapshot_is_immutable_and_returns_defensive_process_copies() -> None: + source_values = {"KEY": "original"} + source_origins = {"KEY": EnvironmentOrigin.ENV_FILE} + snapshot = LifecycleEnvironmentSnapshot( + values=source_values, + origins=source_origins, + ) + source_values["KEY"] = "source-mutated" + source_origins["KEY"] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + with pytest.raises(TypeError): + cast("dict[str, str]", snapshot.values)["KEY"] = "mutated" + with pytest.raises(TypeError): + cast("dict[str, EnvironmentOrigin]", snapshot.origins)["KEY"] = EnvironmentOrigin.LAUNCH_ENVIRONMENT + + child_environment = snapshot.as_subprocess_env() + child_environment["KEY"] = "child-only" + + assert snapshot.values["KEY"] == "original" + assert snapshot.origins["KEY"] is EnvironmentOrigin.ENV_FILE + assert snapshot.as_subprocess_env()["KEY"] == "original" + + +def test_snapshot_repr_never_contains_resolved_values() -> None: + snapshot = LifecycleEnvironmentSnapshot( + values={"API_KEY": "secret-sentinel-7f3a"}, + origins={"API_KEY": EnvironmentOrigin.LAUNCH_ENVIRONMENT}, + ) + + rendered = repr(snapshot) + + assert "secret-sentinel-7f3a" not in rendered + assert "API_KEY" in rendered + assert "launch_environment" in rendered + + +def test_snapshot_rejects_mismatched_value_and_origin_keys() -> None: + with pytest.raises(ValueError, match="same keys"): + LifecycleEnvironmentSnapshot( + values={"VALUE_ONLY": "secret-sentinel"}, + origins={"ORIGIN_ONLY": EnvironmentOrigin.ENV_FILE}, + ) + + +def test_resolver_uses_captured_launch_mapping_when_live_environment_changes( + tmp_path, + monkeypatch, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text("DEPENDENT=${BASE}/v1\n", encoding="utf-8") + monkeypatch.setenv("BASE", "captured") + real_parse = environment_module.parse_lifecycle_dotenv + + def mutate_after_capture(path, *, ambient): + monkeypatch.setenv("BASE", "changed-after-capture") + return real_parse(path, ambient=ambient) + + monkeypatch.setattr(environment_module, "parse_lifecycle_dotenv", mutate_after_capture) + + snapshot = resolve_lifecycle_environment(env_file=env_file) + + assert snapshot.values["BASE"] == "captured" + assert snapshot.values["DEPENDENT"] == "captured/v1" + + +@pytest.mark.parametrize("contents", ['BROKEN "value"\n', 'UNTERMINATED="value\n']) +def test_resolver_rejects_malformed_dotenv_without_partial_snapshot( + tmp_path, + contents, +) -> None: + env_file = tmp_path / ".env" + env_file.write_text("SECRET=must-not-leak\n" + contents + "AFTER=value\n", encoding="utf-8") + + with pytest.raises(LifecycleDotenvError) as raised: + resolve_lifecycle_environment(env_file=env_file, launch_environment={}) + + assert raised.value.line == 2 + assert "must-not-leak" not in str(raised.value) + + +@pytest.mark.parametrize( + "contents", + [ + "NUL_KEY_CANARY\x00TAIL=value\n", + "'EQUALS_KEY_CANARY=TAIL'=value\n", + "SAFE=value\x00NUL_VALUE_CANARY\n", + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_snapshot_rejects_subprocess_incompatible_dotenv_binding_without_partial_result(tmp_path, contents) -> None: + env_file = tmp_path / ".env" + env_file.write_text(contents, encoding="utf-8") + + with pytest.raises(LifecycleDotenvError): + resolve_lifecycle_environment(env_file=env_file, launch_environment={}) + + +def test_resolver_rejects_missing_and_invalid_utf8_sources(tmp_path) -> None: + with pytest.raises(LifecycleDotenvError, match="does not exist"): + resolve_lifecycle_environment( + env_file=tmp_path / "missing.env", + launch_environment={}, + ) + invalid = tmp_path / "invalid.env" + invalid.write_bytes(b"TOKEN=\xff\n") + with pytest.raises(LifecycleDotenvError, match="not valid UTF-8"): + resolve_lifecycle_environment(env_file=invalid, launch_environment={}) diff --git a/tests/cli_commands/test_lifecycle_json.py b/tests/cli_commands/test_lifecycle_json.py index fb0ea1a6..a16ce175 100644 --- a/tests/cli_commands/test_lifecycle_json.py +++ b/tests/cli_commands/test_lifecycle_json.py @@ -101,6 +101,7 @@ def _write_representative_v1_project(root: Path) -> None: [services.api] url = "http://user:password@127.0.0.1:8000/private" +tech = "agentseek-api" [processes.app] command = ["python", "PROCESS_SECRET_MUST_NOT_APPEAR"] @@ -172,7 +173,7 @@ def test_info_json_emits_exact_representative_v1_contract(tmp_path: Path, monkey '{"project":{"template":null,"name":"Legacy Project","description":null,"guide":null},' '"metadata_complete":false,"environment":[],"services":' '[{"id":"api","name":null,"description":null,"url":null,"kind":null,"display":null,' - '"primary":null,"tech":null,"providers":[],"check_ids":[],"links":[]}],' + '"primary":null,"tech":"agentseek-api","providers":[],"check_ids":[],"links":[]}],' '"checks":[{"id":"probe","service_id":null,"type":"http","target":null,"state":"not_run"}],' '"tasks":[{"id":"setup","description":null,"starts":[],"stops":[]}],"actions":[],"warnings":' '[{"code":"lifecycle_v1_metadata_incomplete","message":"Lifecycle v1 metadata is incomplete.",' diff --git a/tests/test_agentseek_api_lifecycle_contract.py b/tests/test_agentseek_api_lifecycle_contract.py new file mode 100644 index 00000000..c756fe6b --- /dev/null +++ b/tests/test_agentseek_api_lifecycle_contract.py @@ -0,0 +1,201 @@ +"""Regression coverage for the published agentseek-api lifecycle contract script.""" + +from __future__ import annotations + +import importlib.util +import json +import os +import sys +import time +from pathlib import Path +from types import ModuleType, SimpleNamespace + +import pytest + +_POSIX_SIGKILL_NUMBER = 9 + +_BLOCKED_SEPARATE_SESSION_HELPER = """ +import json +import os +import pathlib +import signal +import sys +import time + +pathlib.Path(sys.argv[1]).write_text(json.dumps({ + "pid": os.getpid(), + "pgid": os.getpgid(0), + "parent_pid": os.getppid(), +}), encoding="utf-8") +signal.signal(signal.SIGTERM, lambda *_args: None) +while True: + time.sleep(0.05) +""" + +_PARENT_EXITS_DURING_GRACE_WITH_PRIVATE_HELPER = """ +import pathlib +import signal +import subprocess +import sys +import time + +helper = pathlib.Path(sys.argv[1]) +marker = pathlib.Path(sys.argv[2]) +subprocess.Popen( + [sys.executable, str(helper), str(marker)], + start_new_session=True, +) + +deadline = time.monotonic() + 5 +while not marker.is_file(): + if time.monotonic() >= deadline: + raise TimeoutError("helper did not publish its process marker") + time.sleep(0.05) + +signal.signal(signal.SIGTERM, lambda *_args: sys.exit(0)) +while True: + time.sleep(0.05) +""" + + +def _load_contract_script() -> ModuleType: + script = Path(__file__).resolve().parents[1] / "scripts" / "check_agentseek_api_lifecycle_contract.py" + spec = importlib.util.spec_from_file_location("agentseek_api_lifecycle_contract", script) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def _wait_until(predicate, *, timeout: float = 5.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if predicate(): + return True + time.sleep(0.05) + return predicate() + + +def _process_is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + except ProcessLookupError: + return False + except PermissionError: + return True + return True + + +def _force_stop(pid: int) -> None: + if not _process_is_running(pid): + return + try: + os.kill(pid, _POSIX_SIGKILL_NUMBER) + except ProcessLookupError: + return + + +def test_contract_main_rejects_windows_before_helper_generation(monkeypatch: pytest.MonkeyPatch) -> None: + contract = _load_contract_script() + monkeypatch.setattr(contract, "os", SimpleNamespace(name="nt")) + + with pytest.raises(RuntimeError) as result: + contract.main() + + assert str(result.value) == "published agentseek-api lifecycle contract requires POSIX process-group support" + + +@pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") +def test_timeout_reaps_private_helper_when_parent_exits_during_grace(tmp_path: Path) -> None: + contract = _load_contract_script() + marker = tmp_path / "helper-process.json" + helper = tmp_path / "blocked_helper.py" + helper.write_text(_BLOCKED_SEPARATE_SESSION_HELPER, encoding="utf-8") + parent = tmp_path / "graceful_parent.py" + parent.write_text(_PARENT_EXITS_DURING_GRACE_WITH_PRIVATE_HELPER, encoding="utf-8") + child_pid: int | None = None + parent_pid: int | None = None + started = time.monotonic() + + try: + with pytest.raises(TimeoutError): + contract._run_agentseek( + [sys.executable, str(parent), str(helper), str(marker)], + cwd=tmp_path, + env=dict(os.environ), + timeout_seconds=1.0, + graceful_shutdown_timeout_seconds=1.0, + helper_process_marker=marker, + helper_process_group_grace_seconds=0.1, + fallback_reap_timeout_seconds=1.0, + ) + + elapsed = time.monotonic() - started + assert elapsed < 4.0, "graceful-parent cleanup exceeded its bounded timeout" + assert marker.is_file(), "helper process did not publish its private marker" + observed = json.loads(marker.read_text(encoding="utf-8")) + child_pid = observed["pid"] + parent_pid = observed["parent_pid"] + assert observed["pgid"] == child_pid, "helper did not run in a private process group" + assert _wait_until(lambda: not _process_is_running(parent_pid)), "parent survived its graceful shutdown" + assert _wait_until(lambda: not _process_is_running(child_pid)), "helper survived graceful-parent cleanup" + finally: + if child_pid is not None: + _force_stop(child_pid) + if parent_pid is not None: + _force_stop(parent_pid) + + +@pytest.mark.skipif(os.name == "nt", reason="the contract timeout fallback requires POSIX process groups") +def test_timeout_fallback_reaps_actual_agentseek_parent_and_separate_session_helper(tmp_path: Path) -> None: + contract = _load_contract_script() + marker = tmp_path / "helper-process.json" + lifecycle_dir = tmp_path / ".agentseek" + lifecycle_dir.mkdir() + helper = tmp_path / "blocked_helper.py" + helper.write_text(_BLOCKED_SEPARATE_SESSION_HELPER, encoding="utf-8") + (lifecycle_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "contract/timeout"', + 'name = "Timeout fallback contract"', + "", + "[processes.api]", + f"command = {json.dumps([sys.executable, str(helper), str(marker)])}", + 'cwd = "."', + ]) + + "\n", + encoding="utf-8", + ) + child_pid: int | None = None + parent_pid: int | None = None + started = time.monotonic() + + try: + with pytest.raises(TimeoutError): + contract._run_agentseek( + [sys.executable, "-m", "agentseek", "dev", "--skip-check"], + cwd=tmp_path, + env=dict(os.environ), + timeout_seconds=2.0, + graceful_shutdown_timeout_seconds=0.2, + helper_process_marker=marker, + helper_process_group_grace_seconds=0.1, + fallback_reap_timeout_seconds=1.0, + ) + + elapsed = time.monotonic() - started + assert elapsed < 5.0, "fallback cleanup exceeded its bounded timeout" + assert marker.is_file(), "helper process did not publish its private marker" + observed = json.loads(marker.read_text(encoding="utf-8")) + child_pid = observed["pid"] + parent_pid = observed["parent_pid"] + assert observed["pgid"] == child_pid, "AgentSeek did not start the helper in a separate session" + assert _wait_until(lambda: not _process_is_running(parent_pid)), "AgentSeek parent survived timeout fallback" + assert _wait_until(lambda: not _process_is_running(child_pid)), "helper survived timeout fallback" + finally: + if child_pid is not None: + _force_stop(child_pid) + if parent_pid is not None: + _force_stop(parent_pid) diff --git a/tests/test_docs_lifecycle.py b/tests/test_docs_lifecycle.py index 9403f10f..a75c206e 100644 --- a/tests/test_docs_lifecycle.py +++ b/tests/test_docs_lifecycle.py @@ -8,6 +8,8 @@ import pytest +from agentseek.cli.lifecycle.compatibility import MINIMUM_AGENTSEEK_API_VERSION + ROOT = Path(__file__).resolve().parents[1] TEMPLATES_ROOT = ROOT / "templates" TEMPLATE_INDEX = TEMPLATES_ROOT / "index.json" @@ -15,6 +17,13 @@ ROOT / "docs" / "reference" / "lifecycle-spec.md", ROOT / "docs" / "reference" / "lifecycle-spec.zh.md", ) +LIFECYCLE_SNAPSHOT_SUMMARIES = ( + ROOT / "docs" / "get-started" / "index.md", + ROOT / "docs" / "get-started" / "index.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + *LIFECYCLE_REFERENCES, +) LIFECYCLE_V2_SPEC_URL = "https://github.com/ob-labs/agentseek/blob/main/specs/lifecycle-v2-service-discovery.md" ROOT_DOTENV_EXAMPLE = ROOT / ".env.example" ROOT_READMES = ( @@ -382,3 +391,163 @@ def test_lifecycle_references_describe_authored_v2_loading(reference: Path) -> N "`agentseek-ai/agentseek-templates`" in row and "`version = 2`" in row for row in table_rows ) assert has_v2_catalog_row, reference + + +@pytest.mark.parametrize( + "guide", + (ROOT / "docs" / "guides" / "choose-template.md", ROOT / "docs" / "guides" / "choose-template.zh.md"), +) +def test_choose_template_guides_match_locked_catalog_runtime(guide: Path) -> None: + """The guides must describe the lifecycle command shipped by the locked catalog.""" + text = guide.read_text(encoding="utf-8") + + assert "langgraph dev" in text, guide + assert "agentseek-api dev" not in text, guide + + +@pytest.mark.parametrize( + "reference", + ( + *LIFECYCLE_REFERENCES, + ROOT / "src" / "skills" / "agentseek-lifecycle" / "references" / "agentseek-lifecycle.md", + ), +) +def test_lifecycle_references_define_the_immutable_environment_boundary(reference: Path) -> None: + """Lifecycle references must describe the one-time child environment contract.""" + text = reference.read_text(encoding="utf-8") + lines = text.splitlines() + + assert "immutable snapshot" in text + assert "`KEY=`" in text + assert "`KEY`" in text + assert "malformed dotenv" in text + assert "exit 2" in text + assert "`agentseek info`" in text + assert "`agentseek doctor --strict`" in text + assert "exit 1" in text + if reference.name.endswith(".zh.md"): + assert "非 dry-run" in text + assert "dotenv 状态" in text + assert "物理绑定出现的顺序" in text + assert "已捕获的启动环境" in text + assert "初始子进程环境/快照" in text + assert "任意子进程代码" in text + else: + assert "non-dry-run" in text + assert "dotenv status" in text + assert "physical bindings in order" in text + assert "captured launch environment" in text + assert "initial child environment/snapshot" in text + assert "arbitrary child code" in text.lower() + assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text + assert any("`agentseek dev`" in line and "snapshot" in line for line in lines) + assert any("`agentseek task`" in line and "`env_file`" in line for line in lines) + assert "multiple env files, or env interpolation." not in text + assert "多个 env 文件或 env 插值" not in text + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "guides" / "create-template.md", + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_template_authoring_requires_a_compatible_released_api(reference: Path) -> None: + """Template authors must pin a released runtime API with direct process argv.""" + text = reference.read_text(encoding="utf-8") + + assert f"`agentseek-api >= {MINIMUM_AGENTSEEK_API_VERSION}`" in text + assert "exact published version" in text + assert "direct argv" in text + + +@pytest.mark.parametrize( + ("guide", "former_generic_guidance"), + ( + ( + ROOT / "docs" / "guides" / "create-template.md", + "During `agentseek dev`, the\n" + "project `.env` is also passed to long-running child processes, with exported\n" + "shell variables taking precedence.", + ), + ( + ROOT / "docs" / "guides" / "create-template.zh.md", + "在 lifecycle 文件的 `[env.*]` 中声明同一组必需名称。AgentSeek 用这些声明检查\n" + "就绪状态\uff1b`agentseek dev` 会把项目 `.env` 传给长运行子进程\uff0cshell 变量优先。", + ), + ), +) +def test_template_guides_define_the_one_time_dev_environment_boundary( + guide: Path, + former_generic_guidance: str, +) -> None: + """Template guides must not describe dotenv as a generic child pass-through.""" + text = guide.read_text(encoding="utf-8") + + assert "immutable snapshot" in text + assert "`KEY=`" in text + assert "`agentseek task`" in text + assert "`env_file`" in text + assert former_generic_guidance not in text + + +@pytest.mark.parametrize("reference", LIFECYCLE_SNAPSHOT_SUMMARIES) +def test_lifecycle_snapshot_summaries_explicitly_exclude_dry_run(reference: Path) -> None: + """Every public snapshot summary must reserve resolution for non-dry-run dev.""" + text = reference.read_text(encoding="utf-8") + qualification = "非 dry-run" if reference.name.endswith(".zh.md") else "non-dry-run" + + assert qualification in text + assert "`agentseek dev`" in text + if reference in LIFECYCLE_REFERENCES: + env_file_row = next(line for line in text.splitlines() if line.startswith("| `env_file`")) + + assert qualification in env_file_row + assert "`agentseek dev`" in env_file_row + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_chinese_template_authoring_localizes_release_contract_terms(reference: Path) -> None: + """Chinese authoring guidance keeps only the required English contract phrases.""" + text = reference.read_text(encoding="utf-8") + + assert "\uff08immutable snapshot\uff09" in text + assert "\uff08exact published version\uff09" in text + assert "\uff08direct argv\uff09" in text + assert "editable" not in text + assert "checkout" not in text + assert re.search(r"\bpin\b", text) is None + assert "digest" not in text + + +@pytest.mark.parametrize( + "reference", + ( + ROOT / "docs" / "get-started" / "index.zh.md", + ROOT / "docs" / "guides" / "create-template.zh.md", + ROOT / "docs" / "reference" / "lifecycle-spec.zh.md", + ROOT / "docs" / "reference" / "template-authoring-contract.zh.md", + ), +) +def test_chinese_lifecycle_docs_do_not_code_switch_nonmandatory_parenthetical_terms(reference: Path) -> None: + """Chinese lifecycle prose keeps only the required English contract parentheticals.""" + text = reference.read_text(encoding="utf-8") + + for term in ( + "\uff08non-dry-run\uff09", + "\uff08captured launch environment\uff09", + "\uff08physical bindings in order\uff09", + "\uff08initial child environment/snapshot\uff09", + "\uff08arbitrary child code\uff09", + "\uff08dotenv status\uff09", + ): + assert term not in text diff --git a/tests/test_entrypoint.py b/tests/test_entrypoint.py index 19ae7688..a97a9738 100644 --- a/tests/test_entrypoint.py +++ b/tests/test_entrypoint.py @@ -7,6 +7,8 @@ import sys from pathlib import Path +import pytest + def test_agentseek_command_shows_help() -> None: command = shutil.which("agentseek") @@ -45,6 +47,61 @@ def test_agentseek_invalid_mode_exits_without_traceback() -> None: assert "Traceback" not in result.stderr +@pytest.mark.parametrize( + ("dotenv_contents", "canary"), + [ + ("NUL_KEY_CANARY\x00TAIL=value\n", "NUL_KEY_CANARY"), + ("'EQUALS_KEY_CANARY=TAIL'=value\n", "EQUALS_KEY_CANARY"), + ("SAFE=value\x00NUL_VALUE_CANARY\n", "NUL_VALUE_CANARY"), + ], + ids=["nul-key", "equals-key", "resolved-value"], +) +def test_agentseek_dev_rejects_subprocess_incompatible_dotenv_before_starting_child( + tmp_path: Path, + dotenv_contents: str, + canary: str, +) -> None: + command = [sys.executable, "-m", "agentseek"] + spec_dir = tmp_path / ".agentseek" + spec_dir.mkdir() + marker = tmp_path / "child.started" + child_command = [ + sys.executable, + "-c", + "from pathlib import Path; Path('child.started').write_text('started', encoding='utf-8')", + ] + (spec_dir / "lifecycle.toml").write_text( + "\n".join([ + "version = 2", + 'template = "test/invalid-dotenv-environment"', + 'name = "Invalid dotenv environment"', + 'env_file = "lifecycle.env"', + "", + "[processes.app]", + f"command = {json.dumps(child_command)}", + ]), + encoding="utf-8", + ) + (tmp_path / "lifecycle.env").write_text(dotenv_contents, encoding="utf-8") + + result = subprocess.run( # noqa: S603 + [*command, "dev", "--skip-check"], + cwd=tmp_path, + capture_output=True, + text=True, + check=False, + ) + + output = result.stdout + result.stderr + assert result.returncode == 2 + assert not marker.exists() + assert "Invalid lifecycle environment." in result.stderr + assert canary not in output + assert "\x00" not in output + assert "\\x00" not in output + assert "Traceback" not in output + + def test_agentseek_task_does_not_inherit_dotenv_secrets(tmp_path: Path) -> None: command = shutil.which("agentseek") assert command is not None diff --git a/tests/test_github_workflows.py b/tests/test_github_workflows.py index 6ccb824c..cbe56f1f 100644 --- a/tests/test_github_workflows.py +++ b/tests/test_github_workflows.py @@ -2,9 +2,40 @@ from __future__ import annotations +import re from pathlib import Path +def _job_block(workflow: str, job_name: str) -> str: + match = re.search( + rf"^ {re.escape(job_name)}:\n.*?(?=^ [a-zA-Z0-9_-]+:|\Z)", + workflow, + flags=re.MULTILINE | re.DOTALL, + ) + assert match, f"workflow does not define job {job_name!r}" + return match.group() + + +def test_published_api_lifecycle_contract_uses_declared_floors() -> None: + workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "main.yml" + text = workflow.read_text(encoding="utf-8") + + minimum_supported_cli = _job_block(text, "minimum-supported-cli") + assert "uv run --python 3.13 --isolated --no-project" in minimum_supported_cli + assert "--with-editable ." in minimum_supported_cli + assert "--with pydantic-settings==2.0.0" in minimum_supported_cli + assert "--with python-dotenv==1.0.0" in minimum_supported_cli + assert "agentseek --help" in minimum_supported_cli + + api_contract = _job_block(text, "agentseek-api-lifecycle-contract") + assert "uv run --python 3.12 --isolated --no-project" in api_contract + assert "--with-editable ." in api_contract + assert 'test "${api_version}" = "0.2.2"' in api_contract + assert "export PYTHONPATH=" in api_contract + assert '--with "agentseek-api==${api_version}"' in api_contract + assert "scripts/check_agentseek_api_lifecycle_contract.py" in api_contract + + def test_phoenix_smoke_verifies_multiple_trace_markers() -> None: """The Phoenix smoke job must prove more than one persisted trace.""" workflow = Path(__file__).resolve().parents[1] / ".github" / "workflows" / "main.yml" diff --git a/uv.lock b/uv.lock index e67d4b8e..957d8818 100644 --- a/uv.lock +++ b/uv.lock @@ -50,6 +50,7 @@ dependencies = [ { name = "logfire" }, { name = "pydantic" }, { name = "pydantic-settings" }, + { name = "python-dotenv" }, { name = "typer" }, ] @@ -92,6 +93,7 @@ requires-dist = [ { name = "logfire", specifier = ">=4.33.0" }, { name = "pydantic", specifier = ">=2.0" }, { name = "pydantic-settings", specifier = ">=2.0.0" }, + { name = "python-dotenv", specifier = ">=1.0,<1.3" }, { name = "typer", specifier = ">=0.12" }, ]