diff --git a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md index 6d4098a..c70a36c 100644 --- a/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-implementation/SKILL.md @@ -18,7 +18,7 @@ metadata: ## Managed-lock broker gate -Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`를 tracked background process로 시작하고 ready JSON을 기다리며, checkout 밖 role-owned mode `0700` runtime directory와 run-unique handle/socket을 미리 만든다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다. +Issue workflow는 bootstrap 이후 반드시 [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 하나의 role-neutral managed-lock broker를 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고, ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `/runtime//broker.handle`과 `broker.sock`이며 verification stage run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Repository lock은 같은 handle의 `acquire_repository`/`release_repository` request로만 중첩하고, TTL 전에 `renew`한다. 정상·오류·cancel 모두 `release_repository`(보유 시) → `release` → broker wait/reap → `probe`의 `free=true` 확인 순서를 완료하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다. ## 사용 시점 diff --git a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md index b3b1128..b2dadb3 100644 --- a/harness/agents/impl/skills/gitea-issue-rework/SKILL.md +++ b/harness/agents/impl/skills/gitea-issue-rework/SKILL.md @@ -20,7 +20,7 @@ Gitea 인증과 authoritative readback은 [Gitea authenticated access 운영 지 ## Managed-lock broker gate -Bootstrap 이후 이 Issue 재작업도 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나만 사용한다. `serve`는 checkout 밖 pre-created mode `0700` role-owned runtime directory, unique handle/socket, tracked background process와 ready JSON을 요구한다. 같은 handle로만 public `request` API의 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. Client는 raw token을 inspect/log하지 않으며 broker request API가 token을 캡슐화한다. 정상·오류·cancel cleanup은 보유한 repository lease release, Issue `release`, broker wait/reap, `probe`의 `free=true` 확인까지 동일하며 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. +Bootstrap 이후 이 Issue 재작업도 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나만 사용한다. `serve`에 canonical Issue identity, role, workflow run-id와 TTL을 전달해 broker-generated runtime을 만들고 ready JSON의 `handle`/`socket` 경로만 사용한다. 경로는 `/runtime//broker.handle`과 `broker.sock`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. 같은 handle로만 public `request` API의 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. Client는 raw token을 inspect/log하지 않으며 broker request API가 token을 캡슐화한다. 정상·오류·cancel cleanup은 보유한 repository lease release, Issue `release`, broker wait/reap, `probe`의 `free=true` 확인까지 동일하며 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. ## 사용 시점 diff --git a/harness/agents/review/skills/gitea-code-review/SKILL.md b/harness/agents/review/skills/gitea-code-review/SKILL.md index 3966b2d..d5541fa 100644 --- a/harness/agents/review/skills/gitea-code-review/SKILL.md +++ b/harness/agents/review/skills/gitea-code-review/SKILL.md @@ -18,7 +18,7 @@ metadata: ## Managed-lock broker gate -Bootstrap 이후 code review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Checkout 밖 pre-created mode `0700` role-owned runtime directory와 unique handle/socket으로 tracked `serve`를 시작하고 ready JSON을 기다린다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Nested repository lease는 같은 handle의 `acquire_repository`/`release_repository` request로만 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel에서 repository release(보유 시) → Issue `release` → broker wait/reap → `probe` `free=true` 확인을 검증하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. Native publication은 이 broker lease를 사용하며 adapter의 compatibility bridge는 canonical protocol을 우회하지 않는다. +Bootstrap 이후 code review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 요청하고 ready JSON의 `handle`/`socket`을 사용한다. Runtime은 `/runtime//`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. Nested repository lease는 같은 handle의 `acquire_repository`/`release_repository` request로만 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel에서 repository release(보유 시) → Issue `release` → broker wait/reap → `probe` `free=true` 확인을 검증하기 전에는 완료·로그·보고하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. Native publication은 이 broker lease를 사용하며 adapter의 compatibility bridge는 canonical protocol을 우회하지 않는다. Reviewer의 persistent harness/.runtime repository-local storage는 role/workflow runtime 경로와 별개이며 이 예외를 유지한다. ## 사용 시점과 정본 diff --git a/harness/agents/spec/skills/gitea-discovery-authoring/SKILL.md b/harness/agents/spec/skills/gitea-discovery-authoring/SKILL.md index b5c411c..04eed7b 100644 --- a/harness/agents/spec/skills/gitea-discovery-authoring/SKILL.md +++ b/harness/agents/spec/skills/gitea-discovery-authoring/SKILL.md @@ -18,7 +18,7 @@ Gitea Issue 원본과 저장소 근거를 조사해 Discovery 초안을 작성 ## Managed-lock broker gate -Bootstrap 이후 Issue workflow는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Checkout 밖 pre-created mode `0700` role-owned runtime directory와 unique handle/socket을 사용해 tracked `serve`를 시작하고 ready JSON을 기다린다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle의 `acquire_repository`/`release_repository`로만 nested repository lock을 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel 모두 repository release(보유 시), Issue release, broker wait/reap, `probe` `free=true` readback 순서가 검증돼야 완료·로그·보고할 수 있다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. +Bootstrap 이후 Issue workflow는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 공통 managed-lock broker 하나를 사용한다. Canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 만들도록 `serve`를 실행하고 ready JSON의 `handle`/`socket` 경로만 사용한다. Runtime은 `/runtime//`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle의 `acquire_repository`/`release_repository`로만 nested repository lock을 관리하고 TTL 전에 `renew`한다. 정상·오류·cancel 모두 repository release(보유 시), Issue release, broker wait/reap, `probe` `free=true` readback 순서가 검증돼야 완료·로그·보고할 수 있다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. ## 사용 시점 diff --git a/harness/agents/spec/skills/gitea-discovery-review/SKILL.md b/harness/agents/spec/skills/gitea-discovery-review/SKILL.md index 5fc46db..cfb6c14 100644 --- a/harness/agents/spec/skills/gitea-discovery-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-discovery-review/SKILL.md @@ -20,7 +20,7 @@ Gitea 인증과 readback은 [Gitea authenticated access 운영 지침](../../../ ## Managed-lock broker gate -Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 단일 role-neutral managed-lock broker를 사용한다. `serve`를 tracked background process로 시작하고 ready JSON을 기다리며 checkout 밖 mode `0700` role-owned runtime directory와 unique handle/socket을 pre-create한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle로 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. 정상·오류·cancel cleanup은 repository release(보유 시), Issue `release`, broker wait/reap, `probe` `free=true` 확인이며, 이 검증 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. +Bootstrap 이후 Discovery review는 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)의 단일 role-neutral managed-lock broker를 사용한다. `serve`를 tracked background process로 시작하고 ready JSON을 기다리며 canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 요청하고 ready JSON의 `handle`/`socket`을 사용한다. runtime은 `/runtime//`이며 verification run-id와 별개다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않으며 public `request` API가 token을 캡슐화한다. 같은 handle로 `acquire_repository`/`release_repository`를 요청하고 TTL 전에 `renew`한다. 정상·오류·cancel cleanup은 repository release(보유 시), Issue `release`, broker wait/reap, `probe` `free=true` 확인이며, 이 검증 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`, lock-file unlink와 client-side lock holder는 금지한다. ## 사용 시점 diff --git a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md index 1d65d9d..961352a 100644 --- a/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md +++ b/harness/agents/spec/skills/gitea-implementation-spec-review/SKILL.md @@ -18,7 +18,7 @@ metadata: ## Managed-lock broker gate -Cache와 Issue workflow의 lock은 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)을 따른다. Bootstrap 이후에는 role-neutral broker 하나를 `serve`로 tracked background process로 시작하고 ready JSON을 기다린다. Checkout 밖 role-owned mode `0700` runtime directory와 unique handle/socket을 pre-create하며 client는 raw token을 inspect/log하지 않고 public `request` API가 이를 캡슐화한다. Nested repository lock은 같은 handle의 `acquire_repository`/`release_repository` request만 사용하고 TTL 전에 `renew`한다. 정상·no-op·검증 실패·API 오류·cancel을 포함한 모든 종료 경로는 `release_repository`(보유 시) → `release` → wait/reap → `probe`에서 `free=true` 확인을 거쳐야 하며, 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다. +Cache와 Issue workflow의 lock은 canonical [Repository cache 운영 지침](../../../../operations/repository-cache.md)을 따른다. Bootstrap 이후에는 role-neutral broker 하나를 `serve`로 tracked background process로 시작하고 ready JSON을 기다린다. Canonical Issue identity, role, workflow run-id와 TTL로 broker-generated runtime을 요청하고 ready JSON의 `handle`/`socket`을 사용한다. Runtime은 `/runtime//`이며 verification run-id와 독립적이다. Runtime 생성은 approval이나 worktree admission을 대신하지 않는다. Cache bootstrap 이후 broker가 Issue lock 아래 runtime을 준비하고, ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다. Client는 raw token을 inspect/log하지 않고 public `request` API를 사용한다. Nested repository lock은 같은 handle의 `acquire_repository`/`release_repository` request만 사용하고 TTL 전에 `renew`한다. 정상·no-op·검증 실패·API 오류·cancel을 포함한 모든 종료 경로는 `release_repository`(보유 시) → `release` → wait/reap → `probe`에서 `free=true` 확인을 거쳐야 하며, 그 전에는 completed/log/report를 주장하지 않는다. Raw `flock ... sleep`과 lock-file unlink는 금지한다. ## 사용 시점 diff --git a/harness/lib/managed_lock_broker.py b/harness/lib/managed_lock_broker.py index 9e2f4af..d602ca7 100644 --- a/harness/lib/managed_lock_broker.py +++ b/harness/lib/managed_lock_broker.py @@ -17,6 +17,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from harness.lib.managed_lock import LockTimeoutError, ManagedLockSet, probe_free +from harness.lib.runtime_root import issue_runtime_path, prepare_issue_runtime ROLES = {"gitea-impl-agent", "gitea-spec-agent", "gitea-review-agent"} MAX_TTL = 3600 @@ -184,13 +185,18 @@ class Broker: raise ValueError("issue must be positive") if not 1 <= args.ttl_seconds <= MAX_TTL: raise ValueError("ttl_seconds out of bounds") + runtime_path = issue_runtime_path(args.root, args.owner, args.repository, args.issue, args.run_id) self.root = _abs(args.root, "root") - self.handle = _abs(args.handle, "handle") - self.sock_path = _abs(args.socket, "socket") + self.handle = runtime_path / "broker.handle" + self.sock_path = self.handle.parent / "broker.sock" + supplied_handle = getattr(args, "handle", None) + supplied_socket = getattr(args, "socket", None) + if supplied_handle is not None and os.fspath(supplied_handle) != str(self.handle): + raise ValueError("handle path must be canonical") + if supplied_socket is not None and os.fspath(supplied_socket) != str(self.sock_path): + raise ValueError("socket path must be canonical") locks = self.root / "repositories" / args.owner / args.repository / "locks" _safe_dir(locks, "repository locks") - _safe_dir(self.handle.parent, "handle") - _safe_dir(self.sock_path.parent, "socket") self.token = secrets.token_hex(32) self.deadline = time.monotonic() + args.ttl_seconds self.set = ManagedLockSet.for_issue(self.root, args.owner, args.repository, args.issue, timeout=args.acquire_timeout) @@ -337,6 +343,14 @@ class Broker: signal.signal(signal.SIGTERM, lambda *_: setattr(self, "stopping", True)) signal.signal(signal.SIGINT, lambda *_: setattr(self, "stopping", True)) self.set.issue.write_metadata(self.meta) + with self.set.repository_scope(): + runtime = prepare_issue_runtime(self.root, self.meta["owner"], self.meta["repository"], + self.meta["issue"], self.meta["run_id"], lock_set=self.set) + try: + runtime_fd = runtime.fd + self.socket_parent_fd = os.dup(runtime_fd) + finally: + os.close(runtime.fd) try: _write_handle(self.handle, self.state, exclusive=True, on_created=self._record_handle_identity) @@ -346,7 +360,9 @@ class Broker: if (st.st_dev, st.st_ino) != self.handle_identity: raise ValueError("created handle identity changed") self.handle_created = True - self.socket_parent_fd, socket_name = _open_parent(self.sock_path) + if self.socket_parent_fd is None: + raise ValueError("runtime directory unavailable") + socket_name = self.sock_path.name try: os.stat(socket_name, dir_fd=self.socket_parent_fd, follow_symlinks=False) raise ValueError("socket path already exists") @@ -404,13 +420,26 @@ def request_handle(handle_path: str | Path, action: str, ttl: float | None = Non handle = _abs(str(handle_path), "handle") data = _read_handle(handle) sock_path = _abs(data["socket"], "socket") - req = {"action": action, "token": data["token"]} - if ttl is not None: - req["ttl_seconds"] = ttl - with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: - sock.settimeout(3); sock.connect(str(sock_path)); sock.sendall((json.dumps(req) + "\n").encode()) - with sock.makefile("rb") as response_file: - response = json.loads(response_file.readline()) + parent_fd, name = _open_parent(sock_path) + try: + parent_st = os.fstat(parent_fd) + if (parent_st.st_uid != os.getuid() or stat.S_IMODE(parent_st.st_mode) != 0o700): + raise ValueError("unsafe socket parent") + st = os.stat(name, dir_fd=parent_fd, follow_symlinks=False) + if (not stat.S_ISSOCK(st.st_mode) or st.st_uid != os.getuid() + or stat.S_IMODE(st.st_mode) != 0o600): + raise ValueError("unsafe socket") + req = {"action": action, "token": data["token"]} + if ttl is not None: + req["ttl_seconds"] = ttl + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as sock: + sock.settimeout(3) + sock.connect(f"/proc/self/fd/{parent_fd}/{name}") + sock.sendall((json.dumps(req) + "\n").encode()) + with sock.makefile("rb") as response_file: + response = json.loads(response_file.readline()) + finally: + os.close(parent_fd) if not isinstance(response, dict): raise ValueError("invalid broker response") return response @@ -444,7 +473,7 @@ def parser() -> argparse.ArgumentParser: p = argparse.ArgumentParser() sub = p.add_subparsers(dest="command", required=True) s = sub.add_parser("serve") - s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle", required=True); s.add_argument("--socket", required=True); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0) + s.add_argument("--root", required=True); s.add_argument("--owner", required=True); s.add_argument("--repository", required=True); s.add_argument("--issue", required=True, type=int); s.add_argument("--role", required=True); s.add_argument("--run-id", required=True); s.add_argument("--handle"); s.add_argument("--socket"); s.add_argument("--ttl-seconds", required=True, type=float); s.add_argument("--acquire-timeout", type=float, default=0.0) r = sub.add_parser("request") r.add_argument("--handle", required=True); r.add_argument("--action", required=True, choices=["status", "renew", "acquire_repository", "release_repository", "release"]); r.add_argument("--ttl-seconds", type=float) check = sub.add_parser("probe") diff --git a/harness/lib/runtime_root.py b/harness/lib/runtime_root.py new file mode 100644 index 0000000..4b8b36c --- /dev/null +++ b/harness/lib/runtime_root.py @@ -0,0 +1,264 @@ +"""Canonical, private Issue runtime allocation (not cache/worktree admission).""" +from __future__ import annotations + +import argparse +import grp +import json +import os +import pwd +import re +import stat +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path + +if __package__ in (None, ''): + sys.path.insert(0, str(Path(__file__).resolve().parents[2])) + +from harness.lib.managed_lock import ManagedLockSet, validate_held_flock + +_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,254}") +_RUN = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}") +_DIR_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC + + +def _identity(root, owner, repository, issue, run_id=None): + raw = os.fspath(root) + if not isinstance(raw, str) or not raw.startswith('/') or '\x00' in raw: + raise ValueError('invalid harness root') + if any(part in {'', '.', '..'} for part in raw.split('/')[1:]): + raise ValueError('invalid harness root') + for value in (owner, repository): + if not isinstance(value, str) or not _IDENTIFIER.fullmatch(value): + raise ValueError('invalid repository identity') + if isinstance(issue, bool) or not isinstance(issue, int) or issue <= 0: + raise ValueError('invalid Issue') + if run_id is not None and (not isinstance(run_id, str) or not _RUN.fullmatch(run_id)): + raise ValueError('invalid workflow run id') + return Path(raw) + + +def issue_runtime_path(root, owner, repository, issue, run_id): + root = _identity(root, owner, repository, issue, run_id) + return root / 'repositories' / owner / repository / 'worktrees' / f'issue-{issue}' / 'runtime' / run_id + + +def _open_absolute(path): + fd = os.open('/', _DIR_FLAGS) + try: + for part in path.parts[1:]: + child = os.open(part, _DIR_FLAGS, dir_fd=fd) + os.close(fd) + fd = child + return fd + except BaseException: + os.close(fd) + raise + + +def _private(st): + if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or stat.S_IMODE(st.st_mode) != 0o700: + raise ValueError('unsafe private directory') + + +def _repository_container(st): + mode = stat.S_IMODE(st.st_mode) + if not stat.S_ISDIR(st.st_mode) or st.st_uid != os.getuid() or mode & 0o002: + raise ValueError('unsafe repositories directory') + if mode & 0o020: + try: + group = grp.getgrgid(st.st_gid) + members = {pwd.getpwnam(name).pw_uid for name in group.gr_mem} + members.update(user.pw_uid for user in pwd.getpwall() if user.pw_gid == st.st_gid) + except KeyError as exc: + raise ValueError('unresolved repositories group') from exc + if members != {os.getuid()}: + raise ValueError('unsafe repositories group') + + +def _existing(parent, name, check=_private): + child = os.open(name, _DIR_FLAGS, dir_fd=parent) + try: + check(os.fstat(child)) + named = os.stat(name, dir_fd=parent, follow_symlinks=False) + opened = os.fstat(child) + if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino): + raise ValueError('directory identity changed') + return child + except BaseException: + os.close(child) + raise + + +def _cache_fd(root, owner, repository): + fd = _open_absolute(root) + try: + if os.fstat(fd).st_mode & 0o002: + raise ValueError('world-writable harness root') + for name, check in [('repositories', _repository_container), (owner, _private), (repository, _private)]: + child = _existing(fd, name, check) + os.close(fd) + fd = child + return fd + except BaseException: + os.close(fd) + raise + + +def _new_child(parent, name, created): + os.mkdir(name, 0o700, dir_fd=parent) + initial = os.stat(name, dir_fd=parent, follow_symlinks=False) + created.append((os.dup(parent), name, (initial.st_dev, initial.st_ino))) + # Correct only a directory just created by this call, including restrictive umask. + if not stat.S_ISDIR(initial.st_mode) or initial.st_uid != os.getuid(): + raise ValueError('new directory identity changed') + os.chmod(name, 0o700, dir_fd=parent, follow_symlinks=False) + fd = _existing(parent, name) + opened = os.fstat(fd) + if (opened.st_dev, opened.st_ino) != (initial.st_dev, initial.st_ino): + os.close(fd) + raise ValueError('new directory identity changed') + return fd + + +@dataclass(frozen=True) +class RuntimeDirectory: + path: Path + fd: int # Caller owns this descriptor and must close it. + + +def prepare_issue_runtime(root, owner, repository, issue, run_id, *, lock_set): + path = issue_runtime_path(root, owner, repository, issue, run_id) + expected = ManagedLockSet.paths_for_issue(root, owner, repository, issue) + if (not isinstance(lock_set, ManagedLockSet) or not lock_set.issue.held or not lock_set.repository.held + or (lock_set.issue.path, lock_set.repository.path) != expected): + raise ValueError('matching Issue and repository leases required') + for lock in (lock_set.issue, lock_set.repository): + lock_fd = lock.fd + if lock_fd is None or not validate_held_flock(lock_fd, lock.path): + raise ValueError('kernel lease unavailable') + parent = _open_absolute(lock.path.parent) + try: + _private(os.fstat(parent)) + named = os.stat(lock.path.name, dir_fd=parent, follow_symlinks=False) + opened = os.fstat(lock_fd) + if (named.st_dev, named.st_ino) != (opened.st_dev, opened.st_ino): + raise ValueError('kernel lease path identity mismatch') + finally: + os.close(parent) + fd = _cache_fd(Path(root), owner, repository) + created = [] + success = False + try: + for name in ('worktrees', f'issue-{issue}', 'runtime'): + try: + child = _existing(fd, name) + except FileNotFoundError: + child = _new_child(fd, name, created) + os.close(fd) + fd = child + # Runs are immutable identities; even a safe, empty stale run is a collision. + try: + fd_run = _new_child(fd, run_id, created) + except FileExistsError as exc: + raise ValueError('runtime run already exists') from exc + os.close(fd) + fd = fd_run + success = True + return RuntimeDirectory(path, fd) + finally: + if not success: + os.close(fd) + for parent, name, identity in reversed(created): + try: + current = os.stat(name, dir_fd=parent, follow_symlinks=False) + if (current.st_dev, current.st_ino) == identity and stat.S_ISDIR(current.st_mode): + os.rmdir(name, dir_fd=parent) # Empty, owned inode only; never recursive. + except OSError: + pass + for parent, _, _ in created: + os.close(parent) + + +def inspect_issue_worktree_admission(root, owner, repository, issue): + """Classify container shape; caller still authenticates state/base/registration. + + This read-only check is used under Issue + repository leases. It does not + authorize Git writes or declare an existing checkout valid. + """ + root = _identity(root, owner, repository, issue) + fd = _cache_fd(root, owner, repository) + issue_fd = None + try: + bare = root / 'repositories' / owner / repository / 'repository.git' + bare_fd = os.open('repository.git', _DIR_FLAGS, dir_fd=fd) + try: + bare_st = os.fstat(bare_fd) + if bare_st.st_uid != os.getuid() or bare_st.st_mode & 0o022: + raise ValueError('unsafe bare repository') + result = subprocess.run(['git', '--git-dir', f'/proc/self/fd/{bare_fd}', 'worktree', 'list', '--porcelain', '-z'], + pass_fds=(bare_fd,), capture_output=True, text=True, timeout=30, check=False) + finally: + os.close(bare_fd) + if result.returncode: + raise ValueError('Git registration unavailable') + checkout = bare.parent / 'worktrees' / f'issue-{issue}' / 'checkout' + registrations = [field[9:] for field in result.stdout.split('\x00') if field.startswith('worktree ')] + registered = registrations.count(str(checkout)) + try: + worktrees = _existing(fd, 'worktrees') + except FileNotFoundError: + return 'invalid' if registered else 'absent' + try: + try: + issue_fd = _existing(worktrees, f'issue-{issue}') + except FileNotFoundError: + return 'invalid' if registered else 'absent' + finally: + os.close(worktrees) + entries = set(os.listdir(issue_fd)) + for name in entries & {'runtime', 'verification'}: + child = _existing(issue_fd, name) + os.close(child) + if entries <= {'runtime', 'verification'}: + return 'invalid' if registered else 'runtime-only' + if entries != (entries & {'runtime', 'verification'}) | {'checkout', 'state.json'} or registered != 1: + return 'invalid' + source = os.stat('checkout', dir_fd=issue_fd, follow_symlinks=False) + state = os.stat('state.json', dir_fd=issue_fd, follow_symlinks=False) + if (not stat.S_ISDIR(source.st_mode) or source.st_uid != os.getuid() or source.st_mode & 0o022 + or not stat.S_ISREG(state.st_mode) or state.st_uid != os.getuid() + or stat.S_IMODE(state.st_mode) != 0o600 or state.st_nlink != 1): + return 'invalid' + return 'checkout-candidate' + finally: + if issue_fd is not None: + os.close(issue_fd) + os.close(fd) + + +def main(): + # Direct-script bootstrap is below; imports are delayed to avoid broker cycles. + parser = argparse.ArgumentParser() + parser.add_argument('command', choices=['inspect-worktree']) + for name in ('root', 'owner', 'repository', 'handle'): + parser.add_argument('--' + name, required=True) + parser.add_argument('--issue', required=True, type=int) + args = parser.parse_args() + try: + from harness.lib import managed_lock_broker as broker, verification_root + root = _identity(args.root, args.owner, args.repository, args.issue) + verification_root._lease(Path(args.handle), root, args.owner, args.repository, args.issue) + if broker.request_handle(args.handle, 'status').get('status') != 'repository-held': + raise ValueError('repository lease required') + shape = inspect_issue_worktree_admission(root, args.owner, args.repository, args.issue) + print(json.dumps({'container': shape})) + return 1 if shape == 'invalid' else 0 + except (ValueError, OSError, subprocess.SubprocessError): + print(json.dumps({'error': 'worktree-container-unavailable'})) + return 2 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/harness/operations/repository-cache.md b/harness/operations/repository-cache.md index baee499..df9335d 100644 --- a/harness/operations/repository-cache.md +++ b/harness/operations/repository-cache.md @@ -63,8 +63,10 @@ Repository cache 경로는 고정된 machine-local 절대경로나 현재 작업 Bootstrap 이후 모든 Issue workflow는 role-neutral shared managed-lock broker 하나를 사용한다. 개별 impl/spec/review skill이 직접 `flock`을 획득하거나 broker 규칙을 재정의하지 않는다. Broker executable은 `harness/lib/managed_lock_broker.py`이며 authenticated request action은 `status`, `renew`, `acquire_repository`, `release_repository`, `release`이고 최대 TTL은 `3600`초다. -- Bootstrap lock을 해제한 뒤 broker를 시작한다. checkout 밖 trusted parent 아래 role-owned mode `0700` runtime directory를 미리 만들고, run-unique handle과 socket path를 사용한다. Handle file은 mode `0600`이고 socket은 mode `0600`이며 둘 다 symlink가 아니어야 한다. -- `serve`는 tracked background process로 시작하고 ready JSON을 기다린 뒤에만 Issue 작업을 시작한다. Ready 전에 broker는 bound **filesystem socket node**의 type·owner UID·device/inode와 exact mode `0600`을 검증한다. Socket FD의 sockfs metadata는 이 검증을 대신하지 못한다. Client는 raw token을 직접 read/log하지 않으며, broker public `request` API가 handle을 인증하고 token을 캡슐화한다. +- Broker runtime은 `/repositories///worktrees/issue-/runtime//`에 둔다. ``는 verification stage의 ``와 독립적이다. Runtime은 broker 소유 transient active resource이며 `verification/`는 보존되는 evidence다. +- Runtime 디렉터리는 broker가 owner/repository/issue/role/workflow-run-id/TTL을 받아 만들고 serve-ready JSON의 `handle`과 `socket` 필드로 실제 경로를 반환한다. 호출자는 이 값을 각각 `HANDLE`과 `SOCKET` 변수로 사용하며 임의 runtime directory, handle 또는 socket을 미리 만들지 않는다. Canonical basename은 `broker.handle`과 `broker.sock`이며 이미 제공한 handle/socket 옵션은 정확히 canonical 경로일 때만 허용된다. +- runtime 생성은 승인이나 admission을 의미하지 않는다. Admission 전에 Issue lock을 획득하고, 필요한 trusted parent 생성만을 위한 짧은 own-repository lock을 획득한 다음 runtime을 만든다. Bootstrap은 Issue lease 아래 실행하지 않는다. +- `serve`는 tracked background process로 시작하고 ready JSON을 기다린 뒤에만 Issue 작업을 시작한다. Ready 전에 broker는 bound filesystem socket node의 type·owner UID·device/inode와 exact mode `0600`을 검증한다. Client는 raw token을 read/log하지 않고 public request API가 인증을 캡슐화한다. 이 broker의 보안 경계에서 filesystem owner UID는 trusted principal이다. mode `0700`/`0600`과 random bearer token은 다른 UID 또는 foreign token을 사용한 작업을 막지만, 같은 UID를 공유하는 상호 비신뢰 프로세스를 서로 격리하지는 못한다. 그런 격리가 필요한 배포는 각 role을 distinct UID로 실행해야 한다. @@ -73,34 +75,33 @@ Bootstrap 이후 모든 Issue workflow는 role-neutral shared managed-lock broke - 정상, 오류와 cancel 경로는 동일한 cleanup을 수행한다: 보유 중이면 `release_repository`, `release`, broker process wait/reap, 그리고 Issue `probe`를 실행해 `free=true`를 요구한다. 다른 Issue workflow가 합법적으로 repository lock을 먼저 획득할 수 있으므로 완료 gate는 Issue resource를 검사하고, 종료한 broker process가 자신의 repository FD를 더 이상 보유하지 않음을 wait/reap으로 증명한다. 이 순서를 검증하기 전에는 completed/log/report를 주장하지 않는다. Lock 파일은 절대 unlink하지 않는다. - 소켓 정리는 부모 directory FD를 기준으로 기록한 filesystem device/inode·type·owner가 일치하는 경로만 삭제한다. 경로가 다른 객체나 symlink로 교체되면 그 객체는 삭제하지 않고 `socket-identity-changed`로 보고한다. 같은 UID의 경쟁 프로세스까지 원자적 격리를 보장하지 않으며, SIGKILL/crash 뒤 소켓 삭제를 보장하지 않는다. Stale 경로를 자동 삭제하거나 재사용하지 않는다. 첫 소켓 정리 실패 뒤에는 부모 FD를 닫고 경로를 다시 열어 자동 재시도하지 않는다. 잔여 경로의 제거는 운영자가 exact 경로와 소유권을 확인하는 별도 복구이며, 반복 cleanup은 이전 부분 실패를 보존한다. -- 정상 release 응답은 기존 `{"status":"released"}`를 유지한다. 정리가 일부 실패하면 broker는 `{"error":"cleanup-incomplete","cleanup":...}`를 응답하고 종료하며, `cleanup`의 `lease_released`, `socket_removed`, `handle_saved`, `errors`로 해제와 잔여 정리를 구분한다. Handle 기록 가능 시 같은 결과를 비밀값 없이 저장하고 stderr에도 오류 코드만 보고한다. 부분 실패는 nonzero process exit로 보고하며, 성공한 cleanup을 재적용하거나 실패 이력을 성공으로 덮어쓰지 않는다. 이 오류만으로 이미 해제한 lease를 재해제하거나 workflow 외부 write를 반복하지 않는다. +- 정상 release 응답은 기존 `{"status":"released"}`를 유지한다. 정리가 일부 실패하면 broker는 `{"error":"cleanup-incomplete","cleanup":...}`를 응답하고 종료하며, `cleanup`의 `lease_released`, `socket_removed`, `handle_saved`, `errors`로 해제와 잔여 정리를 구분한다. Handle 기록 가능 시 같은 결과를 비밀값 없이 저장하고 stderr에도 오류 코드만 보고한다. 부분 실패는 nonzero process exit로 보고하며, 성공한 cleanup을 재적용하거나 실패 이력을 성공으로 덮어쓰지 않는다. 성공 기록을 실패로 덮어쓰지 않는다. 이 오류만으로 이미 해제한 lease를 재해제하거나 workflow 외부 write를 반복하지 않는다. - 완료 gate는 기존 release → wait/reap → Issue probe 순서에 더해 broker의 정상 종료와 run-owned 소켓 경로 부재를 확인한다. `probe free=true`는 lock 해제 증거이지 소켓 권한·삭제 증거가 아니다. Lease 해제와 소켓·handle 정리 실패는 별도로 보고하고 완료로 간주하지 않는다. ### Canonical command templates ```sh -# is pre-created mode 0700 outside every checkout. +# Runtime paths come only from broker ready JSON; never create them or read the token. python3 /harness/lib/managed_lock_broker.py serve \ --root \ --owner --repository --issue \ - --role --run-id \ - --handle /.handle \ - --socket /.sock \ + --role --run-id \ --ttl-seconds +# Parse only public paths from ready JSON; do not print token-bearing fields. +HANDLE= +SOCKET= +python3 /harness/lib/managed_lock_broker.py request \ + --handle "$HANDLE" --action status +python3 /harness/lib/managed_lock_broker.py request \ + --handle "$HANDLE" --action acquire_repository +python3 /harness/lib/managed_lock_broker.py request \ + --handle "$HANDLE" --action release_repository +python3 /harness/lib/managed_lock_broker.py request \ + --handle "$HANDLE" --action renew --ttl-seconds +python3 /harness/lib/managed_lock_broker.py request \ + --handle "$HANDLE" --action release -# After ready JSON; never print/read the token value. -python3 /harness/lib/managed_lock_broker.py request \ - --handle /.handle --action status -python3 /harness/lib/managed_lock_broker.py request \ - --handle /.handle --action acquire_repository -python3 /harness/lib/managed_lock_broker.py request \ - --handle /.handle --action release_repository -python3 /harness/lib/managed_lock_broker.py request \ - --handle /.handle --action renew --ttl-seconds -python3 /harness/lib/managed_lock_broker.py request \ - --handle /.handle --action release - -# After wait/reap of the tracked broker process; free=true is mandatory for the Issue resource. +# After wait/reap of tracked broker; free=true mandatory for Issue resource. python3 /harness/lib/managed_lock_broker.py probe \ --root \ --owner --repository --issue \ @@ -186,15 +187,23 @@ Worktree를 신규 생성하거나 기존 worktree에서 구현을 재개하기 3. repository cache가 이 문서의 path, ownership, permission, bare, remote identity와 authoritative ref 검증을 통과했다. 4. 구현에 사용할 exact base SHA와 그 출처를 결정했다. Discovery pinned revision과 현재 default branch SHA가 다르면 어느 한쪽을 자동 선택하지 않고 변경을 조사해 요구사항과 충돌하지 않는 base를 확정하거나 중단한다. 5. `repository.git`에 exact base SHA의 commit object가 존재한다. 문자열 형식이나 object 존재만으로 Gitea 또는 승인된 Discovery에서 유래했다는 검증을 대체하지 않는다. -6. canonical target path, `state.json`과 Git worktree registration의 존재 여부를 함께 조사했다. -7. 신규 생성이면 target path와 동일 Issue registration이 모두 없어야 한다. 재사용이면 아래의 기존 worktree 검증을 모두 통과해야 한다. +6. canonical `checkout`, `state.json`과 Git worktree registration의 존재 여부를 함께 조사하고 Issue container의 entry·type·owner·mode를 확인한다. 안전한 `runtime/`·`verification/`만 존재하고 checkout·state·동일 Issue registration이 모두 없는 경우는 container-only 신규 생성 후보이며 기존 worktree가 아니다. Checkout·state·registration 중 일부만 있거나 unknown entry가 있으면 자동 cleanup/repair 없이 거부한다. +7. 신규 생성은 container 부재 또는 위 container-only 상태에서만 허용한다. 재사용은 일관된 checkout·state·registration이 존재하고 아래의 기존 worktree 검증을 모두 통과해야 한다. Runtime 생성 자체는 구현 승인이나 admission을 의미하지 않는다. + +계획된 container-shape 확인은 같은 handle로 repository lease를 획득한 뒤 공통 helper로 실행한다. Helper는 exact Git registration을 read-only로 조회하며 다음 JSON `container`를 반환한다: `absent`, `runtime-only`, `checkout-candidate`, `invalid`. 앞의 두 상태는 신규 생성 후보일 뿐 구현 승인·base·cache 검증을 대신하지 않으며, `checkout-candidate`도 아래 기존 worktree 검증을 모두 통과해야 한다. `invalid` 또는 helper 오류는 자동 복구 없이 중단한다. 실제 state 계약은 `state.json`이고 별도의 `registration.json`을 만들지 않는다. + +```sh +python3 /harness/lib/runtime_root.py inspect-worktree \ + --root --owner --repository \ + --issue --handle "$HANDLE" +``` Gate가 끝난 뒤 write 직전에 Issue body, `updated_at`과 구현 승인 상태를 다시 읽는다. admission snapshot과 다르면 worktree를 생성하거나 기존 worktree를 수정하지 않는다. ## Worktree 신규 생성 1. Broker Issue lease를 유지한 채 같은 handle에 `acquire_repository`를 request해 repository lease를 중첩한다. -2. cache와 base commit을 다시 검증하고 `worktrees`와 Issue directory를 canonical mode로 생성한 뒤 즉시 `lstat`한다. +2. cache와 base commit을 다시 검증한다. Broker가 준비한 `worktrees`와 Issue directory는 canonical mode·owner·type을 다시 검증하여 그대로 사용한다. Container 부재가 입증된 경로만 별도 안전한 생성 절차로 만들며 기존 경로를 덮어쓰거나 repair하지 않는다. 3. final `checkout`, `state.json`과 동일 Issue의 Git worktree registration이 모두 없는지 다시 확인한다. 4. `git --git-dir= worktree add --detach `와 동등한 호출로 exact final checkout을 생성한다. branch 생성 option을 사용하지 않는다. 5. worktree를 Issue 용도로 `git worktree lock`하고 reason에 repository credential이나 민감정보 없이 Issue 번호를 기록한다. @@ -212,7 +221,7 @@ Git 명령의 성공 exit만으로 생성 완료를 판정하지 않는다. Git ## 기존 worktree 재사용 -기존 directory 또는 Git registration 중 하나라도 발견되면 신규 생성으로 덮어쓰지 않는다. Broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 얻은 repository lease 아래에서 다음을 모두 확인한다. +기존 `checkout`, `state.json` 또는 동일 Issue의 Git registration 중 하나라도 발견되면 신규 생성으로 덮어쓰지 않는다. 안전한 runtime/verification-only container는 이 재사용 조건에 포함하지 않는다. Broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 얻은 repository lease 아래에서 다음을 모두 확인한다. - `state.json`이 유효한 `gitea-agent.worktree/v1`이고 owner, repository, Issue와 role이 현재 workflow와 일치한다. - exact checkout이 canonical `repository.git`에 한 번만 등록돼 있고 locked 상태다. @@ -248,7 +257,9 @@ Dirty worktree, untracked 파일 또는 base 이후 commit은 진행 중 구현 정상 worktree 제거는 사용자가 exact worktree 폐기를 승인했거나, 승인된 delivery가 완료되어 remote branch SHA가 local HEAD와 일치함을 authoritative readback한 경우에만 수행한다. 제거 전 base SHA, final HEAD SHA, dirty·untracked 상태, 미전달 commit과 remote 보존 여부를 확인하고 보고한다. Dirty 상태, 미전달 commit 또는 판정 불가능한 파일이 있으면 `--force`, filesystem 재귀 삭제, reset 또는 clean으로 우회하지 않는다. -제거할 때는 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 상태에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 제거 전에 아래 verification 계약에 따라 보존할 artifact의 identity, manifest·plan digest와 읽기 가능 여부를 확인한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 identity가 확인된 현재 run의 state temporary file만 제거한다. `verification/`이 남으면 Issue directory를 보존한다. 다른 알 수 없는 파일·directory가 남아 있으면 자동 정리하지 않고 partial로 보고한다. 비어 있는 Issue directory만 제거할 수 있다. 마지막으로 exact checkout, Git registration과 `state.json`의 부재 및 보존한 verification의 경로·digest가 제거 전과 같음을 다시 확인하고 `release_repository`를 request한다. Verification이 정상 보존돼 Issue directory가 남는 것은 checkout 제거 실패가 아니다. +제거할 때는 broker Issue lease를 유지하고 같은 handle의 `acquire_repository` request로 repository lease를 중첩한 상태에서 worktree lock을 해제하고 Git operation으로 checkout과 registration을 먼저 제거한다. 제거 전에 verification 계약에 따라 보존할 artifact identity, manifest·plan digest와 읽기 가능 여부를 확인한다. 두 대상의 부재를 확인한 뒤 exact `state.json`과 identity가 확인된 현재 run의 state temporary file만 제거한다. `runtime/`과 `verification/`은 서로 독립된 자원이며 worktree 제거 절차는 이를 삭제하거나 active broker/socket/handle을 건드리지 않는다. `verification/`이 남으면 Issue directory를 보존한다. `runtime/`만 남은 경우도 같은 보존 원칙을 적용하며, unknown entry·부분 생성 조합·identity/mode mismatch는 자동 복구·삭제하지 않고 partial로 보고한다. 마지막으로 exact checkout, Git registration과 `state.json`의 부재 및 verification 경로·digest가 제거 전과 같음을 재확인하고 `release_repository`를 request한다. 정상 보존된 runtime/verification 때문에 Issue directory가 남는 것은 checkout 제거 실패가 아니다. 비어 있는 Issue directory만 제거할 수 있으며 자동 만료·재귀 삭제를 도입하지 않는다. + +Runtime 실행에서 만든 server·합성 DB·임시 credential은 해당 workflow가 종료 전에 정리·부재를 확인한다. Broker cleanup은 lease 해제·프로세스 종료·소유 socket 삭제·handle 기록 결과를 검증하며 기존 부분 실패 계약을 유지한다. Terminal handle과 run directory는 private 진단 자료로 남을 수 있으나 secret-bearing handle을 verification/reviewer 기록으로 복사하거나 token을 출력하지 않는다. 이 잔존 자체를 socket 정리 실패로 세지 않으며, stale run은 재사용·자동 삭제하지 않는다. 남은 runtime의 폐기는 exact 경로와 활성 자원 부재를 확인한 별도 승인 절차로만 수행한다. ## Credential 안전 diff --git a/harness/tests/common/test_managed_lock_broker.py b/harness/tests/common/test_managed_lock_broker.py index 6151b45..23e599b 100644 --- a/harness/tests/common/test_managed_lock_broker.py +++ b/harness/tests/common/test_managed_lock_broker.py @@ -24,17 +24,36 @@ def wait_ready(proc, timeout=3): return json.loads(line) +def ensure_cache(root, owner="alice", repo="ledger"): + repository = root / "repositories" / owner / repo + repository.mkdir(parents=True, exist_ok=True) + (root / "repositories" / owner).chmod(0o700) + repository.chmod(0o700) + return repository + + +def prepare_fixture_runtime(root, run_id): + path = runtime_dir(root, run_id=run_id) + path.mkdir(parents=True, exist_ok=True) + for directory in (path.parent.parent.parent, path.parent.parent, path.parent, path): + directory.chmod(0o700) + return path + + +def runtime_dir(root, owner="alice", repo="ledger", issue=7, run_id="run-1"): + return root / "repositories" / owner / repo / "worktrees" / f"issue-{issue}" / "runtime" / run_id + + class ManagedLockBrokerTests(unittest.TestCase): def start(self, root, owner="alice", repo="ledger", issue=7, ttl=5, role="gitea-impl-agent", mask=-1): + ensure_cache(root, owner, repo) locks = root / "repositories" / owner / repo / "locks" locks.mkdir(parents=True) os.chmod(locks, 0o700) (locks / "repository.lock").touch(mode=0o600) (locks / f"issue-{issue}.lock").touch(mode=0o600) - runtime = root / "runtime" - runtime.mkdir() - os.chmod(runtime, 0o700) - handle = runtime / "handle.json" + runtime = runtime_dir(root, owner, repo, issue, "run-1") + handle = runtime / "broker.handle" socket = runtime / "broker.sock" cmd = [sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", owner, "--repository", repo, "--issue", str(issue), "--role", role, "--run-id", "run-1", @@ -60,6 +79,97 @@ class ManagedLockBrokerTests(unittest.TestCase): with s.makefile("rb") as response_file: return json.loads(response_file.readline()) + def test_constructor_rejects_lexical_root_and_handle_aliases(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d); ensure_cache(root) + locks = root / 'repositories/alice/ledger/locks'; locks.mkdir(mode=0o700) + values = dict(root=str(root), owner='alice', repository='ledger', issue=7, + role='gitea-impl-agent', run_id='aliases', ttl_seconds=3, + acquire_timeout=0, handle=None, socket=None) + path = runtime_dir(root, run_id='aliases') + for override in [dict(root=f'{root}/./'), dict(handle=f'{path}/./broker.handle')]: + with self.subTest(override=override), self.assertRaises(ValueError): + broker.Broker(Namespace(**(values | override))) + self.assertFalse(path.exists()) + + def test_ready_retains_only_one_pinned_runtime_directory_descriptor(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + proc, handle, socket = self.start(root) + try: + targets = [] + for fd in Path(f'/proc/{proc.pid}/fd').iterdir(): + try: + if os.readlink(fd) == str(handle.parent): + targets.append(fd.name) + except FileNotFoundError: + pass + self.assertEqual(len(targets), 1, f'leaked runtime descriptors: {targets}') + finally: + broker.request_handle(handle, 'release') + proc.wait(timeout=3) + proc.stdout.close(); proc.stderr.close() + + def test_serve_defaults_to_canonical_names_and_rejects_path_override(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + ensure_cache(root) + locks = root / "repositories/alice/ledger/locks" + locks.mkdir(); locks.chmod(0o700) + (locks / "repository.lock").touch(mode=0o600) + (locks / "issue-7.lock").touch(mode=0o600) + base = [sys.executable, str(BROKER), "serve", "--root", str(root), "--owner", "alice", + "--repository", "ledger", "--issue", "7", "--role", "gitea-impl-agent", + "--run-id", "defaults", "--ttl-seconds", "3"] + proc = subprocess.Popen(base, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + try: + ready = wait_ready(proc) + expected = runtime_dir(root, run_id="defaults") + self.assertEqual(Path(ready["handle"]), expected / "broker.handle") + self.assertEqual(Path(ready["socket"]), expected / "broker.sock") + broker.request_handle(expected / "broker.handle", "release") + self.assertEqual(proc.wait(timeout=2), 0) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + proc.stdout.close(); proc.stderr.close() + runtime = runtime_dir(root, run_id="override") + result = subprocess.run([*base[:], "--handle", str(root / "elsewhere"), "--run-id", "override"], + capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertFalse(runtime.exists()) + + def test_issue_contention_does_not_create_runtime(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) + ensure_cache(root) + locks = root / "repositories/alice/ledger/locks" + locks.mkdir(); locks.chmod(0o700) + (locks / "repository.lock").touch(mode=0o600) + issue_lock = locks / "issue-7.lock" + issue_lock.touch(mode=0o600) + with ManagedLock(issue_lock): + proc = subprocess.run([sys.executable, str(BROKER), "serve", "--root", str(root), + "--owner", "alice", "--repository", "ledger", "--issue", "7", + "--role", "gitea-impl-agent", "--run-id", "blocked", "--ttl-seconds", "2"], + capture_output=True, text=True) + self.assertNotEqual(proc.returncode, 0) + self.assertFalse(runtime_dir(root, run_id="blocked").exists()) + + def test_long_socket_path_connects_via_held_directory_fd(self): + with tempfile.TemporaryDirectory() as d: + root = Path(d) / ("r" * 48) + root.mkdir() + ensure_cache(root) + proc, handle, sock = self.start(root) + try: + self.assertGreater(len(str(sock).encode()), 107) + self.assertEqual(broker.request_handle(handle, "status")["status"], "held") + self.assertEqual(broker.request_handle(handle, "release")["status"], "released") + self.assertEqual(proc.wait(timeout=3), 0) + finally: + if proc.poll() is None: proc.kill(); proc.wait() + proc.stdout.close(); proc.stderr.close() + def test_cross_process_contention_and_authenticated_release(self): with tempfile.TemporaryDirectory() as d: root = Path(d) @@ -133,6 +243,7 @@ class ManagedLockBrokerTests(unittest.TestCase): proc, handle, sock = self.start(Path(d), mask=mask) try: self.assertEqual(stat.S_IMODE(sock.lstat().st_mode), 0o600) + self.assertEqual(stat.S_IMODE(handle.parent.stat().st_mode), 0o700) self.assertTrue(stat.S_ISSOCK(sock.lstat().st_mode)) self.assertEqual(broker.request_handle(handle, "status")["status"], "held") self.assertEqual(broker.request_handle(handle, "release")["status"], "released") @@ -146,12 +257,13 @@ class ManagedLockBrokerTests(unittest.TestCase): def make_bound_instance(self, root): import socket + ensure_cache(root) locks = root / "repositories/alice/ledger/locks" locks.mkdir(parents=True); locks.chmod(0o700) (locks / "repository.lock").touch(mode=0o600) - runtime = root / "runtime"; runtime.mkdir(mode=0o700) + runtime = prepare_fixture_runtime(root, "cleanup-test") instance = broker.Broker(Namespace(root=str(root), owner="alice", repository="ledger", issue=7, - role="gitea-impl-agent", run_id="cleanup-test", handle=str(runtime / "handle.json"), + role="gitea-impl-agent", run_id="cleanup-test", handle=str(runtime / "broker.handle"), socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) instance.set.issue.acquire() broker._write_handle(instance.handle, instance.state, exclusive=True, @@ -240,12 +352,16 @@ class ManagedLockBrokerTests(unittest.TestCase): with tempfile.TemporaryDirectory() as d: instance = self.make_bound_instance(Path(d)) instance.cleanup("released") - args = Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7, - role="gitea-impl-agent", run_id="startup-permission", handle=str(instance.handle.parent / "new.handle"), - socket=str(instance.sock_path.parent / "new.sock"), ttl_seconds=5.0, acquire_timeout=0.0) - fresh = broker.Broker(args) + fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7, + role="gitea-impl-agent", run_id="startup-permission", handle=str(runtime_dir(instance.root, run_id="startup-permission") / "broker.handle"), + socket=str(runtime_dir(instance.root, run_id="startup-permission") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) out = io.StringIO() - with patch.object(broker.os, "chmod", side_effect=PermissionError("injected")), contextlib.redirect_stdout(out): + real_chmod = os.chmod + def fail_socket_chmod(path, mode, **kwargs): + if path == fresh.sock_path.name: + raise PermissionError("injected") + return real_chmod(path, mode, **kwargs) + with patch.object(broker.os, "chmod", side_effect=fail_socket_chmod), contextlib.redirect_stdout(out): with self.assertRaises(PermissionError): fresh.run() self.assertEqual(out.getvalue(), "") self.assertFalse(fresh.sock_path.exists()) @@ -272,8 +388,8 @@ class ManagedLockBrokerTests(unittest.TestCase): with tempfile.TemporaryDirectory() as d: instance = self.make_bound_instance(Path(d)); instance.cleanup("released") fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7, - role="gitea-impl-agent", run_id="handle-swap", handle=str(instance.handle.parent / "swap.handle"), - socket=str(instance.sock_path.parent / "swap.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) + role="gitea-impl-agent", run_id="handle-swap", handle=str(runtime_dir(instance.root, run_id="handle-swap") / "broker.handle"), + socket=str(runtime_dir(instance.root, run_id="handle-swap") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) original_write = broker._write_handle foreign = b'{"foreign":"preserve"}\n' def replace_after_create(path, state, **kwargs): @@ -303,13 +419,14 @@ class ManagedLockBrokerTests(unittest.TestCase): with tempfile.TemporaryDirectory() as d: instance = self.make_bound_instance(Path(d)); instance.cleanup("released") fresh = broker.Broker(Namespace(root=str(instance.root), owner="alice", repository="ledger", issue=7, - role="gitea-impl-agent", run_id="socket-swap", handle=str(instance.handle.parent / "swap.handle"), - socket=str(instance.sock_path.parent / "swap.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) + role="gitea-impl-agent", run_id="socket-swap", handle=str(runtime_dir(instance.root, run_id="socket-swap") / "broker.handle"), + socket=str(runtime_dir(instance.root, run_id="socket-swap") / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0)) real_chmod = os.chmod def replace_after_chmod(path, mode, **kwargs): real_chmod(path, mode, **kwargs) - fresh.sock_path.unlink() - fresh.sock_path.write_text("foreign socket replacement") + if path == fresh.sock_path.name: + fresh.sock_path.unlink() + fresh.sock_path.write_text("foreign socket replacement") out = io.StringIO() with patch.object(broker.os, "chmod", side_effect=replace_after_chmod), contextlib.redirect_stdout(out): with self.assertRaises(ValueError): fresh.run() @@ -354,13 +471,14 @@ class ManagedLockBrokerTests(unittest.TestCase): for action in ("renew", "release"): with self.subTest(action=action), tempfile.TemporaryDirectory() as d: root = Path(d) + ensure_cache(root) locks = root / "repositories/alice/ledger/locks" locks.mkdir(parents=True); os.chmod(locks, 0o700) (locks / "repository.lock").touch(mode=0o600) - runtime = root / "runtime"; runtime.mkdir(mode=0o700) + runtime = prepare_fixture_runtime(root, "expiry-test") args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7, role="gitea-impl-agent", run_id="expiry-test", - handle=str(runtime / "handle.json"), socket=str(runtime / "broker.sock"), + handle=str(runtime / "broker.handle"), socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0) instance = broker.Broker(args) instance.set.issue.acquire() @@ -378,11 +496,12 @@ class ManagedLockBrokerTests(unittest.TestCase): def test_startup_failure_releases_issue_and_terminalizes_partial_handle(self): with tempfile.TemporaryDirectory() as d: root = Path(d) + ensure_cache(root) locks = root / "repositories/alice/ledger/locks" locks.mkdir(parents=True); os.chmod(locks, 0o700) (locks / "repository.lock").touch(mode=0o600) - runtime = root / "runtime"; runtime.mkdir(mode=0o700) - handle = runtime / "handle.json" + runtime = runtime_dir(root, run_id="startup-failure") + handle = runtime / "broker.handle" args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7, role="gitea-impl-agent", run_id="startup-failure", handle=str(handle), socket=str(runtime / "broker.sock"), @@ -405,11 +524,12 @@ class ManagedLockBrokerTests(unittest.TestCase): def test_startup_existing_handle_is_never_claimed_or_modified(self): with tempfile.TemporaryDirectory() as d: root = Path(d) + ensure_cache(root) locks = root / "repositories/alice/ledger/locks" locks.mkdir(parents=True); os.chmod(locks, 0o700) (locks / "repository.lock").touch(mode=0o600) - runtime = root / "runtime"; runtime.mkdir(mode=0o700) - handle = runtime / "handle.json" + runtime = prepare_fixture_runtime(root, "existing-handle") + handle = runtime / "broker.handle" original = b'{"foreign":"do-not-touch"}\n' handle.write_bytes(original); handle.chmod(0o600) args = Namespace(root=str(root), owner="alice", repository="ledger", issue=7, @@ -417,7 +537,7 @@ class ManagedLockBrokerTests(unittest.TestCase): handle=str(handle), socket=str(runtime / "broker.sock"), ttl_seconds=5.0, acquire_timeout=0.0) instance = broker.Broker(args) - with self.assertRaises(FileExistsError): + with self.assertRaises(ValueError): instance.run() self.assertEqual(handle.read_bytes(), original) self.assertTrue(probe_free(locks / "issue-7.lock")) @@ -462,4 +582,4 @@ class ManagedLockBrokerTests(unittest.TestCase): if __name__ == "__main__": - unittest.main() + unittest.main() \ No newline at end of file diff --git a/harness/tests/common/test_runtime_root.py b/harness/tests/common/test_runtime_root.py new file mode 100644 index 0000000..85a1b66 --- /dev/null +++ b/harness/tests/common/test_runtime_root.py @@ -0,0 +1,189 @@ +import json +import os +import stat +import subprocess +import tempfile +import unittest +from pathlib import Path + +from harness.lib.managed_lock import ManagedLockSet +from harness.lib import runtime_root as runtime + + +class RuntimeRootTests(unittest.TestCase): + def cache(self, root): + repo = root / 'repositories' / 'owner' / 'repo' + for p in [root / 'repositories', root / 'repositories' / 'owner', repo, repo / 'locks']: + p.mkdir(mode=0o700) + return repo + + def prepare(self, root, run='workflow-1'): + with ManagedLockSet.for_issue(root, 'owner', 'repo', 17) as locks: + with locks.repository_scope(): + return runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, run, lock_set=locks) + + def test_new_runtime_is_private_and_preserves_existing_cache(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + os.chmod(root / 'repositories', 0o755) + before = repo.stat() + result = self.prepare(root) + try: + self.assertEqual(result.path, runtime.issue_runtime_path(root, 'owner', 'repo', 17, 'workflow-1')) + self.assertEqual(stat.S_IMODE(os.fstat(result.fd).st_mode), 0o700) + self.assertEqual(os.fstat(result.fd).st_ino, result.path.stat().st_ino) + finally: + os.close(result.fd) + self.assertEqual(repo.stat().st_ino, before.st_ino) + self.assertEqual(stat.S_IMODE((root / 'repositories').stat().st_mode), 0o755) + self.assertFalse((result.path.parents[1] / 'state.json').exists()) + + def test_missing_or_wrong_lock_set_cannot_create_directories(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + locks = ManagedLockSet.for_issue(root, 'owner', 'repo', 17) + with self.assertRaises(ValueError): + runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks) + self.assertFalse((repo / 'worktrees').exists()) + with locks: + with self.assertRaises(ValueError): + runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks) + with locks.repository_scope(): + with self.assertRaises(ValueError): + runtime.prepare_issue_runtime(root, 'owner', 'repo', 18, 'run', lock_set=locks) + self.assertFalse((repo / 'worktrees').exists()) + + def test_kernel_unlocked_wrappers_cannot_authorize_runtime_creation(self): + import fcntl + for resource in ('issue', 'repository'): + with self.subTest(resource=resource), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + with ManagedLockSet.for_issue(root, 'owner', 'repo', 17) as locks: + with locks.repository_scope(): + fcntl.flock(getattr(locks, resource).fd, fcntl.LOCK_UN) + self.assertTrue(getattr(locks, resource).held) + with self.assertRaises(ValueError): + runtime.prepare_issue_runtime(root, 'owner', 'repo', 17, 'run', lock_set=locks) + self.assertFalse((repo / 'worktrees').exists()) + + def test_run_collision_preserves_competing_run_and_parent_chain(self): + from unittest.mock import patch + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); self.cache(root) + path = runtime.issue_runtime_path(root, 'owner', 'repo', 17, 'collision') + mkdir = os.mkdir + def competing_run(name, mode=0o777, *, dir_fd=None): + mkdir(name, mode, dir_fd=dir_fd) + if name == 'collision': + raise FileExistsError('competing allocator created run') + with patch.object(runtime.os, 'mkdir', side_effect=competing_run): + with self.assertRaises(ValueError): self.prepare(root, 'collision') + self.assertTrue(path.is_dir()) + self.assertTrue(path.parent.is_dir()) + self.assertTrue(path.parent.parent.is_dir()) + self.assertEqual(list(path.iterdir()), []) + + def test_stale_private_run_is_rejected_without_reuse(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); self.cache(root) + result = self.prepare(root); os.close(result.fd) + marker = result.path / 'marker'; marker.write_text('preserve') + before = result.path.stat() + with self.assertRaises(ValueError): self.prepare(root) + self.assertEqual(result.path.stat().st_ino, before.st_ino) + self.assertEqual(marker.read_text(), 'preserve') + + def test_unsafe_parent_is_not_repaired(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + p = repo / 'worktrees'; p.mkdir(mode=0o755) + with self.assertRaises(ValueError): self.prepare(root) + self.assertEqual(stat.S_IMODE(p.stat().st_mode), 0o755) + self.assertEqual(list(p.iterdir()), []) + + def test_symlink_parent_cannot_redirect_creation(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + other = root / 'elsewhere'; other.mkdir(mode=0o700) + (repo / 'worktrees').symlink_to(other) + with self.assertRaises((ValueError, OSError)): self.prepare(root) + self.assertEqual(list(other.iterdir()), []) + + def test_invalid_identifiers_and_literal_traversal_are_rejected(self): + for root, owner, repository, issue, run in [ + ('/tmp/../root', 'owner', 'repo', 17, 'run'), + ('/tmp/root', '../owner', 'repo', 17, 'run'), + ('/tmp/root', 'owner', 'repo', True, 'run'), + ('/tmp/root', 'owner', 'repo', 17, '..'), + ('/tmp/root', 'owner', 'repo', 17, 'x' * 129), + ]: + with self.subTest(root=root, owner=owner, issue=issue, run=run), self.assertRaises(ValueError): + runtime.issue_runtime_path(root, owner, repository, issue, run) + self.assertIn('o' * 255, str(runtime.issue_runtime_path('/tmp/root', 'o' * 255, 'repo', 17, 'run'))) + + def git(self, *args): + result = subprocess.run(['git', *map(str, args)], capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + return result.stdout + + def test_runtime_only_container_supports_real_new_git_checkout_and_removal(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + seed = root / 'seed' + self.git('init', seed) + (seed / 'file').write_text('fixture') + self.git('-C', seed, 'add', 'file') + self.git('-C', seed, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid', 'commit', '-m', 'fixture') + self.git('clone', '--bare', seed, repo / 'repository.git') + source = self.git('-C', seed, 'rev-parse', 'HEAD').strip() + run = self.prepare(root); os.close(run.fd) + issue = repo / 'worktrees' / 'issue-17' + self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'runtime-only') + self.git('--git-dir', repo / 'repository.git', 'worktree', 'add', '--detach', issue / 'checkout', source) + self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'invalid') + state = issue / 'state.json'; state.write_text(json.dumps({'schema': 'gitea-agent.worktree/v1'})); state.chmod(0o600) + self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'checkout-candidate') + self.git('--git-dir', repo / 'repository.git', 'worktree', 'remove', issue / 'checkout') + state.unlink() + self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'runtime-only') + self.assertTrue(run.path.exists()) + (issue / 'unknown').write_text('preserve') + self.assertEqual(runtime.inspect_issue_worktree_admission(root, 'owner', 'repo', 17), 'invalid') + + def test_inspection_cli_requires_live_repository_lease_and_uses_real_git(self): + import sys + from harness.lib import managed_lock_broker as broker + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp); repo = self.cache(root) + self.git('init', '--bare', repo / 'repository.git') + base = Path(__file__).resolve().parents[2] + cmd = [sys.executable, '-B', str(base / 'lib' / 'managed_lock_broker.py'), 'serve', + '--root', str(root), '--owner', 'owner', '--repository', 'repo', '--issue', '17', + '--role', 'gitea-impl-agent', '--run-id', 'cli-workflow', '--ttl-seconds', '30'] + process = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + try: + line = process.stdout.readline() + self.assertTrue(line, 'broker failed before ready') + ready = json.loads(line) + inspect = [sys.executable, '-B', str(base / 'lib' / 'runtime_root.py'), 'inspect-worktree', + '--root', str(root), '--owner', 'owner', '--repository', 'repo', '--issue', '17', + '--handle', ready['handle']] + result = subprocess.run(inspect, capture_output=True, text=True, timeout=10) + self.assertNotEqual(result.returncode, 0) + broker.request_handle(ready['handle'], 'acquire_repository') + result = subprocess.run(inspect, capture_output=True, text=True, timeout=10) + self.assertEqual(result.returncode, 0, result.stdout + result.stderr) + self.assertEqual(json.loads(result.stdout)['container'], 'runtime-only') + broker.request_handle(ready['handle'], 'release_repository') + broker.request_handle(ready['handle'], 'release') + self.assertEqual(process.wait(timeout=10), 0) + finally: + if process.poll() is None: + process.terminate(); process.wait(timeout=10) + process.stdout.close(); process.stderr.close() + + def test_inspection_rejects_missing_cache_instead_of_creating_it(self): + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises((ValueError, OSError)): + runtime.inspect_issue_worktree_admission(Path(tmp), 'owner', 'repo', 17) + self.assertEqual(list(Path(tmp).iterdir()), []) diff --git a/harness/tests/common/test_verification_contract_docs.py b/harness/tests/common/test_verification_contract_docs.py index 1e07e39..35dd199 100644 --- a/harness/tests/common/test_verification_contract_docs.py +++ b/harness/tests/common/test_verification_contract_docs.py @@ -17,6 +17,36 @@ CANONICAL = "/repositories///worktrees/issue- class VerificationContractDocumentationTests(unittest.TestCase): + def test_runtime_policy_is_broker_owned_and_separate_from_verification(self): + text = CACHE.read_text(encoding="utf-8") + self.assertIn("runtime/", text) + self.assertIn("broker.handle", text) + self.assertIn("broker.sock", text) + self.assertIn("ready JSON", text) + self.assertIn("runtime 생성은 승인이나 admission을 의미하지 않는다", text) + self.assertIn("active resource", text) + self.assertIn("unknown", text) + self.assertIn("성공 기록을 실패로 덮어쓰지 않는다", text) + for path in SKILLS.values(): + with self.subTest(path=path.name): + role = path.read_text(encoding="utf-8") + gate = role.split("## Managed-lock broker gate", 1)[1].split("\n## ", 1)[0] + self.assertIn("broker-generated", gate) + self.assertIn("ready JSON", gate) + self.assertNotIn("pre-created", gate) + self.assertNotIn("pre-create", gate) + + def test_role_runtime_startup_precedes_and_does_not_replace_admission(self): + roles = [path for path in (ROOT / 'harness/agents').rglob('SKILL.md') + if 'broker-generated runtime' in path.read_text()] + self.assertEqual(len(roles), 6) + for path in roles: + with self.subTest(role=path.parent.name): + gate = path.read_text().split('## Managed-lock broker gate', 1)[1].split('\n## ', 1)[0] + self.assertIn('ready 이후 각 workflow의 진입 검증·승인 조건을 확인한다', gate) + self.assertIn('Runtime 생성은 approval이나 worktree admission을 대신하지 않는다', gate) + self.assertIn('ready JSON의 `handle`/`socket`', gate) + def test_evidence_plan_disables_python_bytecode_in_clean_checkout(self): import json template = ROOT / "harness/agents/impl/skills/gitea-issue-rework/templates/evidence-plan.json" diff --git a/harness/tests/common/test_verification_root.py b/harness/tests/common/test_verification_root.py index 97d26a1..0fa3134 100644 --- a/harness/tests/common/test_verification_root.py +++ b/harness/tests/common/test_verification_root.py @@ -184,6 +184,7 @@ class VerificationRootTests(unittest.TestCase): locks.mkdir(mode=0o700) (locks / "repository.lock").touch(mode=0o600) broker_script = Path(verification_root.broker.__file__) + self.handle = self.issue / "runtime/broker-run/broker.handle" socket_path = self.handle.parent / "broker.sock" command = [ sys.executable,