From 501b685f0230443646b523237536eaa5943f7573 Mon Sep 17 00:00:00 2001 From: agent-exec-transport Date: Sat, 17 Oct 2026 05:57:11 +0000 Subject: [PATCH] agent-exec transport cf117cc8acfd51c9b3a894e92ce8027f/c2dce0e31099 --- .gitea/workflows/ci.yaml | 8 ++ .gitmodules | 3 + AGENT-EXECUTION.md | 4 +- Makefile | 10 +- deps/development-manager | 1 + scripts/agent-exec | 68 ++++++++++ scripts/hooks/pre-commit | 2 +- tests/test_agent_exec_admission.py | 42 ------- tests/test_agent_exec_entry.py | 194 +++++++++++++++++++++++++++++ 9 files changed, 282 insertions(+), 50 deletions(-) create mode 160000 deps/development-manager create mode 100755 scripts/agent-exec create mode 100644 tests/test_agent_exec_entry.py diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index 9a35d103..0a3dabba 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -52,6 +52,8 @@ jobs: "deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System", "deps/logging-system": "Logging-System", + "deps/development-manager": "Development-Manager", + "development/guidelines": "Development-Guidelines", } root_token = os.environ.pop("TOKEN", "") component_token = os.environ.pop("SUBMODULE_TOKEN", "") @@ -164,6 +166,8 @@ jobs: tar -C /usr/local -xzf "$RUNNER_TEMP/go.tgz" ln -sfn /usr/local/go/bin/go /usr/local/bin/go ln -sfn /usr/local/go/bin/gofmt /usr/local/bin/gofmt + - name: Validate AGENT-EXECUTION.md with the pinned shared executor + run: scripts/agent-exec validate --repo . - name: Install Node 22 run: | set -euo pipefail @@ -226,6 +230,8 @@ jobs: "deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System", "deps/logging-system": "Logging-System", + "deps/development-manager": "Development-Manager", + "development/guidelines": "Development-Guidelines", } root_token = os.environ.pop("TOKEN", "") component_token = os.environ.pop("SUBMODULE_TOKEN", "") @@ -517,6 +523,8 @@ jobs: "deps/agent-command": "Agent-Command", "deps/error-code-system": "Error-Code-System", "deps/logging-system": "Logging-System", + "deps/development-manager": "Development-Manager", + "development/guidelines": "Development-Guidelines", } root_token = os.environ.pop("TOKEN", "") component_token = os.environ.pop("SUBMODULE_TOKEN", "") diff --git a/.gitmodules b/.gitmodules index f1c67664..05f478e1 100644 --- a/.gitmodules +++ b/.gitmodules @@ -34,3 +34,6 @@ [submodule "deps/agent-command"] path = deps/agent-command url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Agent-Command.git +[submodule "deps/development-manager"] + path = deps/development-manager + url = ssh://git@git-ssh.u1680832.nyat.app:31411/weichen/Development-Manager.git diff --git a/AGENT-EXECUTION.md b/AGENT-EXECUTION.md index c817b41a..8d5113c2 100644 --- a/AGENT-EXECUTION.md +++ b/AGENT-EXECUTION.md @@ -72,9 +72,9 @@ profiles: integration: exempt_paths: [".task/**", "docs/superpowers/specs/**", "docs/superpowers/plans/**"] transport: - command: python3 scripts/agent_exec.py apply + command: scripts/agent-exec apply admission: - command: python3 scripts/agent_exec.py admit + command: scripts/agent-exec admit cleanup: command: make task-finish ``` diff --git a/Makefile b/Makefile index 2f911918..77f2f6f9 100644 --- a/Makefile +++ b/Makefile @@ -32,7 +32,7 @@ export TASK_INITIATIVE setup: git config core.hooksPath scripts/hooks - chmod +x scripts/hooks/pre-commit scripts/hooks/post-merge scripts/task.sh scripts/evidence.sh scripts/facts-verify.sh + chmod +x scripts/hooks/pre-commit scripts/hooks/post-merge scripts/task.sh scripts/evidence.sh scripts/facts-verify.sh scripts/agent-exec @echo "OK: pre-commit/post-merge 钩子已启用(core.hooksPath=scripts/hooks)" precheck: @@ -91,7 +91,7 @@ help: @echo "make dev 开发模式说明(前后端分离热更新)" @echo "make pin 推进 submodule 指针到各仓远端 main(提交前须 make test 通过)" @echo "make status submodule 状态" - @echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(agent_exec validate)" + @echo "make agent-exec-validate 校验 AGENT-EXECUTION.md(scripts/agent-exec validate,经 dm-server agent-exec)" @echo "make agent-exec-apply RECEIPT= 快进当前分支到成功收据的 transport" @echo "make agent-exec-admit 校验暂存路径具备 transport 来源或属于 host 豁免" @echo "make task-triage REQ='<需求>' TRIAGE_RECEIPT= 以远端为准发现接纳中的 initiative 并写分流收据(候选存在时 exit 5,待决策)" @@ -100,14 +100,14 @@ help: @echo "make task-pr-check [PR=N] [FINAL=1] 核验当前任务分支 PR 的 base/head 门禁(远端为准;FINAL=1 用于 initiative 收口)" agent-exec-validate: - PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py validate --repo . + scripts/agent-exec validate --repo . agent-exec-apply: @if [ -z "$(RECEIPT)" ]; then echo "usage: make agent-exec-apply RECEIPT="; exit 2; fi - PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py apply --repo . --receipt "$(RECEIPT)" + scripts/agent-exec apply --repo . --receipt "$(RECEIPT)" agent-exec-admit: - PYTHONDONTWRITEBYTECODE=1 python3 scripts/agent_exec.py admit --repo . + scripts/agent-exec admit --repo . sync: git submodule update --init --recursive diff --git a/deps/development-manager b/deps/development-manager new file mode 160000 index 00000000..42e675fe --- /dev/null +++ b/deps/development-manager @@ -0,0 +1 @@ +Subproject commit 42e675fec8e0069646319fcd298e61d03d8cbfd5 diff --git a/scripts/agent-exec b/scripts/agent-exec new file mode 100755 index 00000000..c5e16660 --- /dev/null +++ b/scripts/agent-exec @@ -0,0 +1,68 @@ +#!/usr/bin/env bash +# 共享 Development-Manager 执行器薄入口:核对 deps/development-manager 与 index 钉住的提交一致且无修改, +# 构建(或复用缓存的)dm-server 二进制后执行 `dm-server agent-exec`。结果仅为本地完整性证据(SELF)。 +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +root=$(cd -- "$script_dir/.." && pwd) +sub_path="deps/development-manager" +dm="$root/$sub_path" + +die() { + echo "agent-exec: $*" >&2 + exit 2 +} + +# 在 DM checkout 内执行 git;清除 pre-commit 可能继承的仓库环境变量,避免读取超级仓的索引或对象库。 +dm_git() { + env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE git -C "$dm" "$@" +} + +index_entry=$(git -C "$root" ls-files -s -- "$sub_path") || index_entry="" +if [ -z "$index_entry" ]; then + die "index 中不存在 $sub_path 的 gitlink,拒绝执行" +fi +read -r mode pin _ <<<"$index_entry" +if [ "$mode" != "160000" ]; then + die "$sub_path 在 index 中不是 gitlink(mode=$mode),拒绝执行" +fi +if ! [[ "$pin" =~ ^[0-9a-f]{40}$ ]]; then + die "$sub_path 的 index 钉住值无效:$pin" +fi + +remedy="请运行 git submodule update --init deps/development-manager" +if [ ! -e "$dm/.git" ]; then + die "$sub_path 未检出;$remedy" +fi +top=$(dm_git rev-parse --show-toplevel 2>/dev/null) || top="" +real_dm=$(cd -- "$dm" && pwd -P) +if [ -z "$top" ] || [ "$top" != "$real_dm" ]; then + die "$sub_path 不是独立的 checkout;$remedy" +fi + +head=$(dm_git rev-parse HEAD 2>/dev/null) || head="" +if [ "$head" != "$pin" ]; then + die "$sub_path 的 HEAD(${head:-未知})与 index 钉住的 $pin 不一致;$remedy" +fi + +dirty=$(dm_git status --porcelain --ignore-submodules=all) || die "无法读取 $sub_path 的工作区状态,拒绝执行" +if [ -n "$dirty" ]; then + die "$sub_path 存在未提交的修改,拒绝执行" +fi + +cache_dir="${AGENT_EXEC_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/agent-system-dev/agent-exec}" +bin="$cache_dir/dm-server-$pin" +if [ ! -x "$bin" ]; then + command -v go >/dev/null 2>&1 || die "未找到 go,无法构建 dm-server" + mkdir -p "$cache_dir" + tmp=$(mktemp "$cache_dir/.dm-server-$pin.XXXXXX") + trap 'rm -f "$tmp"' EXIT + if ! (cd "$dm" && env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE GOWORK=off go build -trimpath -o "$tmp" ./cmd/dm-server); then + die "构建 dm-server 失败" + fi + chmod 0755 "$tmp" + mv -f "$tmp" "$bin" + trap - EXIT +fi + +exec "$bin" agent-exec "$@" diff --git a/scripts/hooks/pre-commit b/scripts/hooks/pre-commit index c9a0871f..47404f49 100755 --- a/scripts/hooks/pre-commit +++ b/scripts/hooks/pre-commit @@ -13,4 +13,4 @@ echo "$branch" | grep -qE '^(feature|fix|hotfix|release)/[A-Za-z0-9._-]+$' || { root=$(git rev-parse --show-toplevel) # admit 只是本地完整性/篡改证据(SELF):同一 OS 用户可读取本地 HMAC 密钥并绕过本钩子, # 它不是 GATE,也不是可信身份。缺失或无效的本地记录仍拒绝;交付信任来自精确 HEAD 的 CI 或独立 REPLAY。 -PYTHONDONTWRITEBYTECODE=1 python3 "$root/scripts/agent_exec.py" admit --repo "$root" +"$root/scripts/agent-exec" admit --repo "$root" diff --git a/tests/test_agent_exec_admission.py b/tests/test_agent_exec_admission.py index 62505baa..ae3f49e4 100644 --- a/tests/test_agent_exec_admission.py +++ b/tests/test_agent_exec_admission.py @@ -5,7 +5,6 @@ import hashlib import hmac import json import os -import shutil import subprocess import tempfile import unittest @@ -678,29 +677,6 @@ class AgentExecAdmissionTests(unittest.TestCase): self.assertEqual(rejected.returncode, agent_exec.EXIT_BLOCKED, rejected.stderr) self.assertIn("admit: no local integrity record", rejected.stderr) - def test_precommit_rejects_unproven_paths_after_branch_checks(self) -> None: - self._install_project_hook() - self._stage("src/extra.txt", "unproven\n") - proc = self._hook() - combined = proc.stdout + proc.stderr - self.assertNotEqual(proc.returncode, 0, combined) - self.assertIn("admit: no local integrity record", combined) - - _git(self.repo, "checkout", "-B", "main", self.head) - self._stage("src/extra.txt", "unproven\n") - blocked = self._hook() - text = blocked.stdout + blocked.stderr - self.assertNotEqual(blocked.returncode, 0, text) - self.assertIn("集成分支", text) - self.assertNotIn("admit:", text) - - def test_precommit_accepts_host_managed_exemptions(self) -> None: - self._install_project_hook() - self._stage("docs/superpowers/plans/admission.md", "plan\n") - proc = self._hook() - self.assertEqual(proc.returncode, 0, proc.stderr + proc.stdout) - self.assertIn("admit: ok", proc.stdout) - def test_make_agent_exec_apply_requires_receipt(self) -> None: env = os.environ.copy() env.pop("RECEIPT", None) @@ -719,21 +695,3 @@ class AgentExecAdmissionTests(unittest.TestCase): def _git_reset_index(self) -> None: _git(self.repo, "reset", "--hard", self.head) _git(self.repo, "clean", "-fd") - - def _install_project_hook(self) -> None: - dest = self.repo / "scripts" - hooks = dest / "hooks" - hooks.mkdir(parents=True) - for name in ("agent_exec.py", "agent_exec_config.py", "agent_exec_adapters.py"): - shutil.copy(SOURCE / "scripts" / name, dest / name) - hook = hooks / "pre-commit" - shutil.copy(SOURCE / "scripts" / "hooks" / "pre-commit", hook) - hook.chmod(0o755) - - def _hook(self) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", str(self.repo / "scripts" / "hooks" / "pre-commit")], - cwd=str(self.repo), - capture_output=True, - text=True, - ) diff --git a/tests/test_agent_exec_entry.py b/tests/test_agent_exec_entry.py new file mode 100644 index 00000000..15661778 --- /dev/null +++ b/tests/test_agent_exec_entry.py @@ -0,0 +1,194 @@ +"""scripts/agent-exec 薄入口与 pre-commit 委派测试:临时真实 Git 仓库 + 假 dm-server,不访问网络。""" +from __future__ import annotations + +import os +import shutil +import subprocess +import tempfile +import unittest +from pathlib import Path + + +SOURCE = Path(__file__).resolve().parents[1] +HAS_GO = shutil.which("go") is not None + +GO_MAIN = r'''package main + +import ( + "fmt" + "os" + "strings" +) + +func main() { + fmt.Printf("fake-dm-server %s\n", strings.Join(os.Args[1:], " ")) +} +''' + +# 预置在缓存中的桩二进制:用于不依赖 go 的 pre-commit 委派测试(入口命中缓存即直接 exec)。 +STUB = "#!/bin/sh\nprintf 'fake-dm-server %s\\n' \"$*\"\n" + + +def _clean_env(**extra: str) -> dict[str, str]: + env = {k: v for k, v in os.environ.items() if not k.startswith("GIT_")} + env.update( + GIT_TERMINAL_PROMPT="0", + GIT_AUTHOR_NAME="Agent Exec", + GIT_AUTHOR_EMAIL="agent-exec@example.com", + GIT_COMMITTER_NAME="Agent Exec", + GIT_COMMITTER_EMAIL="agent-exec@example.com", + ) + env.update(extra) + return env + + +def _git(cwd: Path, *args: str) -> str: + proc = subprocess.run( + ["git", "-C", str(cwd), *args], + capture_output=True, + text=True, + env=_clean_env(), + check=True, + ) + return proc.stdout.strip() + + +class AgentExecEntryTests(unittest.TestCase): + def setUp(self) -> None: + self.tmp = tempfile.TemporaryDirectory(prefix="agent-exec-entry-") + self.addCleanup(self.tmp.cleanup) + self.base = Path(os.path.realpath(self.tmp.name)) + self.cache = self.base / "cache" + self.refused_cache = self.base / "refused-cache" + self.dm_src = self._make_fake_dm() + self.pin = _git(self.dm_src, "rev-parse", "HEAD") + self.repo = self._make_super_repo() + self.dm = self.repo / "deps" / "development-manager" + + def _make_fake_dm(self) -> Path: + src = self.base / "fake-dm" + (src / "cmd" / "dm-server").mkdir(parents=True) + (src / "go.mod").write_text("module example.com/fakedm\n\ngo 1.22\n", encoding="utf-8") + (src / "cmd" / "dm-server" / "main.go").write_text(GO_MAIN, encoding="utf-8") + (src / "README.md").write_text("fake dm\n", encoding="utf-8") + _git(src, "init", "-q", "-b", "main") + _git(src, "add", "-A") + _git(src, "commit", "-q", "-m", "fake dm") + return src + + def _make_super_repo(self) -> Path: + repo = self.base / "super" + (repo / "scripts" / "hooks").mkdir(parents=True) + for src, rel in ( + (SOURCE / "scripts" / "agent-exec", "scripts/agent-exec"), + (SOURCE / "scripts" / "hooks" / "pre-commit", "scripts/hooks/pre-commit"), + ): + dest = repo / rel + shutil.copy(src, dest) + dest.chmod(0o755) + (repo / "README.md").write_text("super\n", encoding="utf-8") + _git(repo, "init", "-q", "-b", "main") + _git(repo, "update-index", "--add", "--cacheinfo", + f"160000,{self.pin},deps/development-manager") + dm = repo / "deps" / "development-manager" + dm.parent.mkdir() + _git(repo, "clone", "-q", "--no-checkout", str(self.dm_src), str(dm)) + _git(dm, "checkout", "-q", "--detach", self.pin) + _git(repo, "add", "-A") + _git(repo, "commit", "-q", "-m", "init") + return repo + + def _entry(self, *args: str, cache: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [str(self.repo / "scripts" / "agent-exec"), *args], + cwd=str(self.repo), + capture_output=True, + text=True, + env=_clean_env(AGENT_EXEC_CACHE=str(cache or self.cache)), + ) + + def _binary(self, cache: Path | None = None) -> Path: + return (cache or self.cache) / f"dm-server-{self.pin}" + + def _assert_no_binary(self, cache: Path) -> None: + leftovers = list(cache.glob("dm-server-*")) if cache.exists() else [] + self.assertEqual(leftovers, []) + + def _assert_refused(self, proc: subprocess.CompletedProcess[str], needle: str) -> None: + combined = proc.stdout + proc.stderr + self.assertEqual(proc.returncode, 2, combined) + self.assertIn(needle, proc.stderr) + self.assertNotIn("fake-dm-server", combined) + self._assert_no_binary(self.refused_cache) + + @unittest.skipUnless(HAS_GO, "go is not on PATH") + def test_positive_run_builds_and_execs_pinned_binary(self) -> None: + proc = self._entry("validate", "--repo", ".") + self.assertEqual(proc.returncode, 0, proc.stderr) + self.assertIn("fake-dm-server agent-exec validate --repo .", proc.stdout) + self.assertTrue(self._binary().is_file()) + + @unittest.skipUnless(HAS_GO, "go is not on PATH") + def test_second_run_reuses_cached_binary(self) -> None: + first = self._entry("version") + self.assertEqual(first.returncode, 0, first.stderr) + before = self._binary().stat().st_mtime_ns + second = self._entry("version") + self.assertEqual(second.returncode, 0, second.stderr) + self.assertIn("fake-dm-server agent-exec version", second.stdout) + self.assertEqual(self._binary().stat().st_mtime_ns, before) + self.assertEqual(list(self.cache.glob(".dm-server-*")), []) + + def test_refuses_when_dm_head_differs_from_pin(self) -> None: + _git(self.dm, "commit", "-q", "--allow-empty", "-m", "move head") + proc = self._entry("validate", "--repo", ".", cache=self.refused_cache) + self._assert_refused(proc, "与 index 钉住的") + self.assertIn("git submodule update --init deps/development-manager", proc.stderr) + + def test_refuses_tracked_modification_in_dm_checkout(self) -> None: + (self.dm / "README.md").write_text("changed\n", encoding="utf-8") + proc = self._entry("validate", "--repo", ".", cache=self.refused_cache) + self._assert_refused(proc, "存在未提交的修改") + + def test_refuses_when_gitlink_absent(self) -> None: + _git(self.repo, "rm", "--cached", "-q", "deps/development-manager") + proc = self._entry("validate", "--repo", ".", cache=self.refused_cache) + self._assert_refused(proc, "gitlink") + + def test_precommit_delegates_admit_to_pinned_binary(self) -> None: + _git(self.repo, "config", "core.hooksPath", "scripts/hooks") + (self.repo / "src").mkdir() + (self.repo / "src" / "b.txt").write_text("staged\n", encoding="utf-8") + _git(self.repo, "add", "src/b.txt") + + main_cache = self.base / "main-cache" + rejected = self._commit(main_cache, "on main") + combined = rejected.stdout + rejected.stderr + self.assertNotEqual(rejected.returncode, 0, combined) + self.assertIn("集成分支", combined) + self.assertNotIn("fake-dm-server", combined) + self._assert_no_binary(main_cache) + + cache = self.base / "hook-cache" + cache.mkdir() + stub = self._binary(cache) + stub.write_text(STUB, encoding="utf-8") + stub.chmod(0o755) + + _git(self.repo, "checkout", "-q", "-b", "feature/x") + accepted = self._commit(cache, "feat: x") + combined = accepted.stdout + accepted.stderr + self.assertEqual(accepted.returncode, 0, combined) + self.assertIn(f"fake-dm-server agent-exec admit --repo {self.repo}", combined) + + def _commit(self, cache: Path, message: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["git", "-C", str(self.repo), "commit", "-q", "-m", message], + capture_output=True, + text=True, + env=_clean_env(AGENT_EXEC_CACHE=str(cache)), + ) + + +if __name__ == "__main__": + unittest.main()