Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5f65e45895 | ||
|
|
b915386ccb | ||
|
|
e5f8209f57 | ||
|
|
4420b2afcb | ||
|
|
057e915664 | ||
|
|
89ed795e33 | ||
|
|
f18c7f1b51 | ||
|
|
9d2843c6b0 | ||
|
|
da0474ce96 | ||
|
|
e48c93ca28 | ||
|
|
97f8340d9a | ||
|
|
11944ef3f4 |
@@ -0,0 +1,832 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Durable online task protocol for external AIOS agents.
|
||||
|
||||
The store is deliberately independent from Git/Gitea mutation. It provides a
|
||||
small durable state machine for publishing work, atomic claim/lease ownership,
|
||||
heartbeat, idempotent result submission, explicit server-side verification,
|
||||
lease recovery, and audit events.
|
||||
|
||||
No plaintext agent credential or task lease token is persisted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
import uuid
|
||||
|
||||
from aios_capability_contract import has_capabilities, normalize_required_capabilities
|
||||
|
||||
|
||||
TERMINAL_STATES = frozenset({"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED"})
|
||||
ACTIVE_LEASE_STATES = frozenset({"CLAIMED", "RUNNING"})
|
||||
TASK_STATES = frozenset({
|
||||
"QUEUED", "CLAIMED", "RUNNING", "VERIFYING",
|
||||
"SUCCEEDED", "FAILED", "EXPIRED", "CANCELLED",
|
||||
})
|
||||
VERIFY_DECISIONS = frozenset({"SUCCEEDED", "NEEDS_REWORK", "FAILED"})
|
||||
|
||||
|
||||
class TaskProtocolError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = http_status
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Principal:
|
||||
kind: str
|
||||
identifier: str
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def iso(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def parse_time(value: str | None) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = datetime.fromisoformat(value)
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def digest_json(value: Any) -> str:
|
||||
return hashlib.sha256(canonical_json(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _string_list(value: Iterable[object] | None, field: str, *, max_items: int = 128) -> list[str]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Iterable):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
text = str(item).strip()
|
||||
if not text or len(text) > 160:
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result.append(text)
|
||||
if len(result) > max_items:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 400)
|
||||
return sorted(set(result))
|
||||
|
||||
|
||||
def _json_object(value: object | None, field: str, *, max_bytes: int = 128 * 1024) -> dict[str, Any]:
|
||||
if value is None:
|
||||
return {}
|
||||
if not isinstance(value, Mapping):
|
||||
raise TaskProtocolError(field.upper() + "_INVALID", 400)
|
||||
result = dict(value)
|
||||
if len(canonical_json(result).encode("utf-8")) > max_bytes:
|
||||
raise TaskProtocolError(field.upper() + "_TOO_LARGE", 413)
|
||||
return result
|
||||
|
||||
|
||||
def _criteria(value: object | None) -> list[Any]:
|
||||
if value is None:
|
||||
return []
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, list):
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_INVALID", 400)
|
||||
if len(value) > 128:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
if len(canonical_json(value).encode("utf-8")) > 128 * 1024:
|
||||
raise TaskProtocolError("SUCCESS_CRITERIA_TOO_LARGE", 413)
|
||||
return list(value)
|
||||
|
||||
|
||||
class TaskStore:
|
||||
"""SQLite-backed durable task store.
|
||||
|
||||
SQLite BEGIN IMMEDIATE serializes claims so two agents cannot successfully
|
||||
claim the same queued task. WAL mode allows concurrent readers.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
now: Callable[[], datetime] = utcnow,
|
||||
token_pepper: str = "",
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.now = now
|
||||
self.token_pepper = token_pepper
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=10, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
connection.execute("PRAGMA busy_timeout=10000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
agent_type TEXT NOT NULL,
|
||||
agent_version TEXT NOT NULL,
|
||||
capabilities_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
status TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
last_seen_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
task_id TEXT PRIMARY KEY,
|
||||
goal TEXT NOT NULL,
|
||||
instructions TEXT NOT NULL,
|
||||
success_criteria_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
constraints_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
deadline TEXT,
|
||||
priority INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
attempt INTEGER NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL,
|
||||
claimed_by TEXT,
|
||||
lease_token_hash TEXT,
|
||||
lease_expires_at TEXT,
|
||||
result_json TEXT,
|
||||
result_digest TEXT,
|
||||
result_idempotency_key TEXT,
|
||||
verification_json TEXT,
|
||||
revision INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY(claimed_by) REFERENCES agents(agent_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS idempotency (
|
||||
scope TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
payload_digest TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(scope, key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
event_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
task_id TEXT,
|
||||
agent_id TEXT,
|
||||
event_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_queue
|
||||
ON tasks(status, priority DESC, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_tasks_lease
|
||||
ON tasks(status, lease_expires_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_events_task
|
||||
ON events(task_id, event_id);
|
||||
"""
|
||||
)
|
||||
|
||||
def _token_hash(self, token: str) -> str:
|
||||
return hashlib.sha256((self.token_pepper + "\0" + token).encode("utf-8")).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _row_task(row: sqlite3.Row, *, include_result: bool = True) -> dict[str, Any]:
|
||||
value: dict[str, Any] = {
|
||||
"task_id": row["task_id"],
|
||||
"goal": row["goal"],
|
||||
"instructions": row["instructions"],
|
||||
"success_criteria": json.loads(row["success_criteria_json"]),
|
||||
"required_capabilities": json.loads(row["required_capabilities_json"]),
|
||||
"constraints": json.loads(row["constraints_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
"deadline": row["deadline"],
|
||||
"priority": row["priority"],
|
||||
"max_attempts": row["max_attempts"],
|
||||
"attempt": row["attempt"],
|
||||
"status": row["status"],
|
||||
"claimed_by": row["claimed_by"],
|
||||
"lease_expires_at": row["lease_expires_at"],
|
||||
"revision": row["revision"],
|
||||
"created_at": row["created_at"],
|
||||
"updated_at": row["updated_at"],
|
||||
}
|
||||
if include_result:
|
||||
value["result"] = json.loads(row["result_json"]) if row["result_json"] else None
|
||||
value["verification"] = (
|
||||
json.loads(row["verification_json"]) if row["verification_json"] else None
|
||||
)
|
||||
return value
|
||||
|
||||
def _event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
event_type: str,
|
||||
*,
|
||||
task_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(task_id, agent_id, event_type, canonical_json(dict(payload or {})), iso(self.now())),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
agent_id: str,
|
||||
agent_type: str,
|
||||
agent_version: str,
|
||||
capabilities: Iterable[object],
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
agent_id = agent_id.strip()
|
||||
agent_type = agent_type.strip()
|
||||
agent_version = agent_version.strip()
|
||||
if not agent_id or len(agent_id) > 160 or not agent_type or not agent_version:
|
||||
raise TaskProtocolError("AGENT_IDENTITY_INVALID", 400)
|
||||
caps = _string_list(capabilities, "capabilities")
|
||||
meta = _json_object(metadata, "metadata")
|
||||
token = "agt_" + secrets.token_urlsafe(32)
|
||||
now = iso(self.now())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"INSERT INTO agents(agent_id,agent_type,agent_version,capabilities_json,"
|
||||
"metadata_json,token_hash,status,created_at,last_seen_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
agent_id,
|
||||
agent_type,
|
||||
agent_version,
|
||||
canonical_json(caps),
|
||||
canonical_json(meta),
|
||||
self._token_hash(token),
|
||||
"ACTIVE",
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"AGENT_REGISTERED",
|
||||
agent_id=agent_id,
|
||||
payload={"agent_type": agent_type, "agent_version": agent_version},
|
||||
)
|
||||
connection.commit()
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("AGENT_ALREADY_EXISTS", 409) from exc
|
||||
return {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": agent_type,
|
||||
"agent_version": agent_version,
|
||||
"capabilities": caps,
|
||||
"agent_token": token,
|
||||
"credential_returned_once": True,
|
||||
}
|
||||
|
||||
def authenticate_agent(self, token: str) -> dict[str, Any]:
|
||||
if not token:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
token_hash = self._token_hash(token)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM agents WHERE token_hash=? AND status='ACTIVE'",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("UNAUTHORIZED", 401)
|
||||
connection.execute(
|
||||
"UPDATE agents SET last_seen_at=? WHERE agent_id=?",
|
||||
(iso(self.now()), row["agent_id"]),
|
||||
)
|
||||
return {
|
||||
"agent_id": row["agent_id"],
|
||||
"agent_type": row["agent_type"],
|
||||
"agent_version": row["agent_version"],
|
||||
"capabilities": json.loads(row["capabilities_json"]),
|
||||
"metadata": json.loads(row["metadata_json"]),
|
||||
}
|
||||
|
||||
def _idempotent_lookup(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
) -> dict[str, Any] | None:
|
||||
row = connection.execute(
|
||||
"SELECT payload_digest,response_json FROM idempotency WHERE scope=? AND key=?",
|
||||
(scope, key),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
if not hmac.compare_digest(row["payload_digest"], payload_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
return json.loads(row["response_json"])
|
||||
|
||||
def _idempotent_store(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
scope: str,
|
||||
key: str,
|
||||
payload_digest: str,
|
||||
response: Mapping[str, Any],
|
||||
) -> None:
|
||||
connection.execute(
|
||||
"INSERT INTO idempotency(scope,key,payload_digest,response_json,created_at)"
|
||||
" VALUES(?,?,?,?,?)",
|
||||
(scope, key, payload_digest, canonical_json(dict(response)), iso(self.now())),
|
||||
)
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
goal: str,
|
||||
instructions: str,
|
||||
success_criteria: object | None,
|
||||
required_capabilities: Iterable[object] | None = None,
|
||||
constraints: Mapping[str, Any] | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
deadline: str | None = None,
|
||||
priority: int = 0,
|
||||
max_attempts: int = 3,
|
||||
idempotency_key: str,
|
||||
) -> dict[str, Any]:
|
||||
task_id = (task_id or ("task-" + uuid.uuid4().hex)).strip()
|
||||
goal = goal.strip()
|
||||
instructions = instructions.strip()
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not task_id or len(task_id) > 180 or not goal or not instructions:
|
||||
raise TaskProtocolError("TASK_BINDING_INVALID", 400)
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
if not isinstance(priority, int) or isinstance(priority, bool) or not -100 <= priority <= 100:
|
||||
raise TaskProtocolError("PRIORITY_INVALID", 400)
|
||||
if not isinstance(max_attempts, int) or isinstance(max_attempts, bool) or not 1 <= max_attempts <= 20:
|
||||
raise TaskProtocolError("MAX_ATTEMPTS_INVALID", 400)
|
||||
parsed_deadline = parse_time(deadline)
|
||||
if parsed_deadline and parsed_deadline <= self.now():
|
||||
raise TaskProtocolError("DEADLINE_INVALID", 400)
|
||||
criteria = _criteria(success_criteria)
|
||||
required = _string_list(required_capabilities, "required_capabilities")
|
||||
normalized_required = normalize_required_capabilities(required)
|
||||
if any(value.startswith("exact:") for value in normalized_required):
|
||||
raise TaskProtocolError("TASK_CAPABILITY_UNKNOWN", 403)
|
||||
constraints_value = _json_object(constraints, "constraints")
|
||||
metadata_value = _json_object(metadata, "metadata")
|
||||
payload = {
|
||||
"task_id": task_id,
|
||||
"goal": goal,
|
||||
"instructions": instructions,
|
||||
"success_criteria": criteria,
|
||||
"required_capabilities": required,
|
||||
"constraints": constraints_value,
|
||||
"metadata": metadata_value,
|
||||
"deadline": iso(parsed_deadline) if parsed_deadline else None,
|
||||
"priority": priority,
|
||||
"max_attempts": max_attempts,
|
||||
}
|
||||
payload_digest = digest_json(payload)
|
||||
now = iso(self.now())
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
replay = self._idempotent_lookup(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
)
|
||||
if replay is not None:
|
||||
connection.commit()
|
||||
return {**replay, "idempotent": True}
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO tasks(task_id,goal,instructions,success_criteria_json,"
|
||||
"required_capabilities_json,constraints_json,metadata_json,deadline,priority,"
|
||||
"max_attempts,attempt,status,revision,created_at,updated_at)"
|
||||
" VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
task_id,
|
||||
goal,
|
||||
instructions,
|
||||
canonical_json(criteria),
|
||||
canonical_json(required),
|
||||
canonical_json(constraints_value),
|
||||
canonical_json(metadata_value),
|
||||
payload["deadline"],
|
||||
priority,
|
||||
max_attempts,
|
||||
0,
|
||||
"QUEUED",
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError as exc:
|
||||
raise TaskProtocolError("TASK_ID_CONFLICT", 409) from exc
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
result = self._row_task(row)
|
||||
self._idempotent_store(
|
||||
connection,
|
||||
scope="create_task",
|
||||
key=idempotency_key,
|
||||
payload_digest=payload_digest,
|
||||
response=result,
|
||||
)
|
||||
self._event(connection, "TASK_CREATED", task_id=task_id, payload={"priority": priority})
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _expire_locked(self, connection: sqlite3.Connection) -> None:
|
||||
now_dt = self.now()
|
||||
now = iso(now_dt)
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status IN ('QUEUED','CLAIMED','RUNNING')"
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
deadline = parse_time(row["deadline"])
|
||||
if deadline is not None and deadline <= now_dt:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='EXPIRED',claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(now, row["task_id"]),
|
||||
)
|
||||
self._event(connection, "TASK_EXPIRED", task_id=row["task_id"])
|
||||
continue
|
||||
if row["status"] in ACTIVE_LEASE_STATES:
|
||||
lease_expires = parse_time(row["lease_expires_at"])
|
||||
if lease_expires is not None and lease_expires <= now_dt:
|
||||
if row["attempt"] >= row["max_attempts"]:
|
||||
next_state = "FAILED"
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
if next_state == "FAILED":
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(next_state, now, row["task_id"]),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"LEASE_EXPIRED",
|
||||
task_id=row["task_id"],
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"next_state": next_state, "attempt": row["attempt"]},
|
||||
)
|
||||
|
||||
def expire_leases(self) -> int:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
before = connection.total_changes
|
||||
self._expire_locked(connection)
|
||||
changed = connection.total_changes - before
|
||||
connection.commit()
|
||||
return changed
|
||||
|
||||
def poll_tasks(self, *, agent_id: str, limit: int = 20) -> list[dict[str, Any]]:
|
||||
if not 1 <= int(limit) <= 100:
|
||||
raise TaskProtocolError("LIMIT_INVALID", 400)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
agent = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if agent is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
capabilities = json.loads(agent["capabilities_json"])
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM tasks WHERE status='QUEUED' ORDER BY priority DESC,created_at ASC"
|
||||
).fetchall()
|
||||
result = []
|
||||
for row in rows:
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if has_capabilities(capabilities, required):
|
||||
result.append(self._row_task(row, include_result=False))
|
||||
if len(result) >= int(limit):
|
||||
break
|
||||
connection.commit()
|
||||
return result
|
||||
|
||||
def _agent_capabilities(self, connection: sqlite3.Connection, agent_id: str) -> set[str]:
|
||||
row = connection.execute(
|
||||
"SELECT capabilities_json FROM agents WHERE agent_id=? AND status='ACTIVE'",
|
||||
(agent_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("AGENT_NOT_FOUND", 404)
|
||||
return set(json.loads(row["capabilities_json"]))
|
||||
|
||||
def claim_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(lease_seconds, int) or isinstance(lease_seconds, bool) or not 30 <= lease_seconds <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
lease_token = "lease_" + secrets.token_urlsafe(32)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "QUEUED":
|
||||
raise TaskProtocolError("TASK_NOT_CLAIMABLE", 409)
|
||||
capabilities = self._agent_capabilities(connection, agent_id)
|
||||
required = json.loads(row["required_capabilities_json"])
|
||||
if not has_capabilities(capabilities, required):
|
||||
raise TaskProtocolError("AGENT_CAPABILITY_MISMATCH", 403)
|
||||
attempt = int(row["attempt"]) + 1
|
||||
if attempt > int(row["max_attempts"]):
|
||||
raise TaskProtocolError("TASK_ATTEMPTS_EXHAUSTED", 409)
|
||||
expires = self.now() + timedelta(seconds=lease_seconds)
|
||||
now = iso(self.now())
|
||||
updated = connection.execute(
|
||||
"UPDATE tasks SET status='CLAIMED',claimed_by=?,lease_token_hash=?,"
|
||||
"lease_expires_at=?,attempt=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=? AND status='QUEUED'",
|
||||
(
|
||||
agent_id,
|
||||
self._token_hash(lease_token),
|
||||
iso(expires),
|
||||
attempt,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise TaskProtocolError("TASK_CLAIM_CONFLICT", 409)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_CLAIMED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"attempt": attempt, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return {
|
||||
**self._row_task(row, include_result=False),
|
||||
"lease_token": lease_token,
|
||||
"lease_token_returned_once": True,
|
||||
}
|
||||
|
||||
def _validate_lease(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
allowed_states: frozenset[str] = ACTIVE_LEASE_STATES,
|
||||
) -> sqlite3.Row:
|
||||
self._expire_locked(connection)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] not in allowed_states:
|
||||
raise TaskProtocolError("TASK_LEASE_NOT_ACTIVE", 409)
|
||||
if row["claimed_by"] != agent_id:
|
||||
raise TaskProtocolError("TASK_LEASE_OWNER_MISMATCH", 403)
|
||||
if not row["lease_token_hash"] or not hmac.compare_digest(
|
||||
row["lease_token_hash"], self._token_hash(lease_token)
|
||||
):
|
||||
raise TaskProtocolError("TASK_LEASE_TOKEN_INVALID", 403)
|
||||
expires = parse_time(row["lease_expires_at"])
|
||||
if expires is None or expires <= self.now():
|
||||
raise TaskProtocolError("TASK_LEASE_EXPIRED", 409)
|
||||
return row
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
progress: Mapping[str, Any] | None = None,
|
||||
lease_seconds: int = 300,
|
||||
) -> dict[str, Any]:
|
||||
if not 30 <= int(lease_seconds) <= 3600:
|
||||
raise TaskProtocolError("LEASE_SECONDS_INVALID", 400)
|
||||
progress_value = _json_object(progress, "progress", max_bytes=64 * 1024)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
expires = self.now() + timedelta(seconds=int(lease_seconds))
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='RUNNING',lease_expires_at=?,revision=revision+1,"
|
||||
"updated_at=? WHERE task_id=?",
|
||||
(iso(expires), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_HEARTBEAT",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"progress": progress_value, "lease_expires_at": iso(expires)},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row, include_result=False)
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
lease_token: str,
|
||||
idempotency_key: str,
|
||||
result: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
idempotency_key = idempotency_key.strip()
|
||||
if not idempotency_key or len(idempotency_key) > 160:
|
||||
raise TaskProtocolError("IDEMPOTENCY_KEY_INVALID", 400)
|
||||
result_value = _json_object(result, "result", max_bytes=1024 * 1024)
|
||||
result_digest = digest_json(result_value)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["result_idempotency_key"] == idempotency_key:
|
||||
if not row["result_digest"] or not hmac.compare_digest(row["result_digest"], result_digest):
|
||||
raise TaskProtocolError("IDEMPOTENCY_CONFLICT", 409)
|
||||
connection.commit()
|
||||
return {**self._row_task(row), "idempotent": True}
|
||||
self._validate_lease(
|
||||
connection,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
lease_token=lease_token,
|
||||
)
|
||||
now = iso(self.now())
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='VERIFYING',result_json=?,result_digest=?,"
|
||||
"result_idempotency_key=?,lease_token_hash=NULL,lease_expires_at=NULL,"
|
||||
"revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(
|
||||
canonical_json(result_value),
|
||||
result_digest,
|
||||
idempotency_key,
|
||||
now,
|
||||
task_id,
|
||||
),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"RESULT_SUBMITTED",
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
payload={"result_digest": result_digest, "idempotency_key": idempotency_key},
|
||||
)
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
|
||||
def verify_task(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
decision: str,
|
||||
verification: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
decision = decision.strip().upper()
|
||||
if decision not in VERIFY_DECISIONS:
|
||||
raise TaskProtocolError("VERIFICATION_DECISION_INVALID", 400)
|
||||
verification_value = _json_object(verification, "verification")
|
||||
verification_value = {"decision": decision, **verification_value}
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] != "VERIFYING":
|
||||
raise TaskProtocolError("TASK_NOT_VERIFYING", 409)
|
||||
if decision == "SUCCEEDED":
|
||||
next_state = "SUCCEEDED"
|
||||
elif decision == "FAILED":
|
||||
next_state = "FAILED"
|
||||
elif int(row["attempt"]) >= int(row["max_attempts"]):
|
||||
next_state = "FAILED"
|
||||
verification_value["reason"] = verification_value.get(
|
||||
"reason", "MAX_ATTEMPTS_EXHAUSTED"
|
||||
)
|
||||
else:
|
||||
next_state = "QUEUED"
|
||||
now = iso(self.now())
|
||||
if next_state in {"SUCCEEDED", "FAILED"}:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
else:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status=?,claimed_by=NULL,lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,verification_json=?,revision=revision+1,updated_at=? "
|
||||
"WHERE task_id=?",
|
||||
(next_state, canonical_json(verification_value), now, task_id),
|
||||
)
|
||||
self._event(
|
||||
connection,
|
||||
"TASK_VERIFIED",
|
||||
task_id=task_id,
|
||||
agent_id=row["claimed_by"],
|
||||
payload={"decision": decision, "next_state": next_state},
|
||||
)
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def cancel_task(self, *, task_id: str, reason: str = "") -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
if row["status"] in TERMINAL_STATES:
|
||||
connection.commit()
|
||||
return self._row_task(row)
|
||||
connection.execute(
|
||||
"UPDATE tasks SET status='CANCELLED',lease_token_hash=NULL,"
|
||||
"lease_expires_at=NULL,revision=revision+1,updated_at=? WHERE task_id=?",
|
||||
(iso(self.now()), task_id),
|
||||
)
|
||||
self._event(connection, "TASK_CANCELLED", task_id=task_id, payload={"reason": reason})
|
||||
updated = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
connection.commit()
|
||||
return self._row_task(updated)
|
||||
|
||||
def get_task(self, task_id: str) -> dict[str, Any]:
|
||||
self.expire_leases()
|
||||
with self._connect() as connection:
|
||||
row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (task_id,)).fetchone()
|
||||
if row is None:
|
||||
raise TaskProtocolError("TASK_NOT_FOUND", 404)
|
||||
return self._row_task(row)
|
||||
|
||||
def get_events(self, task_id: str, *, limit: int = 200) -> list[dict[str, Any]]:
|
||||
with self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT * FROM events WHERE task_id=? ORDER BY event_id DESC LIMIT ?",
|
||||
(task_id, max(1, min(int(limit), 1000))),
|
||||
).fetchall()
|
||||
return [
|
||||
{
|
||||
"event_id": row["event_id"],
|
||||
"task_id": row["task_id"],
|
||||
"agent_id": row["agent_id"],
|
||||
"event_type": row["event_type"],
|
||||
"payload": json.loads(row["payload_json"]),
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
for row in reversed(rows)
|
||||
]
|
||||
@@ -6,7 +6,7 @@ The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
@@ -18,7 +18,7 @@ import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget,EvidenceEnvelope
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
@@ -69,6 +69,7 @@ class BaselineService:
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
self.store.evidence_attestation_verifier=self._verify_task_evidence
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
@@ -379,11 +380,39 @@ class BaselineService:
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
with self.store._transaction():
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
evidence=EvidenceEnvelope.create(task_id=task.task_id,claim_id=claim.claim_id,
|
||||
kind='diagnostic',worker_identity=claim.worker_identity,payload=envelope,
|
||||
previous_evidence_hash=self.store._last_evidence_hash(task.task_id),
|
||||
attestation_ref='baseline-machine:'+self.signer.key_id)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':self.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(self.signer.private_key.sign(proof)).decode())
|
||||
self.store.complete_claim_with_evidence_and_event(task.task_id,claim.claim_id,
|
||||
'COMPLETED',envelope,[evidence])
|
||||
|
||||
def _verify_task_evidence(self,evidence):
|
||||
"""Verify the TaskPlane wrapper and its separately signed operation receipt."""
|
||||
try:
|
||||
if not isinstance(evidence,EvidenceEnvelope) or evidence.kind!='diagnostic': return False
|
||||
prefix='baseline-machine:'
|
||||
if not evidence.attestation_ref or not evidence.attestation_ref.startswith(prefix): return False
|
||||
key_id=evidence.attestation_ref[len(prefix):]
|
||||
key=self.signer.trusted_public_keys.get(key_id)
|
||||
if key is None or not evidence.signature: return False
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':key_id,'evidence':evidence.digest_payload()})
|
||||
key.verify(base64.b64decode(evidence.signature,validate=True),proof)
|
||||
claim=self.store.get_claim(evidence.claim_id)
|
||||
task=self.store.get_task(evidence.task_id)
|
||||
if claim.task_id!=task.task_id or claim.worker_identity!=evidence.worker_identity: return False
|
||||
if claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
return self._receipt_valid(task,evidence.payload)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Canonical fail-closed capability semantics for formal AgentTask eligibility."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
# These aliases are the verified C4 contract captured in
|
||||
# ``qa/capability-contract.txt``. Unknown task names become exact requirements;
|
||||
# unknown worker names are never granted.
|
||||
_TASK_ALIASES: dict[str, frozenset[str]] = {
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"repository_read": frozenset({"repo.read"}),
|
||||
"git.read": frozenset({"repo.read"}),
|
||||
"repo.read": frozenset({"repo.read"}),
|
||||
"test_execution": frozenset({"test.execute"}),
|
||||
"git.test": frozenset({"test.execute"}),
|
||||
"test.execute": frozenset({"test.execute"}),
|
||||
"governed_git_write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.git.write": frozenset({"governed.git.write.branch", "governed.git.write.file"}),
|
||||
"governed.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.write.file": frozenset({"governed.git.write.file"}),
|
||||
"shell": frozenset({"runtime.shell"}),
|
||||
"git": frozenset({"runtime.git"}),
|
||||
"python": frozenset({"runtime.python"}),
|
||||
"code.read": frozenset({"legacy.code.read"}),
|
||||
"code.review": frozenset({"legacy.code.review"}),
|
||||
"code.write": frozenset({"legacy.code.write"}),
|
||||
"code.write.patch": frozenset({"legacy.code.write.patch"}),
|
||||
"test.python": frozenset({"legacy.test.python"}),
|
||||
"test.shell.allowlisted": frozenset({"legacy.test.shell.allowlisted"}),
|
||||
"git.commit": frozenset({"legacy.git.commit"}),
|
||||
"git.diff.read": frozenset({"legacy.git.diff.read"}),
|
||||
"git.force_push": frozenset({"legacy.git.force_push"}),
|
||||
"deploy.production": frozenset({"legacy.deploy.production"}),
|
||||
"secret.transform": frozenset({"legacy.secret.transform"}),
|
||||
"secure_file_transformer": frozenset({"legacy.secure_file_transformer"}),
|
||||
"runtime.shadow": frozenset({"legacy.runtime.shadow"}),
|
||||
"review.security": frozenset({"legacy.review.security"}),
|
||||
"review.api-contract": frozenset({"legacy.review.api-contract"}),
|
||||
"gpu.train": frozenset({"legacy.gpu.train"}),
|
||||
}
|
||||
|
||||
_WORKER_ALIASES = {
|
||||
key: value for key, value in _TASK_ALIASES.items()
|
||||
if key not in {"release:candidate", "governed_git_write", "governed.git.write"}
|
||||
}
|
||||
_WORKER_ALIASES.update({
|
||||
"release:candidate": frozenset({"release:candidate"}),
|
||||
"governed.git.write.branch": frozenset({"governed.git.write.branch"}),
|
||||
"governed.git.write.file": frozenset({"governed.git.write.file"}),
|
||||
})
|
||||
|
||||
_EXACT_LIFECYCLE_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"production.plan.apply",
|
||||
"production.promote",
|
||||
"production.rollback",
|
||||
"task.principal.issue",
|
||||
"testing",
|
||||
"verification",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
for _capability in _EXACT_LIFECYCLE_CAPABILITIES:
|
||||
_TASK_ALIASES[_capability] = frozenset({_capability})
|
||||
_WORKER_ALIASES[_capability] = frozenset({_capability})
|
||||
_UNKNOWN_PREFIX = "exact:"
|
||||
|
||||
|
||||
def _clean(value: object) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("capability must be a string")
|
||||
text = value.strip().lower()
|
||||
if not text:
|
||||
raise ValueError("capability must not be empty")
|
||||
return text
|
||||
|
||||
|
||||
def normalize_required_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
result.add(_UNKNOWN_PREFIX + text)
|
||||
else:
|
||||
result.update(_TASK_ALIASES.get(text, frozenset({_UNKNOWN_PREFIX + text})))
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def normalize_worker_capabilities(values: Iterable[object]) -> frozenset[str]:
|
||||
result: set[str] = set()
|
||||
for value in values:
|
||||
text = _clean(value)
|
||||
if "*" in text:
|
||||
continue
|
||||
mapped = _WORKER_ALIASES.get(text)
|
||||
if mapped is not None:
|
||||
result.update(mapped)
|
||||
if {"governed.git.write.branch", "governed.git.write.file"}.issubset(result):
|
||||
result.add("governed.git.write")
|
||||
return frozenset(result)
|
||||
|
||||
|
||||
def has_capabilities(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> bool:
|
||||
required = normalize_required_capabilities(required_capabilities)
|
||||
effective = normalize_worker_capabilities(worker_capabilities)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in required):
|
||||
return False
|
||||
return required.issubset(effective)
|
||||
|
||||
|
||||
def required_capabilities_subset(granted_requirements: Iterable[object], required_requirements: Iterable[object]) -> bool:
|
||||
granted = normalize_required_capabilities(granted_requirements)
|
||||
required = normalize_required_capabilities(required_requirements)
|
||||
if any(value.startswith(_UNKNOWN_PREFIX) for value in granted | required):
|
||||
return False
|
||||
return required.issubset(granted)
|
||||
|
||||
|
||||
def normalized_capability_matrix(worker_capabilities: Iterable[object], required_capabilities: Iterable[object]) -> dict[str, object]:
|
||||
worker_values = tuple(worker_capabilities)
|
||||
required_values = tuple(required_capabilities)
|
||||
worker = normalize_worker_capabilities(worker_values)
|
||||
required = normalize_required_capabilities(required_values)
|
||||
return {
|
||||
"worker_effective": sorted(worker),
|
||||
"task_required": sorted(required),
|
||||
"eligible": has_capabilities(worker_values, required_values),
|
||||
}
|
||||
@@ -1342,21 +1342,39 @@ def main(
|
||||
BridgeHandler.principal_verifier = task_principal_verifier
|
||||
if task_admission_handlers is None:
|
||||
admission_runtime = None
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_environment,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
admission_runtime = expired_admission_runtime()
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, build_source_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_environment(
|
||||
post_admission=runtime.project_admitted_task,
|
||||
)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
)
|
||||
admission_runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(activation_raw),
|
||||
post_admission=runtime.project_admitted_task,
|
||||
if admission_runtime is None or task_principal_verifier is not None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw),
|
||||
)
|
||||
admission_runtime = AdmissionRuntimeRouter(admission_runtime, source_runtime).bundle()
|
||||
if admission_runtime is not None:
|
||||
task_admission_handlers = admission_runtime.handlers
|
||||
if BridgeHandler.principal_verifier is None:
|
||||
|
||||
@@ -0,0 +1,446 @@
|
||||
"""aios_owner_based_executor — Owner 基于授权的可执行 CLI 入口。
|
||||
|
||||
把 "owner 基于授权 → Agent 自主完成受治理写链" 从测试/装配层提升为**可直接执行的命令**。
|
||||
集成 V2R2C3 现场组合根 `field_governed_writer.py`,装配到生产入口
|
||||
`GovernedWriterAutomation.acquire_verified()`。
|
||||
|
||||
子命令:
|
||||
register <authorization_ref> 登记一条 Owner 授权到 authority(可 --policy-json 指定,
|
||||
默认用内置参考授权物 azn-owner-gw-v1)。
|
||||
acquire <authorization_ref> 装配 FieldGovernedWriterFactory,运行 acquire_verified(),
|
||||
输出 GovernedWriterSession JSON。
|
||||
|
||||
行为契约:
|
||||
- 生产入口唯一授权来源是 authority 已登记的 authorization_ref;未登记即 fail-closed。
|
||||
- authority 未装配(--no-authority 或构造时 owner_authority=None)→ AuthorizationDenied。
|
||||
- EXPIRED 任务无预签 recovery → OwnerActionRequired(OWNER_BOUNDARY,非自动恢复)。
|
||||
- status==error 输出到 stderr,退出码非 0;成功输出会话 JSON,退出码 0。
|
||||
- legacy_global_writes_enabled 默认 false = 安全不变式,不是 blocker。
|
||||
|
||||
用法示例:
|
||||
python aios_owner_based_executor.py register azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --branch-mode continue --head-oid <oid>
|
||||
python aios_owner_based_executor.py acquire azn-owner-gw-v1 --task-status EXPIRED # OwnerBoundary
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.field_governed_writer import (
|
||||
FieldGovernedWriterFactory,
|
||||
FieldOwnerAuthorizationAuthority,
|
||||
FieldRuntimeStatus,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
# 版本与示例授权引用
|
||||
VERSION = "V2R2C3"
|
||||
DEFAULT_AZN_REF = "azn-owner-gw-v1"
|
||||
|
||||
_AZN_CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
_AZN_OPS = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"epoch_reissue",
|
||||
"governed_commit",
|
||||
"heartbeat",
|
||||
"job_reconcile",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"successor_recovery",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"writer_lease",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 授权物 / 请求构造
|
||||
# ---------------------------------------------------------------------------
|
||||
def reference_policy(*, expires_at: float | None = None) -> OwnerAuthorizationPolicy:
|
||||
"""内置参考授权物 azn-owner-gw-v1(现场登记时 Owner 设定当下有效 expires_at)。"""
|
||||
if expires_at is None:
|
||||
expires_at = time.time() + 30 * 86400.0
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id=DEFAULT_AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
|
||||
|
||||
def _default_subject_repo_branch(task: str) -> tuple[str, str, str]:
|
||||
return ("aios-autonomous-admin", "aios/aios", f"agent/autonomy-{task}")
|
||||
|
||||
|
||||
def build_request(
|
||||
*,
|
||||
task_id: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
plan_digest: str,
|
||||
base_oid: str,
|
||||
generation: int,
|
||||
risk: str,
|
||||
caps: frozenset[str],
|
||||
files: tuple[str, ...],
|
||||
overrides: dict | None = None,
|
||||
) -> GovernedWriterRequest:
|
||||
subject, repo, branch = _default_subject_repo_branch(task_id)
|
||||
body = dict(
|
||||
subject=subject,
|
||||
task_id=task_id,
|
||||
repository=repo,
|
||||
target_branch=branch,
|
||||
risk=risk,
|
||||
generation=generation,
|
||||
base_oid=base_oid,
|
||||
required_capabilities=caps,
|
||||
file_allowlist=files,
|
||||
plan_digest=plan_digest,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if overrides:
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 依据配置构造 authority(含 --no-authority fail-closed 场景)
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_authority(
|
||||
*, policy_json_path: str | None, use_reference: bool, expires_at: float | None
|
||||
) -> FieldOwnerAuthorizationAuthority | None:
|
||||
if policy_json_path:
|
||||
data = json.loads(Path(policy_json_path).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(data["authorization_ref"], pol)
|
||||
return auth
|
||||
if use_reference:
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(DEFAULT_AZN_REF, reference_policy(expires_at=expires_at))
|
||||
return auth
|
||||
return None # authority 未装配 -> acquire_verified 直接 fail-closed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 子命令实现
|
||||
# ---------------------------------------------------------------------------
|
||||
def cmd_register(args: argparse.Namespace) -> int:
|
||||
expires_at = float(args.expires_at) if args.expires_at else None
|
||||
if args.policy_json:
|
||||
data = json.loads(Path(args.policy_json).read_text(encoding="utf-8"))
|
||||
pol = OwnerAuthorizationPolicy(**data["policy"])
|
||||
ref = data.get("authorization_ref", pol.authorization_id)
|
||||
else:
|
||||
pol = reference_policy(expires_at=expires_at)
|
||||
ref = DEFAULT_AZN_REF
|
||||
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(ref, pol)
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"status": "registered",
|
||||
"authorization_ref": ref,
|
||||
"policy_fingerprint": pol.policy_fingerprint,
|
||||
"subject": pol.subject,
|
||||
"repository": pol.repository,
|
||||
"allowed_branch_prefixes": list(pol.allowed_branch_prefixes),
|
||||
"risk_ceiling": pol.risk_ceiling,
|
||||
"expires_at": pol.expires_at,
|
||||
"note": "registered in-memory authority; field authority must persist",
|
||||
},
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_acquire(args: argparse.Namespace) -> int:
|
||||
from app.task_plane.governance_control_kernel import StaleManifest
|
||||
|
||||
# 使用独立工作目录构造 manifest + ledger,避免污染运行目录
|
||||
workdir = Path(args.workdir) if args.workdir else None
|
||||
if workdir is None:
|
||||
workdir = Path(tempfile.mkdtemp(prefix="gw_exec_"))
|
||||
|
||||
store = ManifestStore(str(workdir / "manifest.db"))
|
||||
ledger = ActionLedger(str(workdir / "ledger.db"))
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(
|
||||
DiscoveryObservation.build(
|
||||
worker="W1",
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
activation_digest="activation-exec",
|
||||
epoch_id="epoch-exec-prov",
|
||||
policy_digest="policy-exec",
|
||||
plan_provider_root="/plans/exec",
|
||||
task_id=args.task,
|
||||
grant_id="grant-exec",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
)
|
||||
manifest_id = store.freeze(barrier.freeze())
|
||||
|
||||
auth = build_authority(
|
||||
policy_json_path=args.policy_json,
|
||||
use_reference=args.reference,
|
||||
expires_at=float(args.expires_at) if args.expires_at else None,
|
||||
)
|
||||
|
||||
task_status = args.task_status.upper()
|
||||
task = TaskState(task_id=args.task, status=task_status, revision=1)
|
||||
|
||||
if args.branch_mode == "create":
|
||||
branch_exists, head_oid = False, None
|
||||
elif args.branch_mode == "continue":
|
||||
branch_exists, head_oid = True, args.head_oid
|
||||
else: # auto: 由 read 决定(默认新分支)
|
||||
branch_exists, head_oid = (True, args.head_oid) if args.head_oid else (False, None)
|
||||
|
||||
runtime_status = FieldRuntimeStatus(
|
||||
legacy_global_writes_enabled=args.legacy_global,
|
||||
task_principal_writes_enabled=args.principal_writes,
|
||||
task_principal_verifier_ready=args.verifier_ready,
|
||||
governed_write_blockers=tuple(args.blocker or ()),
|
||||
)
|
||||
|
||||
# fail-closed authority 场景:--no-authority 显式不装配
|
||||
if args.no_authority:
|
||||
auth = None
|
||||
|
||||
adapters = _make_adapters(
|
||||
authority=auth,
|
||||
manifest_store=store,
|
||||
action_ledger=ledger,
|
||||
task=task,
|
||||
branch_exists=branch_exists,
|
||||
head_oid=head_oid,
|
||||
repo=args.repo if args.repo else "aios/aios",
|
||||
branch=args.branch if args.branch else f"agent/autonomy-{args.task}",
|
||||
generation=args.generation,
|
||||
runtime_status=runtime_status,
|
||||
)
|
||||
if auth is None:
|
||||
adapters.owner_authority = None
|
||||
|
||||
automation = _automation(store, ledger)
|
||||
request = build_request(
|
||||
task_id=args.task,
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=args.runtime,
|
||||
process_start_identity=args.process_start,
|
||||
plan_digest=args.plan_digest,
|
||||
base_oid=args.base_oid,
|
||||
generation=args.generation,
|
||||
risk=args.risk,
|
||||
caps=set(_AZN_CAPS),
|
||||
files=tuple(args.files),
|
||||
)
|
||||
|
||||
try:
|
||||
session = automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=args.authorization_ref,
|
||||
readiness=runtime_status_to_readiness(runtime_status),
|
||||
epoch=None,
|
||||
adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
print(json.dumps({"status": "ok", "version": VERSION, "session": session.to_dict()},
|
||||
ensure_ascii=False, indent=2))
|
||||
return 0
|
||||
except OwnerActionRequired as exc:
|
||||
print(json.dumps({"status": "owner_boundary", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 3
|
||||
except AuthorizationDenied as exc:
|
||||
print(json.dumps({"status": "auth_denied", "version": VERSION, "reason": str(exc)},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 2
|
||||
except Exception as exc: # noqa: BLE001 - CLI 边界统一收口
|
||||
print(json.dumps({"status": "error", "version": VERSION,
|
||||
"error": f"{type(exc).__name__}: {exc}"},
|
||||
ensure_ascii=False, indent=2), file=sys.stderr)
|
||||
return 1
|
||||
finally:
|
||||
_cleanup_workdir(workdir)
|
||||
|
||||
|
||||
def _make_adapters(
|
||||
*,
|
||||
authority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task,
|
||||
branch_exists,
|
||||
head_oid,
|
||||
repo,
|
||||
branch,
|
||||
generation,
|
||||
runtime_status,
|
||||
):
|
||||
from app.task_plane.field_governed_writer import _DefaultExecutionPlane, _DefaultGit, \
|
||||
_DefaultJobs, _DefaultPrincipalIssuer, _DefaultTrustedSigner, _DefaultWriterLease
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAdapters
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(repository=repo, target_branch=branch, generation=generation),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
|
||||
def runtime_status_to_readiness(rs: FieldRuntimeStatus):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriteReadiness
|
||||
|
||||
return GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=rs.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=rs.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=rs.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(rs.governed_write_blockers),
|
||||
)
|
||||
|
||||
|
||||
def _automation(store, ledger):
|
||||
from app.task_plane.governed_writer_automation import GovernedWriterAutomation
|
||||
|
||||
return GovernedWriterAutomation(manifest_store=store, action_ledger=ledger)
|
||||
|
||||
|
||||
def _cleanup_workdir(workdir: Path) -> None:
|
||||
import gc
|
||||
import shutil
|
||||
|
||||
try:
|
||||
gc.collect()
|
||||
shutil.rmtree(workdir, ignore_errors=True)
|
||||
except Exception: # pragma: no cover - 清理失败不影响结果
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 参数与入口
|
||||
# ---------------------------------------------------------------------------
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
import tempfile # noqa: F401 (确保模块可见)
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="AIOS Owner 基于授权执行器 (V2R2C3)",
|
||||
prog="aios_owner_based_executor",
|
||||
)
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
p_register = sub.add_parser("register", help="登记 Owner 授权到 authority")
|
||||
p_register.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_register.add_argument("--policy-json", help="policy JSON 文件(含 authorization_ref + policy)")
|
||||
p_register.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_register.set_defaults(func=cmd_register)
|
||||
|
||||
p_acquire = sub.add_parser("acquire", help="运行 acquire_verified() 完成受治理写链")
|
||||
p_acquire.add_argument("authorization_ref", nargs="?", default=DEFAULT_AZN_REF)
|
||||
p_acquire.add_argument("--no-authority", action="store_true",
|
||||
help="显式不装配 authority -> fail-closed")
|
||||
p_acquire.add_argument("--policy-json", help="从文件装载授权物(替代内置参考)")
|
||||
p_acquire.add_argument("--reference", action="store_true",
|
||||
help="使用内置参考授权物 azn-owner-gw-v1")
|
||||
p_acquire.add_argument("--expires-at", help="override expires_at (unix)")
|
||||
p_acquire.add_argument("--task", default="task-gw-exec-0001")
|
||||
p_acquire.add_argument("--task-status", default="QUEUED",
|
||||
choices=["QUEUED", "CLAIMED", "RUNNING", "EXPIRED"])
|
||||
p_acquire.add_argument("--branch-mode", default="create", choices=["create", "continue", "auto"])
|
||||
p_acquire.add_argument("--head-oid", help="继续分支的 live HEAD oid")
|
||||
p_acquire.add_argument("--base-oid", default="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3")
|
||||
p_acquire.add_argument("--repo", help="仓库(默认 aios/aios)")
|
||||
p_acquire.add_argument("--branch", help="目标分支(默认 agent/autonomy-<task>)")
|
||||
p_acquire.add_argument("--generation", type=int, default=1)
|
||||
p_acquire.add_argument("--risk", default="medium")
|
||||
p_acquire.add_argument("--runtime", default="runtime-gw-exec")
|
||||
p_acquire.add_argument("--process-start", default="pid:gw:exec:1")
|
||||
p_acquire.add_argument("--plan-digest", default="sha256:plan-exec")
|
||||
p_acquire.add_argument("--files", nargs="*", default=[
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
"active-runtime/app/task_plane/field_governed_writer.py",
|
||||
])
|
||||
p_acquire.add_argument("--workdir", help="manifest/ledger 工作目录(默认临时)")
|
||||
# 运行时状态(W-RUNTIME 只读线)
|
||||
p_acquire.add_argument("--legacy-global", action="store_true",
|
||||
help="legacy_global_writes_enabled(默认 false=不变式)")
|
||||
p_acquire.add_argument("--principal-writes", action="store_true", default=True,
|
||||
help="task_principal_writes_enabled(默认 true)")
|
||||
p_acquire.add_argument("--verifier-ready", action="store_true", default=True,
|
||||
help="task_principal_verifier_ready(默认 true)")
|
||||
p_acquire.add_argument("--blocker", action="append",
|
||||
help="governed_write_blockers 追加")
|
||||
p_acquire.set_defaults(func=cmd_acquire)
|
||||
|
||||
parser.add_argument("--version", action="version", version=VERSION)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
return int(args.func(args))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import tempfile # noqa: F401
|
||||
|
||||
try:
|
||||
raise SystemExit(main())
|
||||
except KeyboardInterrupt:
|
||||
raise SystemExit(130)
|
||||
@@ -51,15 +51,30 @@ AUTHORIZATION_BUNDLE_SCHEMA = "aios.owner-authorization-bundle.v1"
|
||||
MACHINE_CAPABILITY_SCHEMA = "aios.machine-derived-capability.v1"
|
||||
BOOTSTRAP_STATE_SCHEMA = "aios.single-host-bootstrap-state.v1"
|
||||
OWNER_EPOCH_SCHEMA = "aios.owner-delegation-epoch.v2"
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA = "aios.source-publication-policy.v1"
|
||||
SOURCE_PUBLICATION_POLICY_ID = "C5_SOURCE_PUBLICATION_ONLY"
|
||||
SOURCE_PUBLICATION_CAPABILITIES = frozenset(
|
||||
{
|
||||
"agent-task:control",
|
||||
"governed.task.create",
|
||||
"governed.write.files",
|
||||
"task.principal.issue",
|
||||
"writer.lease",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY_PATH = Path("/etc/aios/single-host-signer-registry.json")
|
||||
DEFAULT_OWNER_SIGNER_ID = "local-owner-authority"
|
||||
DEFAULT_MACHINE_SIGNER_ID = "machine-operational"
|
||||
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_RISK = {"low": 0, "medium": 1, "high": 2}
|
||||
# ``engineering`` is deliberately between medium and high. Adding this rank
|
||||
# does not raise any configured ceiling; existing policy files keep their
|
||||
# existing value (the normal local-owner policy is ``medium``).
|
||||
_RISK = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
_MAX_JSON = 128 * 1024
|
||||
_MAX_KEY = 64 * 1024
|
||||
_MAGIC = b"SSHSIG"
|
||||
@@ -691,6 +706,30 @@ class OwnerAuthorizationBundle:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SourcePublicationPolicyRequest:
|
||||
"""One exact, source-only unattended publication authorization request."""
|
||||
|
||||
policy_id: str
|
||||
repository: str
|
||||
authority_base_oid: str
|
||||
source_oid: str
|
||||
source_tree_oid: str
|
||||
target_agent: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
execution_digest: str
|
||||
exact_files: tuple[str, ...]
|
||||
nonce: str
|
||||
ttl_seconds: int
|
||||
risk: str = "engineering"
|
||||
production_impact: str = "none"
|
||||
renewal: str = "none"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "exact_files", tuple(self.exact_files))
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCapabilityRequest:
|
||||
capability_id: str
|
||||
@@ -755,20 +794,28 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
key_path: Path | None,
|
||||
policy: SingleHostOwnerPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Any = time.time,
|
||||
registered_signer: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
key_path,
|
||||
key_path or Path("/dev/null"),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.policy = policy
|
||||
self.clock = clock
|
||||
self.registered_signer = registered_signer
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
if self.registered_signer is not None:
|
||||
return str(self.registered_signer.fingerprint)
|
||||
return super().fingerprint
|
||||
|
||||
@staticmethod
|
||||
def _prefix_narrows(requested: str, allowed: Sequence[str]) -> bool:
|
||||
@@ -831,6 +878,111 @@ class LocalOwnerAuthority(LocalFileSigner):
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
def _sign_canonical(self, value: Mapping[str, Any]) -> SignedArtifact:
|
||||
if self.registered_signer is None:
|
||||
return super()._sign_canonical(value)
|
||||
payload = canonical_json_bytes(dict(value))
|
||||
signature = self.registered_signer._sign_canonical_payload(
|
||||
self.namespace, payload
|
||||
)
|
||||
return SignedArtifact(
|
||||
payload=dict(value),
|
||||
canonical_payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.fingerprint,
|
||||
namespace=self.namespace,
|
||||
)
|
||||
|
||||
def issue_source_publication_policy(
|
||||
self, request: SourcePublicationPolicyRequest
|
||||
) -> SignedArtifact:
|
||||
"""Sign one exact C5 source-publication policy.
|
||||
|
||||
This is an issuance operation only. It cannot change the configured
|
||||
owner policy, its ceiling, branch/file allowlists, or trust domain.
|
||||
"""
|
||||
now = int(self.clock())
|
||||
files = tuple(request.exact_files)
|
||||
def source_scope_narrows(value: str, allowed: Sequence[str]) -> bool:
|
||||
base = value.rstrip("/")
|
||||
return any(
|
||||
base == prefix.rstrip("/")
|
||||
or base.startswith(prefix.rstrip("/") + "/")
|
||||
for prefix in allowed
|
||||
)
|
||||
if (
|
||||
request.policy_id != SOURCE_PUBLICATION_POLICY_ID
|
||||
or request.repository != self.policy.repository
|
||||
or not _HEX40.fullmatch(request.authority_base_oid)
|
||||
or not _HEX40.fullmatch(request.source_oid)
|
||||
or not _HEX40.fullmatch(request.source_tree_oid)
|
||||
or not _ID.fullmatch(request.target_agent)
|
||||
or not request.target_agent.startswith("agent/")
|
||||
or not source_scope_narrows(
|
||||
request.target_branch, self.policy.allowed_branch_prefixes
|
||||
)
|
||||
or not request.target_branch.startswith("agent/")
|
||||
or "\\" in request.target_branch
|
||||
or ".." in PurePosixPath(request.target_branch).parts
|
||||
or request.target_branch.endswith(("/", "."))
|
||||
or type(request.generation) is not int
|
||||
or request.generation < 1
|
||||
or not _HEX64.fullmatch(request.execution_digest)
|
||||
or not files
|
||||
or any(
|
||||
not item
|
||||
or PurePosixPath(item).is_absolute()
|
||||
or "\\" in item
|
||||
or ".." in PurePosixPath(item).parts
|
||||
or item.startswith(".git")
|
||||
or not source_scope_narrows(item, self.policy.allowed_file_prefixes)
|
||||
for item in files
|
||||
)
|
||||
or not SOURCE_PUBLICATION_CAPABILITIES.issubset(self.policy.allowed_capabilities)
|
||||
or len(set(files)) != len(files)
|
||||
or not _ID.fullmatch(request.nonce)
|
||||
or request.risk != "engineering"
|
||||
or request.risk not in _RISK
|
||||
or _RISK[request.risk] > _RISK[self.policy.risk_ceiling]
|
||||
or request.production_impact != "none"
|
||||
or request.renewal != "none"
|
||||
or type(request.ttl_seconds) is not int
|
||||
or not 1 <= request.ttl_seconds <= 3600
|
||||
or request.ttl_seconds > self.policy.maximum_epoch_ttl_seconds
|
||||
):
|
||||
_fail("SOURCE_PUBLICATION_REQUEST_WIDENS_POLICY")
|
||||
value: dict[str, Any] = {
|
||||
"schema": SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
"policy_id": SOURCE_PUBLICATION_POLICY_ID,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": request.repository,
|
||||
"trust_domain": self.policy.trust_domain,
|
||||
"policy_digest": self.policy.digest,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"source_oid": request.source_oid,
|
||||
"source_tree_oid": request.source_tree_oid,
|
||||
"target_agent": request.target_agent,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"exact_files": list(files),
|
||||
"capabilities": sorted(SOURCE_PUBLICATION_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": request.nonce,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 30),
|
||||
"expires_at": now + request.ttl_seconds,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.fingerprint,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return self._sign_canonical(value)
|
||||
|
||||
issue_source_only_policy = issue_source_publication_policy
|
||||
|
||||
def issue_authorization_bundle(
|
||||
self,
|
||||
*,
|
||||
|
||||
@@ -35,6 +35,8 @@ from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
SCHEMA_ENVELOPE = "aios.task-admission-envelope.v1"
|
||||
SCHEMA_GRANT = "aios.task-admission-grant.v1"
|
||||
SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"
|
||||
SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"
|
||||
SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})
|
||||
SCHEMA_RECEIPT = "aios.task-admission-receipt.v1"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
@@ -142,9 +144,30 @@ class OwnerDelegationEpoch:
|
||||
revocation_epoch: int
|
||||
policy_digest: str
|
||||
schema: str = SCHEMA_EPOCH
|
||||
production_scope: str | None = None
|
||||
destructive_policy: str | None = None
|
||||
# Provenance retained by the consumer. These fields are populated by the
|
||||
# signed-directory provider for v2 (and left at their v1-compatible
|
||||
# defaults when reading legacy epochs); they are never used to translate or
|
||||
# re-sign the original artifact.
|
||||
status: str = ""
|
||||
signature_namespace: str = ""
|
||||
signature_key_id: str = ""
|
||||
canonical_payload_digest: str = ""
|
||||
canonical_payload: bytes = b""
|
||||
|
||||
@property
|
||||
def issuer(self) -> str:
|
||||
"""The signed epoch issuer, retained under the legacy identity name."""
|
||||
return self.owner_identity
|
||||
|
||||
@property
|
||||
def canonical_payload_bytes(self) -> bytes:
|
||||
return self.canonical_payload
|
||||
|
||||
def as_public_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value.pop("canonical_payload", None)
|
||||
value["allowed_capabilities"] = sorted(self.allowed_capabilities)
|
||||
value["allowed_branch_prefixes"] = list(self.allowed_branch_prefixes)
|
||||
value["allowed_file_prefixes"] = list(self.allowed_file_prefixes)
|
||||
@@ -309,7 +332,7 @@ def _normalize_capabilities(
|
||||
|
||||
|
||||
def _risk_rank(value: str) -> int:
|
||||
ranks = {"low": 0, "medium": 1, "high": 2}
|
||||
ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
try:
|
||||
return ranks[value]
|
||||
except KeyError:
|
||||
@@ -838,7 +861,7 @@ class TaskAdmissionBootstrapService:
|
||||
now: int,
|
||||
) -> OwnerDelegationEpoch:
|
||||
epoch = self.authorization.verify_delegation_epoch(epoch_id)
|
||||
if epoch.schema != SCHEMA_EPOCH:
|
||||
if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)
|
||||
if epoch.epoch_id != epoch_id:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_ID_MISMATCH", 403)
|
||||
|
||||
@@ -26,6 +26,8 @@ import urllib.request
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
OwnerDelegationEpoch,
|
||||
SCHEMA_EPOCH,
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
TaskAdmissionGrant,
|
||||
canonical_json_bytes,
|
||||
@@ -44,6 +46,19 @@ from aios_gitops_job_identity import (
|
||||
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"
|
||||
_EPOCH_V1_FIELDS = frozenset({
|
||||
"epoch_id", "owner_identity", "trust_domain", "repository",
|
||||
"allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",
|
||||
"risk_ceiling", "issued_at", "not_before", "expires_at",
|
||||
"revocation_epoch", "policy_digest", "schema",
|
||||
})
|
||||
_EPOCH_V2_FIELDS = frozenset({
|
||||
*_EPOCH_V1_FIELDS,
|
||||
"business_goal", "production_scope", "destructive_policy",
|
||||
"signature_namespace", "signature_key_id", "canonical_payload_digest",
|
||||
"status",
|
||||
})
|
||||
_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")
|
||||
|
||||
|
||||
class OfficialTaskTransport(Protocol):
|
||||
@@ -463,6 +478,7 @@ class TaskAdmissionPrincipalProvider:
|
||||
class TaskAdmissionRuntimeBundle:
|
||||
handlers: Mapping[str, Any]
|
||||
principal_verifier: TaskAdmissionPrincipalProvider
|
||||
service: Any | None = None
|
||||
|
||||
|
||||
class TaskAdmissionHostError(RuntimeError):
|
||||
@@ -622,11 +638,15 @@ class SignedEpochDirectoryProvider:
|
||||
"TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH",
|
||||
403,
|
||||
)
|
||||
if value.get("schema") != "aios.owner-delegation-epoch.v1":
|
||||
schema = value.get("schema")
|
||||
if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_SCHEMA_INVALID",
|
||||
403,
|
||||
)
|
||||
expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS
|
||||
if set(value) != expected_fields:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)
|
||||
if value.get("epoch_id") != epoch_id:
|
||||
raise TaskAdmissionError(
|
||||
"TASK_ADMISSION_EPOCH_ID_MISMATCH",
|
||||
@@ -642,7 +662,34 @@ class SignedEpochDirectoryProvider:
|
||||
if epoch_id in self._revoked_ids():
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)
|
||||
|
||||
# v2 is a producer-authored envelope. Validate its self-digest and
|
||||
# signature binding before the cryptographic verifier sees the exact
|
||||
# bytes from disk. The digest is over the payload with only the digest
|
||||
# member removed; no schema translation is permitted.
|
||||
if schema == SCHEMA_EPOCH_V2:
|
||||
if (
|
||||
value.get("status") != "ACTIVE"
|
||||
or not isinstance(value.get("business_goal"), str)
|
||||
or not value["business_goal"]
|
||||
or not isinstance(value.get("production_scope"), str)
|
||||
or not value["production_scope"]
|
||||
or not isinstance(value.get("destructive_policy"), str)
|
||||
or value["destructive_policy"] not in {"forbidden", "rollback-only"}
|
||||
or value.get("signature_namespace") != OWNER_NAMESPACE
|
||||
or not isinstance(value.get("signature_key_id"), str)
|
||||
or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])
|
||||
or not isinstance(value.get("canonical_payload_digest"), str)
|
||||
or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])
|
||||
):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)
|
||||
digest_value = dict(value)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if sha256_json(digest_value) != supplied_digest:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)
|
||||
|
||||
key_id = key_id_file.read_text(encoding="utf-8").strip()
|
||||
if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)
|
||||
armored = signature.read_text(encoding="utf-8")
|
||||
if self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE,
|
||||
@@ -659,6 +706,23 @@ class SignedEpochDirectoryProvider:
|
||||
not_before = value.get("not_before")
|
||||
expires_at = value.get("expires_at")
|
||||
if (
|
||||
not isinstance(value.get("epoch_id"), str)
|
||||
or not value["epoch_id"]
|
||||
or not isinstance(value.get("owner_identity"), str)
|
||||
or not value["owner_identity"]
|
||||
or not isinstance(value.get("trust_domain"), str)
|
||||
or not value["trust_domain"]
|
||||
or not isinstance(value.get("repository"), str)
|
||||
or not value["repository"]
|
||||
or not isinstance(value.get("risk_ceiling"), str)
|
||||
or value["risk_ceiling"] not in {"low", "medium", "engineering", "high"}
|
||||
or not isinstance(value.get("issued_at"), int)
|
||||
or isinstance(value.get("issued_at"), bool)
|
||||
or not isinstance(value.get("revocation_epoch"), int)
|
||||
or isinstance(value.get("revocation_epoch"), bool)
|
||||
or not isinstance(value.get("policy_digest"), str)
|
||||
or not value["policy_digest"]
|
||||
or
|
||||
not isinstance(not_before, int)
|
||||
or isinstance(not_before, bool)
|
||||
or not isinstance(expires_at, int)
|
||||
@@ -672,13 +736,16 @@ class SignedEpochDirectoryProvider:
|
||||
files = value.get("allowed_file_prefixes")
|
||||
if (
|
||||
not isinstance(branches, list)
|
||||
or not branches
|
||||
or not all(isinstance(item, str) and item for item in branches)
|
||||
or not isinstance(capabilities, list)
|
||||
or not capabilities
|
||||
or not all(
|
||||
isinstance(item, str) and item
|
||||
for item in capabilities
|
||||
)
|
||||
or not isinstance(files, list)
|
||||
or not files
|
||||
or not all(isinstance(item, str) and item for item in files)
|
||||
):
|
||||
raise TaskAdmissionError(
|
||||
@@ -701,6 +768,19 @@ class SignedEpochDirectoryProvider:
|
||||
revocation_epoch=int(value["revocation_epoch"]),
|
||||
policy_digest=str(value["policy_digest"]),
|
||||
schema=str(value["schema"]),
|
||||
production_scope=(
|
||||
str(value["production_scope"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
destructive_policy=(
|
||||
str(value["destructive_policy"])
|
||||
if schema == SCHEMA_EPOCH_V2 else None
|
||||
),
|
||||
status=str(value.get("status", "")),
|
||||
signature_namespace=str(value.get("signature_namespace", "")),
|
||||
signature_key_id=str(value.get("signature_key_id", "")),
|
||||
canonical_payload_digest=str(value.get("canonical_payload_digest", "")),
|
||||
canonical_payload=payload,
|
||||
)
|
||||
|
||||
|
||||
@@ -915,6 +995,7 @@ def _build_task_admission_runtime(
|
||||
task_port=task_port,
|
||||
epoch_id=epoch.epoch_id,
|
||||
),
|
||||
service=service,
|
||||
)
|
||||
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
"""Concrete transport adapter for the governed source publication boundary.
|
||||
|
||||
The adapter composes the already existing live writer executor. It owns no
|
||||
leases, TaskPrincipal, credentials, retry policy, or release state machine.
|
||||
Those remain server side. ``authenticated_readback`` is deliberately an
|
||||
injected port: a local commit response is not evidence of an authenticated
|
||||
source/tree binding.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Mapping
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Protocol
|
||||
|
||||
from governed_unattended_release import SourcePublicationIntent
|
||||
from task_plane.governed_writer_automation import GovernedWriterRequest
|
||||
|
||||
|
||||
class GovernedPublicationError(RuntimeError):
|
||||
"""Stable fail-closed publication error."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class GovernedWriterExecutor(Protocol):
|
||||
def acquire(self, *, request: GovernedWriterRequest, job_id: str) -> Any: ...
|
||||
|
||||
def commit_and_readback(
|
||||
self, *, session: Any, request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes], expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
def submit_task_result(
|
||||
self, *, session: Any, result: Mapping[str, Any], idempotency_key: str,
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class SourceFileReader(Protocol):
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]: ...
|
||||
|
||||
|
||||
class AuthenticatedReadback(Protocol):
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent, request: GovernedWriterRequest,
|
||||
session: Any, commit_result: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class AdmissionBindingReader(Protocol):
|
||||
"""Read the server-side task/governed binding after admission."""
|
||||
|
||||
def __call__(
|
||||
self, *, intent: SourcePublicationIntent,
|
||||
admission_receipt: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class GitTreeFileReader(Protocol):
|
||||
"""Read bytes from an immutable committed tree object, never a workspace."""
|
||||
|
||||
def __call__(self, *, tree_oid: str, path: str) -> bytes: ...
|
||||
|
||||
|
||||
class CommittedTreeSourceReader:
|
||||
"""Adapt an authoritative git-object reader to the publication port."""
|
||||
|
||||
def __init__(self, reader: GitTreeFileReader) -> None:
|
||||
if not callable(reader):
|
||||
raise ValueError("git_object_reader_required")
|
||||
self.reader = reader
|
||||
|
||||
def __call__(self, intent: SourcePublicationIntent) -> Mapping[str, bytes]:
|
||||
return {
|
||||
path: self.reader(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
|
||||
|
||||
_OID = re.compile(r"^[0-9a-f]{40}$")
|
||||
_SHA = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
def _oid(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _OID.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_OID_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
result = str(value or "").lower()
|
||||
if not _SHA.fullmatch(result):
|
||||
raise GovernedPublicationError("PUBLICATION_SHA_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, Any]) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
dict(value), sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_EVIDENCE_INVALID") from exc
|
||||
|
||||
|
||||
class GovernedPublicationClient:
|
||||
"""Run TaskLease -> TaskPrincipal -> WriterLease -> commit through ports.
|
||||
|
||||
``writer_executor`` is normally ``LiveGovernedWriterExecutor`` from
|
||||
``task_plane.live_governed_writer_adapters``. ``source_files`` must return
|
||||
the exact immutable bytes for the intent's allowlist; it is never allowed
|
||||
to return a path or a mutable object. The authenticated readback port
|
||||
must be backed by the authoritative provider/bridge and prove the final
|
||||
source OID, tree OID, task, branch, base, generation, and execution digest.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
writer_executor: GovernedWriterExecutor,
|
||||
source_files: SourceFileReader | None = None,
|
||||
git_tree_file: GitTreeFileReader | None = None,
|
||||
admission_binding: AdmissionBindingReader,
|
||||
authenticated_readback: AuthenticatedReadback,
|
||||
subject: str,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
commit_message: str = "governed source publication",
|
||||
) -> None:
|
||||
if (
|
||||
writer_executor is None
|
||||
or any(
|
||||
not callable(getattr(writer_executor, name, None))
|
||||
for name in (
|
||||
"acquire", "commit_and_readback", "submit_task_result",
|
||||
)
|
||||
)
|
||||
):
|
||||
raise ValueError("writer_executor_required")
|
||||
if source_files is not None:
|
||||
raise ValueError("workspace_source_reader_forbidden")
|
||||
if git_tree_file is None:
|
||||
raise ValueError("git_object_source_reader_required")
|
||||
if not callable(admission_binding) or not callable(authenticated_readback):
|
||||
raise ValueError("authenticated_ports_required")
|
||||
for name, value in (("subject", subject), ("manifest_id", manifest_id),
|
||||
("runtime_fingerprint", runtime_fingerprint),
|
||||
("process_start_identity", process_start_identity),
|
||||
("commit_message", commit_message)):
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError(name + "_required")
|
||||
self.writer_executor = writer_executor
|
||||
self.source_files = None
|
||||
self.git_tree_file = git_tree_file
|
||||
self.admission_binding = admission_binding
|
||||
self.authenticated_readback = authenticated_readback
|
||||
self.subject = subject
|
||||
self.manifest_id = manifest_id
|
||||
self.runtime_fingerprint = runtime_fingerprint
|
||||
self.process_start_identity = process_start_identity
|
||||
self.commit_message = commit_message
|
||||
|
||||
def _validate_admission(self, intent: SourcePublicationIntent, receipt: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_INVALID")
|
||||
for key, expected in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid),
|
||||
("target_branch", intent.target_branch), ("generation", intent.generation),
|
||||
("execution_digest", intent.execution_digest)):
|
||||
if receipt.get(key) != expected:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
try:
|
||||
binding = self.admission_binding(intent=intent, admission_receipt=receipt)
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_UNAVAILABLE") from exc
|
||||
if not isinstance(binding, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_INVALID")
|
||||
expected = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(binding.get(key) != value for key, value in expected.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_ADMISSION_BINDING_MISMATCH")
|
||||
job_id = binding.get("job_id")
|
||||
if not isinstance(job_id, str) or not job_id:
|
||||
raise GovernedPublicationError("PUBLICATION_JOB_BINDING_MISSING")
|
||||
return dict(binding)
|
||||
|
||||
def _read_files(self, intent: SourcePublicationIntent) -> tuple[dict[str, bytes], dict[str, str]]:
|
||||
try:
|
||||
raw = {
|
||||
path: self.git_tree_file(tree_oid=intent.source_tree_oid, path=path)
|
||||
for path in intent.file_allowlist
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH") from exc
|
||||
except Exception as exc:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_READ_FAILED") from exc
|
||||
if not isinstance(raw, Mapping) or set(raw) != set(intent.file_allowlist):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_SET_MISMATCH")
|
||||
files: dict[str, bytes] = {}
|
||||
hashes: dict[str, str] = {}
|
||||
for path in intent.file_allowlist:
|
||||
data = raw.get(path)
|
||||
if isinstance(data, bytearray):
|
||||
data = bytes(data)
|
||||
if not isinstance(data, bytes):
|
||||
raise GovernedPublicationError("PUBLICATION_FILE_BYTES_REQUIRED")
|
||||
files[path] = bytes(data)
|
||||
hashes[path] = hashlib.sha256(files[path]).hexdigest()
|
||||
return files, hashes
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise GovernedPublicationError("PUBLICATION_INTENT_REQUIRED")
|
||||
admission_binding = self._validate_admission(intent, admission_receipt)
|
||||
job_id = str(admission_binding["job_id"])
|
||||
files, hashes = self._read_files(intent)
|
||||
request = GovernedWriterRequest(
|
||||
subject=self.subject, task_id=intent.task_id, repository=intent.repository,
|
||||
target_branch=intent.target_branch, risk=intent.risk, generation=intent.generation,
|
||||
base_oid=intent.authority_base_oid,
|
||||
required_capabilities=frozenset(intent.requested_capabilities),
|
||||
file_allowlist=tuple(intent.file_allowlist),
|
||||
plan_digest="sha256:" + intent.execution_digest,
|
||||
manifest_id=self.manifest_id, runtime_fingerprint=self.runtime_fingerprint,
|
||||
process_start_identity=self.process_start_identity,
|
||||
)
|
||||
try:
|
||||
session = self.writer_executor.acquire(request=request, job_id=job_id)
|
||||
commit_result = self.writer_executor.commit_and_readback(
|
||||
session=session, request=request, files=files,
|
||||
expected_sha256=hashes, message=self.commit_message,
|
||||
)
|
||||
if not isinstance(commit_result, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_READBACK_INVALID")
|
||||
readback = self.authenticated_readback(
|
||||
intent=intent, request=request, session=session,
|
||||
commit_result=commit_result,
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A transport exception after a mutation is indeterminate. Never
|
||||
# resubmit it here; the caller must reconcile through the existing
|
||||
# Job/recovery service.
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
if not isinstance(readback, Mapping) or readback.get("authenticated") is not True:
|
||||
raise GovernedPublicationError("PUBLICATION_AUTHENTICATED_READBACK_REQUIRED")
|
||||
required = {
|
||||
"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
}
|
||||
if any(readback.get(key) != value for key, value in required.items()):
|
||||
raise GovernedPublicationError("PUBLICATION_READBACK_BINDING_MISMATCH")
|
||||
for key in ("task_lease", "task_principal", "writer_lease"):
|
||||
if not isinstance(readback.get(key), Mapping) or readback[key].get("task_id") != intent.task_id:
|
||||
raise GovernedPublicationError("PUBLICATION_LEASE_CHAIN_MISSING")
|
||||
source_oid = _oid(readback.get("source_oid"))
|
||||
tree_oid = _oid(readback.get("tree_oid"))
|
||||
nested_commit = readback.get("commit_receipt")
|
||||
if nested_commit is not None and not isinstance(nested_commit, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
commit_oid = _oid(readback.get("commit_oid") or (nested_commit or {}).get("commit_oid"))
|
||||
if source_oid != commit_oid or tree_oid != intent.source_tree_oid:
|
||||
raise GovernedPublicationError("PUBLICATION_SOURCE_TREE_BINDING_MISMATCH")
|
||||
commit_receipt = readback.get("commit_receipt")
|
||||
if not isinstance(commit_receipt, Mapping):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_RECEIPT_REQUIRED")
|
||||
# The live governed receipt binds commit_oid; the independently read
|
||||
# Git tree is carried by authenticated readback. Preserve the original
|
||||
# receipt instead of inventing a tree field absent from its schema.
|
||||
if (commit_receipt.get("commit_oid") != commit_oid
|
||||
or ("tree_oid" in commit_receipt and commit_receipt["tree_oid"] != tree_oid)):
|
||||
raise GovernedPublicationError("PUBLICATION_COMMIT_BINDING_MISMATCH")
|
||||
publication = {
|
||||
**dict(readback), "authenticated": True, "task_id": intent.task_id,
|
||||
"job_id": job_id, "source_oid": source_oid, "tree_oid": tree_oid,
|
||||
"generation": intent.generation, "target_branch": intent.target_branch,
|
||||
"authority_base_oid": intent.authority_base_oid,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"commit_receipt": dict(commit_receipt),
|
||||
}
|
||||
task_result = {
|
||||
"schema": "aios.governed-source-publication-result.v1",
|
||||
"task_id": intent.task_id,
|
||||
"job_id": job_id,
|
||||
"repository": intent.repository,
|
||||
"target_branch": intent.target_branch,
|
||||
"generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest,
|
||||
"authority_base_oid": _oid(intent.authority_base_oid),
|
||||
"candidate_source_oid": _oid(intent.source_commit),
|
||||
"published_source_oid": source_oid,
|
||||
"source_tree_oid": tree_oid,
|
||||
"commit_receipt_sha256": hashlib.sha256(
|
||||
_canonical(commit_receipt)
|
||||
).hexdigest(),
|
||||
}
|
||||
result_sha256 = hashlib.sha256(_canonical(task_result)).hexdigest()
|
||||
idempotency_key = "governed-source-result-" + result_sha256
|
||||
try:
|
||||
submitted = self.writer_executor.submit_task_result(
|
||||
session=session, result=task_result,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if (
|
||||
not isinstance(submitted, Mapping)
|
||||
or submitted.get("task_id") != intent.task_id
|
||||
or submitted.get("status") != "VERIFYING"
|
||||
or submitted.get("result") != task_result
|
||||
):
|
||||
raise GovernedPublicationError(
|
||||
"PUBLICATION_TASK_RESULT_READBACK_INVALID"
|
||||
)
|
||||
except GovernedPublicationError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
# A lost result response may have changed the durable Task. Never
|
||||
# re-register, reclaim or retry here.
|
||||
raise GovernedPublicationError("PUBLICATION_EFFECT_UNKNOWN") from exc
|
||||
publication.update(
|
||||
task_result=task_result,
|
||||
task_result_idempotency_key=idempotency_key,
|
||||
task_result_submission={
|
||||
"task_id": submitted["task_id"], "status": submitted["status"],
|
||||
"revision": submitted.get("revision"),
|
||||
"idempotent": submitted.get("idempotent", False),
|
||||
"result_sha256": result_sha256,
|
||||
},
|
||||
)
|
||||
return publication
|
||||
@@ -0,0 +1,405 @@
|
||||
"""Narrow C5 unattended source-publication policy lifecycle.
|
||||
|
||||
The policy is an Owner-signed, exact-scope admission object. It is checked
|
||||
against the configured Owner policy at issuance and against a live revocation
|
||||
source for every request. It is not an activation/configuration mechanism
|
||||
and does not create release or production authority.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import re
|
||||
import time
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
SOURCE_PUBLICATION_CAPABILITIES,
|
||||
SOURCE_PUBLICATION_POLICY_ID,
|
||||
SOURCE_PUBLICATION_POLICY_SCHEMA,
|
||||
SignedArtifact,
|
||||
SingleHostAuthorityError,
|
||||
SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_RISK_ORDER = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
|
||||
_POLICY_FIELDS = frozenset(
|
||||
{
|
||||
"schema", "policy_id", "owner_identity", "repository", "trust_domain",
|
||||
"policy_digest",
|
||||
"authority_base_oid", "source_oid", "source_tree_oid", "target_agent",
|
||||
"target_branch", "generation", "execution_digest", "exact_files",
|
||||
"capabilities", "risk", "production_impact", "renewal", "nonce",
|
||||
"issued_at", "not_before", "expires_at", "status", "signature_namespace",
|
||||
"signature_key_id", "canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
_REQUEST_FIELDS = frozenset(SourcePublicationPolicyRequest.__dataclass_fields__)
|
||||
|
||||
# The typed Owner epoch producer uses the configured policy's production_scope.
|
||||
# The installed C5 source-only policy contract uses ``none`` for production
|
||||
# impact; keep this exact value bound to the C5 policy rather than accepting an
|
||||
# arbitrary non-empty scope from a raw signer.
|
||||
SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE = "none"
|
||||
SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY = "forbidden"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TaskAdmissionPolicyProjection:
|
||||
"""The only policy projection exposed after an exact request guard."""
|
||||
|
||||
max_risk: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
capabilities: tuple[str, ...]
|
||||
exact_files: tuple[str, ...]
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def _request_dict(request: SourcePublicationPolicyRequest | Mapping[str, Any]) -> dict[str, Any]:
|
||||
if isinstance(request, SourcePublicationPolicyRequest):
|
||||
value = asdict(request)
|
||||
elif isinstance(request, Mapping):
|
||||
value = dict(request)
|
||||
else:
|
||||
raise TypeError("request must be SourcePublicationPolicyRequest or mapping")
|
||||
if set(value) != _REQUEST_FIELDS:
|
||||
raise ValueError("SOURCE_PUBLICATION_REQUEST_FIELDS_INVALID")
|
||||
if "exact_files" in value:
|
||||
value["exact_files"] = tuple(value["exact_files"])
|
||||
return value
|
||||
|
||||
|
||||
class SourcePublicationPolicyVerifier:
|
||||
"""Strict verifier over the existing Owner registry verifier interface."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
owner_verifier: Any,
|
||||
*,
|
||||
revocation_check: Callable[[Mapping[str, Any]], bool] | None,
|
||||
expected_policy_digest: str | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.owner_verifier = owner_verifier
|
||||
self.revocation_check = revocation_check
|
||||
self.expected_policy_digest = expected_policy_digest
|
||||
self.clock = clock
|
||||
|
||||
def _deny(self, code: str) -> None:
|
||||
raise SingleHostAuthorityError(code)
|
||||
|
||||
def _validate_payload(self, artifact: SignedArtifact) -> dict[str, Any]:
|
||||
if not isinstance(artifact, SignedArtifact):
|
||||
self._deny("SOURCE_PUBLICATION_ARTIFACT_INVALID")
|
||||
payload = dict(artifact.payload)
|
||||
if set(payload) != _POLICY_FIELDS:
|
||||
self._deny("SOURCE_PUBLICATION_UNKNOWN_FIELDS")
|
||||
try:
|
||||
if artifact.canonical_payload != canonical_json_bytes(payload):
|
||||
self._deny("SOURCE_PUBLICATION_CANONICAL_BYTES_MISMATCH")
|
||||
if payload["canonical_payload_digest"] != sha256_json(
|
||||
{k: v for k, v in payload.items() if k != "canonical_payload_digest"}
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_PAYLOAD_DIGEST_INVALID")
|
||||
if payload["schema"] != SOURCE_PUBLICATION_POLICY_SCHEMA:
|
||||
self._deny("SOURCE_PUBLICATION_SCHEMA_INVALID")
|
||||
if payload["policy_id"] != SOURCE_PUBLICATION_POLICY_ID:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_ID_INVALID")
|
||||
if payload["owner_identity"] != OWNER_IDENTITY:
|
||||
self._deny("SOURCE_PUBLICATION_OWNER_INVALID")
|
||||
if not isinstance(payload["policy_digest"], str) or not _HEX64.fullmatch(payload["policy_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_INVALID")
|
||||
if self.expected_policy_digest is not None and payload["policy_digest"] != self.expected_policy_digest:
|
||||
self._deny("SOURCE_PUBLICATION_POLICY_DIGEST_MISMATCH")
|
||||
if payload["signature_namespace"] != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_NAMESPACE_INVALID")
|
||||
if payload["signature_key_id"] != artifact.key_id or artifact.namespace != OWNER_NAMESPACE:
|
||||
self._deny("SOURCE_PUBLICATION_ISSUER_MISMATCH")
|
||||
for name in ("authority_base_oid", "source_oid", "source_tree_oid"):
|
||||
if not isinstance(payload[name], str) or not _HEX40.fullmatch(payload[name]):
|
||||
self._deny("SOURCE_PUBLICATION_OID_INVALID")
|
||||
if not isinstance(payload["execution_digest"], str) or not _HEX64.fullmatch(payload["execution_digest"]):
|
||||
self._deny("SOURCE_PUBLICATION_EXECUTION_DIGEST_INVALID")
|
||||
branch = payload["target_branch"]
|
||||
agent = payload["target_agent"]
|
||||
if (
|
||||
not isinstance(branch, str) or not branch.startswith("agent/")
|
||||
or "\\" in branch or ".." in PurePosixPath(branch).parts
|
||||
or branch.endswith(("/", "."))
|
||||
or not isinstance(agent, str) or not agent.startswith("agent/")
|
||||
):
|
||||
self._deny("SOURCE_PUBLICATION_TARGET_INVALID")
|
||||
if payload["risk"] != "engineering" or payload["production_impact"] != "none" or payload["renewal"] != "none":
|
||||
self._deny("SOURCE_PUBLICATION_SCOPE_INVALID")
|
||||
if payload["capabilities"] != sorted(SOURCE_PUBLICATION_CAPABILITIES):
|
||||
self._deny("SOURCE_PUBLICATION_CAPABILITIES_INVALID")
|
||||
if not isinstance(payload["exact_files"], list) or not payload["exact_files"] or len(set(payload["exact_files"])) != len(payload["exact_files"]):
|
||||
self._deny("SOURCE_PUBLICATION_FILES_INVALID")
|
||||
if type(payload["generation"]) is not int or payload["generation"] < 1:
|
||||
self._deny("SOURCE_PUBLICATION_GENERATION_INVALID")
|
||||
if type(payload["issued_at"]) is not int or type(payload["not_before"]) is not int or type(payload["expires_at"]) is not int:
|
||||
self._deny("SOURCE_PUBLICATION_TIME_INVALID")
|
||||
now = int(self.clock())
|
||||
if not payload["not_before"] <= payload["issued_at"] <= now < payload["expires_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_EXPIRED")
|
||||
if payload["expires_at"] <= payload["issued_at"]:
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if payload["expires_at"] - payload["issued_at"] > 3600 or payload["status"] != "ACTIVE":
|
||||
self._deny("SOURCE_PUBLICATION_LIFETIME_INVALID")
|
||||
if self.revocation_check is None:
|
||||
self._deny("SOURCE_PUBLICATION_REVOCATION_CHECK_MISSING")
|
||||
if self.revocation_check(payload) is not True:
|
||||
self._deny("SOURCE_PUBLICATION_REVOKED")
|
||||
except SingleHostAuthorityError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_INVALID") from exc
|
||||
return payload
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self._validate_payload(artifact)
|
||||
# Verify exactly the bytes supplied by the signed artifact. Do not
|
||||
# reconstruct or reserialize them before invoking the registry API.
|
||||
if self.owner_verifier.verify(
|
||||
namespace=artifact.namespace,
|
||||
payload=artifact.canonical_payload,
|
||||
signature=artifact.signature,
|
||||
key_id=artifact.key_id,
|
||||
) is not True:
|
||||
return False
|
||||
if request is not None:
|
||||
expected = _request_dict(request)
|
||||
for field in (
|
||||
"policy_id", "repository", "authority_base_oid", "source_oid",
|
||||
"source_tree_oid", "target_agent", "target_branch", "generation",
|
||||
"execution_digest", "exact_files", "nonce", "risk",
|
||||
"production_impact", "renewal",
|
||||
):
|
||||
actual = tuple(payload[field]) if field == "exact_files" else payload[field]
|
||||
if actual != expected.get(field):
|
||||
return False
|
||||
if int(payload["expires_at"]) - int(payload["issued_at"]) != int(expected["ttl_seconds"]):
|
||||
return False
|
||||
return True
|
||||
except (SingleHostAuthorityError, TypeError, ValueError, KeyError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def require(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
if not self.verify(artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return dict(artifact.payload)
|
||||
|
||||
|
||||
def require_source_publication_epoch_scope(epoch: Any, policy_payload: Mapping[str, Any]) -> None:
|
||||
"""Require v2 epoch provenance to match the signed C5 policy exactly."""
|
||||
schema = getattr(epoch, "schema", None)
|
||||
if schema is None and isinstance(epoch, Mapping):
|
||||
schema = epoch.get("schema")
|
||||
if schema != "aios.owner-delegation-epoch.v2":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCHEMA_REQUIRED")
|
||||
|
||||
def field(name: str) -> Any:
|
||||
if isinstance(epoch, Mapping):
|
||||
return epoch.get(name)
|
||||
return getattr(epoch, name, None)
|
||||
|
||||
if (
|
||||
field("production_scope") != SOURCE_PUBLICATION_EPOCH_PRODUCTION_SCOPE
|
||||
or field("destructive_policy") != SOURCE_PUBLICATION_EPOCH_DESTRUCTIVE_POLICY
|
||||
or field("policy_digest") != policy_payload.get("policy_digest")
|
||||
or field("repository") != policy_payload.get("repository")
|
||||
or field("trust_domain") != policy_payload.get("trust_domain")
|
||||
):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_EPOCH_SCOPE_MISMATCH")
|
||||
|
||||
|
||||
class ScopedSourcePublicationPolicyAdmission:
|
||||
"""Rechecks a policy for every exact request before scoped admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
verifier: SourcePublicationPolicyVerifier,
|
||||
*,
|
||||
bootstrap: Any | None = None,
|
||||
artifact: SignedArtifact | None = None,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any] | None = None,
|
||||
expected_agent_id: str | None = None,
|
||||
) -> None:
|
||||
self.verifier = verifier
|
||||
self.bootstrap = bootstrap
|
||||
self.artifact = artifact
|
||||
self.request = request
|
||||
self.expected_agent_id = expected_agent_id
|
||||
|
||||
def _check_bootstrap_binding(self, payload: Mapping[str, Any]) -> None:
|
||||
if self.bootstrap is None:
|
||||
return
|
||||
policy = getattr(self.bootstrap, "policy", None)
|
||||
digest_provider = getattr(policy, "normalized_policy_digest", None)
|
||||
digest = digest_provider() if callable(digest_provider) else None
|
||||
trust_domain = getattr(policy, "trust_domain", None)
|
||||
if not isinstance(digest, str) or digest != payload.get("policy_digest"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_POLICY_DIGEST_MISMATCH")
|
||||
if not isinstance(trust_domain, str) or trust_domain != payload.get("trust_domain"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_TRUST_DOMAIN_MISMATCH")
|
||||
if self.expected_agent_id is None or payload.get("target_agent") != self.expected_agent_id:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_TARGET_AGENT_MISMATCH")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> bool:
|
||||
try:
|
||||
payload = self.verifier._validate_payload(artifact)
|
||||
if self.expected_agent_id is not None and payload.get("target_agent") != self.expected_agent_id:
|
||||
return False
|
||||
self._check_bootstrap_binding(payload)
|
||||
except SingleHostAuthorityError:
|
||||
return False
|
||||
return self.verifier.verify(artifact, request)
|
||||
|
||||
verify = authorize
|
||||
|
||||
def _request_for_raw(self, raw: Mapping[str, Any]) -> SourcePublicationPolicyRequest:
|
||||
if self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_ARTIFACT_REQUIRED")
|
||||
expected_task = getattr(self, "expected_task_id", None)
|
||||
if expected_task is not None and raw.get("task_id") != expected_task:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_PLAN_TASK_MISMATCH")
|
||||
payload = self.verifier._validate_payload(self.artifact)
|
||||
self._check_bootstrap_binding(payload)
|
||||
if self.request is not None:
|
||||
base = _request_dict(self.request)
|
||||
else:
|
||||
base = {
|
||||
"policy_id": payload["policy_id"], "repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"], "source_oid": payload["source_oid"],
|
||||
"source_tree_oid": payload["source_tree_oid"], "target_agent": payload["target_agent"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "exact_files": tuple(payload["exact_files"]),
|
||||
"nonce": payload["nonce"], "ttl_seconds": payload["expires_at"] - payload["issued_at"],
|
||||
"risk": payload["risk"], "production_impact": payload["production_impact"], "renewal": payload["renewal"],
|
||||
}
|
||||
checks = {
|
||||
"repository": base["repository"], "authority_base_oid": base["authority_base_oid"],
|
||||
"target_branch": base["target_branch"], "generation": base["generation"],
|
||||
"execution_digest": base["execution_digest"], "risk": base["risk"],
|
||||
}
|
||||
for field, expected in checks.items():
|
||||
if raw.get(field) != expected:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_REQUEST_SCOPE_MISMATCH")
|
||||
caps = tuple(raw.get("requested_capabilities", ()))
|
||||
if set(caps) != set(base["capabilities"] if "capabilities" in base else payload["capabilities"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_CAPABILITY_SCOPE_MISMATCH")
|
||||
if tuple(raw.get("file_allowlist", ())) != tuple(base["exact_files"]):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_FILE_SCOPE_MISMATCH")
|
||||
return SourcePublicationPolicyRequest(**base)
|
||||
|
||||
def create_envelope(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
request = self._request_for_raw(raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
return self.bootstrap.create_envelope(raw)
|
||||
|
||||
def authorize_and_admit(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if raw.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
if not self.verifier.verify(self.artifact, request):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_POLICY_DENIED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=str(raw.get("delegation_epoch_id") or ""),
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
return self.bootstrap.authorize_and_admit(raw)
|
||||
|
||||
def status(self, raw: Mapping[str, Any] | str) -> Any:
|
||||
"""Read status only after the same policy and epoch guard as admission."""
|
||||
if self.bootstrap is None or self.artifact is None:
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_BOOTSTRAP_REQUIRED")
|
||||
if isinstance(raw, str):
|
||||
raw = {"envelope_id": raw}
|
||||
envelope = self.bootstrap._load_envelope(str(raw.get("envelope_id") or ""))
|
||||
envelope_raw = envelope.as_dict() if hasattr(envelope, "as_dict") else dict(envelope)
|
||||
request = self._request_for_raw(envelope_raw)
|
||||
self.verifier.require(self.artifact, request)
|
||||
epoch_id = str(raw.get("delegation_epoch_id") or "")
|
||||
if not epoch_id:
|
||||
grant = self.bootstrap.store.get_grant_by_task_id(envelope.task_id)
|
||||
epoch_id = grant.parent_epoch_id if grant is not None else ""
|
||||
if not epoch_id or not hasattr(self.bootstrap, "_verify_epoch"):
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_STATUS_EPOCH_REQUIRED")
|
||||
epoch = self.bootstrap._verify_epoch(
|
||||
epoch_id=epoch_id,
|
||||
envelope=envelope,
|
||||
now=int(self.verifier.clock()),
|
||||
)
|
||||
require_source_publication_epoch_scope(
|
||||
epoch, self.verifier._validate_payload(self.artifact)
|
||||
)
|
||||
if hasattr(self.bootstrap, "status"):
|
||||
return self.bootstrap.status(envelope.envelope_id)
|
||||
return envelope_raw
|
||||
|
||||
def reconcile_status(self, envelope_id: str, authorization: Mapping[str, Any]) -> Any:
|
||||
"""Carry the verified epoch identity when a Task effect has no receipt yet."""
|
||||
if authorization.get("authorization_kind") != "delegation_epoch":
|
||||
raise SingleHostAuthorityError("SOURCE_PUBLICATION_OWNER_SIGNATURE_BYPASS_DENIED")
|
||||
return self.status({"envelope_id": envelope_id,
|
||||
"delegation_epoch_id": authorization.get("delegation_epoch_id")})
|
||||
|
||||
def project_task_admission_policy(
|
||||
self,
|
||||
artifact: SignedArtifact,
|
||||
request: SourcePublicationPolicyRequest | Mapping[str, Any],
|
||||
) -> TaskAdmissionPolicyProjection:
|
||||
payload = self.verifier.require(artifact, request)
|
||||
return TaskAdmissionPolicyProjection(
|
||||
max_risk="engineering",
|
||||
repository=payload["repository"],
|
||||
target_branch=payload["target_branch"],
|
||||
capabilities=tuple(payload["capabilities"]),
|
||||
exact_files=tuple(payload["exact_files"]),
|
||||
)
|
||||
|
||||
to_task_admission_policy = project_task_admission_policy
|
||||
|
||||
|
||||
# Short aliases for callers that prefer the lifecycle terminology.
|
||||
SourcePublicationPolicyAdmission = ScopedSourcePublicationPolicyAdmission
|
||||
SourcePublicationVerifier = SourcePublicationPolicyVerifier
|
||||
@@ -0,0 +1,372 @@
|
||||
"""Thin unattended release runner.
|
||||
|
||||
This module is an adapter around the existing admission and silent-release
|
||||
services. It deliberately owns no release state machine and never creates
|
||||
approval evidence. The runner only binds immutable source facts, delegates
|
||||
authority, and validates durable readbacks before moving to the next phase.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-fA-F]{40}$")
|
||||
SHA256_RE = re.compile(r"^[0-9a-fA-F]{64}$")
|
||||
IDENT_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}$")
|
||||
RETRYABLE_CODES = {408, 425, 429, 500, 502, 503, 504}
|
||||
RETRYABLE_NAMES = {
|
||||
"UNAVAILABLE", "BUSY", "TEMPORARY_FAILURE", "TASK_ADMISSION_IN_PROGRESS",
|
||||
}
|
||||
RISKS = {"low", "medium", "engineering", "high"}
|
||||
|
||||
|
||||
class UnattendedReleaseError(RuntimeError):
|
||||
"""A fail-closed runner error with a stable code."""
|
||||
|
||||
def __init__(self, code: str, message: str | None = None):
|
||||
self.code = code
|
||||
super().__init__(message or code)
|
||||
|
||||
|
||||
def _canon(value: Any) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode()
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return hashlib.sha256(_canon(value)).hexdigest()
|
||||
|
||||
|
||||
def _require_oid(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not OID_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
def _require_sha(name: str, value: Any) -> str:
|
||||
value = str(value or "")
|
||||
if not SHA256_RE.fullmatch(value):
|
||||
raise UnattendedReleaseError("INVALID_" + name.upper())
|
||||
return value.lower()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourcePublicationIntent:
|
||||
"""Immutable, deterministic source and execution binding."""
|
||||
|
||||
repository: str
|
||||
source_commit: str
|
||||
source_tree_oid: str
|
||||
authority_base_oid: str
|
||||
target_branch: str
|
||||
file_allowlist: tuple[str, ...]
|
||||
requested_capabilities: tuple[str, ...]
|
||||
risk: str
|
||||
execution_digest: str
|
||||
generation: int = 1
|
||||
event_id: str = "release"
|
||||
version: str = ""
|
||||
admission_task_id: str = ""
|
||||
admission_idempotency_key: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.repository or not isinstance(self.repository, str):
|
||||
raise UnattendedReleaseError("INVALID_REPOSITORY")
|
||||
for name in ("source_commit", "source_tree_oid", "authority_base_oid"):
|
||||
object.__setattr__(self, name, _require_oid(name, getattr(self, name)))
|
||||
if not isinstance(self.target_branch, str) or not self.target_branch or not self.target_branch.startswith("agent/") or self.target_branch.startswith("-"):
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if ".." in self.target_branch or "//" in self.target_branch or "@{" in self.target_branch:
|
||||
raise UnattendedReleaseError("INVALID_TARGET_BRANCH")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise UnattendedReleaseError("INVALID_GENERATION")
|
||||
object.__setattr__(self, "execution_digest", _require_sha("execution_digest", self.execution_digest))
|
||||
if not self.file_allowlist or any(not isinstance(p, str) or not p or p.startswith(("/", "\\")) or ".." in p or "\\" in p or p == ".git" or p.startswith(".git/") for p in self.file_allowlist):
|
||||
raise UnattendedReleaseError("INVALID_FILE_ALLOWLIST")
|
||||
if len(set(self.file_allowlist)) != len(self.file_allowlist):
|
||||
raise UnattendedReleaseError("DUPLICATE_FILE_ALLOWLIST")
|
||||
if not self.requested_capabilities or any(not isinstance(c, str) or not c or "*" in c for c in self.requested_capabilities):
|
||||
raise UnattendedReleaseError("INVALID_CAPABILITIES")
|
||||
if not isinstance(self.risk, str) or self.risk.lower() not in RISKS:
|
||||
raise UnattendedReleaseError("INVALID_RISK")
|
||||
object.__setattr__(self, "risk", self.risk.lower())
|
||||
object.__setattr__(self, "file_allowlist", tuple(sorted(self.file_allowlist)))
|
||||
object.__setattr__(self, "requested_capabilities", tuple(sorted(set(self.requested_capabilities))))
|
||||
if not IDENT_RE.fullmatch(self.event_id):
|
||||
raise UnattendedReleaseError("INVALID_EVENT_ID")
|
||||
if self.version and not IDENT_RE.fullmatch(self.version):
|
||||
raise UnattendedReleaseError("INVALID_VERSION")
|
||||
if bool(self.admission_task_id) != bool(self.admission_idempotency_key):
|
||||
raise UnattendedReleaseError("INCOMPLETE_ADMISSION_IDENTITY")
|
||||
for value in (self.admission_task_id, self.admission_idempotency_key):
|
||||
if value and (not isinstance(value, str) or not IDENT_RE.fullmatch(value)):
|
||||
raise UnattendedReleaseError("INVALID_ADMISSION_IDENTITY")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = {
|
||||
"repository": self.repository, "source_commit": self.source_commit.lower(),
|
||||
"source_tree_oid": self.source_tree_oid.lower(), "authority_base_oid": self.authority_base_oid.lower(),
|
||||
"target_branch": self.target_branch, "file_allowlist": list(self.file_allowlist),
|
||||
"requested_capabilities": list(self.requested_capabilities), "risk": self.risk,
|
||||
"execution_digest": self.execution_digest.lower(), "generation": self.generation,
|
||||
"event_id": self.event_id, "version": self.version,
|
||||
}
|
||||
if self.admission_task_id:
|
||||
value.update(admission_task_id=self.admission_task_id,
|
||||
admission_idempotency_key=self.admission_idempotency_key)
|
||||
return value
|
||||
|
||||
@property
|
||||
def task_id(self) -> str:
|
||||
return self.admission_task_id or "release-task-" + _digest(self.as_dict())[:32]
|
||||
|
||||
@property
|
||||
def idempotency_key(self) -> str:
|
||||
return self.admission_idempotency_key or "release-intake-" + _digest({"task_id": self.task_id, "execution_digest": self.execution_digest.lower()})[:32]
|
||||
|
||||
def admission_request(self) -> dict[str, Any]:
|
||||
"""Return the sole normalized request accepted by Task admission."""
|
||||
return {"idempotency_key": self.idempotency_key, "task_id": self.task_id,
|
||||
"repository": self.repository, "authority_base_oid": self.authority_base_oid,
|
||||
"target_branch": self.target_branch, "generation": self.generation,
|
||||
"execution_digest": self.execution_digest, "requested_capabilities": list(self.requested_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist), "risk": self.risk}
|
||||
|
||||
|
||||
def _error_code(exc: BaseException) -> str:
|
||||
code = getattr(exc, "code", None) or getattr(exc, "status", None) or exc.__class__.__name__
|
||||
value = str(code).upper()
|
||||
return value if re.fullmatch(r"[A-Z0-9_]{1,100}", value) else "BACKEND_ERROR"
|
||||
|
||||
|
||||
def _bound_receipt(receipt: Any, intent: SourcePublicationIntent) -> bool:
|
||||
if not isinstance(receipt, Mapping):
|
||||
return False
|
||||
expected = {"task_id": intent.task_id, "authority_base_oid": intent.authority_base_oid,
|
||||
"target_branch": intent.target_branch, "generation": intent.generation,
|
||||
"execution_digest": intent.execution_digest}
|
||||
return (receipt.get("schema") == "aios.task-admission-receipt.v1"
|
||||
and all(_canon(receipt.get(k)) == _canon(v) for k, v in expected.items())
|
||||
and receipt.get("task_state") in {"QUEUED", "CLAIMED", "RUNNING"}
|
||||
and all(isinstance(receipt.get(k), str) and receipt[k] for k in ("envelope_id", "grant_id")))
|
||||
|
||||
|
||||
def _retryable(exc: BaseException) -> bool:
|
||||
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if isinstance(status, int):
|
||||
return status in RETRYABLE_CODES
|
||||
code = _error_code(exc)
|
||||
return code in RETRYABLE_NAMES
|
||||
|
||||
|
||||
def _unknown_outcome(exc: BaseException) -> bool:
|
||||
return isinstance(exc, (TimeoutError, ConnectionError, OSError)) or _error_code(exc) in {"TASK_ADMISSION_TASK_STATE_UNKNOWN", "TIMEOUT", "TIMED_OUT", "CONNECTION_LOST"}
|
||||
|
||||
|
||||
class GovernedUnattendedReleaseRunner:
|
||||
"""Phase-separated runner; preparation has no release/deployment rights."""
|
||||
|
||||
def __init__(self, *, admission: Any, release_service: Any = None,
|
||||
publication_adapter: Any = None, owner_approval_broker: Callable[[Mapping[str, Any]], Mapping[str, Any]] | None = None,
|
||||
deployment_enabled: bool = False, deployment_service: Any = None,
|
||||
max_attempts: int = 3, deadline_seconds: float = 30.0,
|
||||
environment_mode: str = "SIMULATION",
|
||||
clock: Callable[[], float] = time.monotonic, sleep: Callable[[float], None] = time.sleep):
|
||||
if admission is None:
|
||||
raise ValueError("admission is required")
|
||||
if max_attempts < 1 or max_attempts > 10 or deadline_seconds <= 0:
|
||||
raise ValueError("invalid retry bounds")
|
||||
self.admission = admission
|
||||
self.environment_mode = str(environment_mode).upper()
|
||||
if self.environment_mode not in {"SIMULATION", "AUTHORITY"}:
|
||||
raise ValueError("environment_mode must be SIMULATION or AUTHORITY")
|
||||
self.release_service = release_service
|
||||
self.publication_adapter = publication_adapter
|
||||
self.owner_approval_broker = owner_approval_broker
|
||||
self.deployment_enabled = bool(deployment_enabled)
|
||||
self.deployment_service = deployment_service
|
||||
self.max_attempts = max_attempts
|
||||
self.deadline_seconds = deadline_seconds
|
||||
self.clock, self.sleep = clock, sleep
|
||||
|
||||
def intake(self, intent: SourcePublicationIntent) -> dict[str, Any]:
|
||||
if not isinstance(intent, SourcePublicationIntent):
|
||||
raise UnattendedReleaseError("INTENT_TYPE_REQUIRED")
|
||||
return {"phase": "PREPARATION", "mode": self.environment_mode, "task_id": intent.task_id,
|
||||
"idempotency_key": intent.idempotency_key, "intent": intent.as_dict(),
|
||||
"release_rights": False, "deployment_rights": False}
|
||||
|
||||
def admit(self, intent: SourcePublicationIntent, authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(authorization, Mapping):
|
||||
raise UnattendedReleaseError("AUTHORIZATION_REQUIRED")
|
||||
if self.environment_mode == "SIMULATION" and getattr(self.admission, "simulation_only", False) is not True:
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "task_id": intent.task_id, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
raw = intent.admission_request()
|
||||
deadline = self.clock() + self.deadline_seconds
|
||||
envelope_id = None
|
||||
for attempt in range(1, self.max_attempts + 1):
|
||||
if self.clock() >= deadline:
|
||||
return {"phase": "RECONCILIATION_REQUIRED" if envelope_id else "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": "ADMISSION_DEADLINE_EXCEEDED"}
|
||||
try:
|
||||
created = self.admission.create_envelope(raw)
|
||||
envelope = created.get("envelope", created) if isinstance(created, Mapping) else {}
|
||||
envelope_id = envelope.get("envelope_id") or envelope_id
|
||||
if not isinstance(envelope_id, str) or not envelope_id:
|
||||
raise UnattendedReleaseError("ENVELOPE_BINDING_MISSING")
|
||||
auth = dict(authorization)
|
||||
auth["envelope_id"] = envelope_id
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
receipt = self.admission.authorize_and_admit(auth)
|
||||
if not _bound_receipt(receipt, intent) or receipt["envelope_id"] != envelope_id:
|
||||
raise UnattendedReleaseError("ADMISSION_RECEIPT_BINDING_MISMATCH")
|
||||
if self.clock() >= deadline:
|
||||
return self._reconcile(envelope_id, intent, "ADMISSION_DEADLINE_EXCEEDED", authorization)
|
||||
return {"phase": "ADMITTED", "mode": self.environment_mode, "attempt": attempt,
|
||||
"envelope": created, "receipt": receipt}
|
||||
except Exception as exc:
|
||||
status_code = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) or getattr(exc, "status", None)
|
||||
if status_code == 403 or _error_code(exc) in {"403", "FORBIDDEN"}:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
# Once authorization may have run, every failure reconciles.
|
||||
# A 5xx is never sufficient proof of NOT_APPLIED.
|
||||
if envelope_id:
|
||||
return self._reconcile(envelope_id, intent, _error_code(exc), authorization)
|
||||
if not _retryable(exc) or self.clock() >= deadline or attempt >= self.max_attempts:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": _error_code(exc)}
|
||||
self.sleep(min(0.05 * (2 ** (attempt - 1)), max(0.0, deadline - self.clock())))
|
||||
raise UnattendedReleaseError("ADMISSION_RETRY_EXHAUSTED")
|
||||
|
||||
def _reconcile(self, envelope_id: str | None, intent: SourcePublicationIntent, error: str,
|
||||
authorization: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if not envelope_id:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error}
|
||||
try:
|
||||
reconcile = getattr(self.admission, "reconcile_status", None)
|
||||
status = reconcile(envelope_id, authorization) if callable(reconcile) else self.admission.status(envelope_id)
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "error": error, "status_error": _error_code(exc)}
|
||||
state = str(status.get("status") or status.get("task_state") or "").upper() if isinstance(status, Mapping) else ""
|
||||
if state in {"NOT_APPLIED", "ABSENT", "NOT_FOUND"}:
|
||||
return {"phase": "RETRYABLE_RECONCILIATION", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
if state == "ADMITTED" and _bound_receipt(status.get("task"), intent):
|
||||
return {"phase": "RECONCILED", "mode": self.environment_mode, "task_id": intent.task_id, "status": status, "receipt": status["task"]}
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "task_id": intent.task_id, "status": status, "error": error}
|
||||
|
||||
def publish(self, intent: SourcePublicationIntent, admission_receipt: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if self.publication_adapter is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_BACKEND_MISSING"}
|
||||
required = ("task_lease", "task_principal", "writer_lease", "commit_receipt", "source_oid", "tree_oid", "authenticated")
|
||||
try:
|
||||
if not _bound_receipt(admission_receipt, intent):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "ADMISSION_RECEIPT_BINDING_MISMATCH"}
|
||||
result = self.publication_adapter.publish(intent, admission_receipt)
|
||||
if not isinstance(result, Mapping) or result.get("authenticated") is not True or any(not result.get(k) for k in required[:-1]):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_CHAIN_MISSING"}
|
||||
if any(not isinstance(result[k], Mapping) or result[k].get("task_id") != intent.task_id
|
||||
for k in ("task_lease", "task_principal", "writer_lease")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_LEASE_BINDING_MISMATCH"}
|
||||
commit = result["commit_receipt"]
|
||||
# Existing continuation receipts carry remote OIDs, while the
|
||||
# authenticated task/job readback carries scope. Preserve both
|
||||
# originals; never fabricate missing fields inside a receipt.
|
||||
optional_bindings = {"task_id": intent.task_id, "tree_oid": result["tree_oid"],
|
||||
"execution_digest": intent.execution_digest,
|
||||
"target_branch": intent.target_branch,
|
||||
"base_oid": intent.authority_base_oid}
|
||||
if (not isinstance(commit, Mapping) or commit.get("commit_oid") != result["source_oid"]
|
||||
or any(key in commit and commit[key] != value for key, value in optional_bindings.items())
|
||||
or result.get("generation") != intent.generation):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_COMMIT_BINDING_MISMATCH"}
|
||||
if not OID_RE.fullmatch(str(result["source_oid"])) or str(result["tree_oid"]).lower() != intent.source_tree_oid.lower() or any(str(result.get(k, "")) != str(v) for k, v in (("task_id", intent.task_id), ("authority_base_oid", intent.authority_base_oid), ("target_branch", intent.target_branch), ("execution_digest", intent.execution_digest))):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "PUBLICATION_RC_BINDING_MISMATCH"}
|
||||
return {"phase": "PUBLISHED", "mode": "AUTHORITY", "readback": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "RECONCILIATION_REQUIRED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def postpublication_rc(self, *, event_id: str, source_commit: str, version: str = "", source_readback: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
if self.environment_mode != "AUTHORITY":
|
||||
return {"phase": "BLOCKED", "mode": self.environment_mode, "error": "AUTHORITY_MODE_REQUIRED"}
|
||||
if not isinstance(source_readback, Mapping) or source_readback.get("authenticated") is not True or str(source_readback.get("source_commit", "")).lower() != str(source_commit).lower():
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "SOURCE_READBACK_REQUIRED"}
|
||||
if self.release_service is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "RELEASE_BACKEND_MISSING"}
|
||||
try:
|
||||
# Only a host-injected authenticated resolver can admit a source.
|
||||
# The caller's readback object alone is never authorization.
|
||||
resolver = getattr(self.release_service, "source_admission_resolver", None)
|
||||
admitted_source = resolver(source_commit) if callable(resolver) else None
|
||||
if (not isinstance(admitted_source, Mapping) or not admitted_source.get("admission_id")
|
||||
or admitted_source.get("source_commit") != source_commit
|
||||
or admitted_source.get("tree_oid") != source_readback.get("tree_oid")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"}
|
||||
intent = self.release_service.create_release_intent({"event_id": event_id, "source_commit": source_commit, **({"version": version} if version else {})})
|
||||
candidate_id = str(intent.get("candidate_id") or intent.get("candidate", {}).get("candidate_id") or "")
|
||||
if not candidate_id:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "CANDIDATE_BINDING_MISSING"}
|
||||
readers = ("get_release_candidate", "get_release_plan", "get_acceptance")
|
||||
if any(not callable(getattr(self.release_service, name, None)) for name in readers):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BACKEND_MISSING"}
|
||||
candidate = self.release_service.get_release_candidate(candidate_id)
|
||||
plan = self.release_service.get_release_plan(candidate_id)
|
||||
acceptance = self.release_service.get_acceptance(candidate_id)
|
||||
deployment = plan.get("deployment") if isinstance(plan, Mapping) else None
|
||||
if (not isinstance(candidate, Mapping) or not isinstance(plan, Mapping) or not isinstance(deployment, Mapping) or not isinstance(acceptance, Mapping)
|
||||
or str(candidate.get("candidate_id")) != candidate_id
|
||||
or str(candidate.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(plan.get("candidate_id", candidate_id)) != candidate_id
|
||||
or str(deployment.get("source_commit")) .lower() != str(source_commit).lower()
|
||||
or str(deployment.get("tree_oid", "")).lower() != str(source_readback.get("tree_oid", "")).lower()
|
||||
or candidate.get("tree_oid") != deployment.get("tree_oid")
|
||||
or candidate.get("artifact_sha256") != deployment.get("artifact_sha256")
|
||||
or candidate.get("manifest_sha256") != deployment.get("manifest_sha256")
|
||||
or str(acceptance.get("candidate_id")) != candidate_id
|
||||
or acceptance.get("release_plan_sha256") != plan.get("sha256")
|
||||
or str(acceptance.get("status")) != "ACCEPTED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "RC_READBACK_BINDING_MISMATCH"}
|
||||
# Public readback is redacted. Reverify original signed bytes inside
|
||||
# the existing service, not by reconstructing them from API output.
|
||||
verify_acceptance = getattr(self.release_service, "verify_release_acceptance", None)
|
||||
if not callable(verify_acceptance):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_REQUIRED"}
|
||||
verified = verify_acceptance(candidate_id)
|
||||
if (not isinstance(verified, Mapping) or verified.get("status") != "VERIFIED"
|
||||
or verified.get("candidate_id") != candidate_id
|
||||
or verified.get("release_plan_sha256") != plan.get("sha256")):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "BUILDER_ATTESTATION_NOT_VERIFIED"}
|
||||
envelope = self.release_service.get_approval_envelope(candidate_id)
|
||||
if self.owner_approval_broker is None:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_BACKEND_MISSING"}
|
||||
approval = self.owner_approval_broker(dict(envelope))
|
||||
if not isinstance(approval, Mapping):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "candidate_id": candidate_id, "error": "OWNER_APPROVAL_MISSING"}
|
||||
accepted = self.release_service.submit_owner_approval(dict(approval))
|
||||
if not isinstance(accepted, Mapping) or accepted.get("status") != "VERIFIED_AND_STORED":
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "APPROVAL_NOT_VERIFIED"}
|
||||
return {"phase": "APPROVED", "mode": "AUTHORITY", "candidate_id": candidate_id,
|
||||
"readback": {"candidate": dict(candidate), "plan": dict(plan), "acceptance": dict(acceptance)},
|
||||
"approval": accepted}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
|
||||
def deploy(self, candidate_id: str) -> dict[str, Any]:
|
||||
if (self.environment_mode != "AUTHORITY" or not self.deployment_enabled or self.deployment_service is None
|
||||
or getattr(self.deployment_service, "execution_enabled", False) is not True):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_ENABLED"}
|
||||
try:
|
||||
result = self.deployment_service.start_deployment(candidate_id)
|
||||
saga = self.deployment_service.get_release_saga(candidate_id)
|
||||
if (not isinstance(result, Mapping) or result.get("candidate_id") != candidate_id
|
||||
or result.get("state") != "COMPLETED" or result.get("status") not in {"VERIFIED", "IDEMPOTENT"}
|
||||
or not isinstance(saga, Mapping) or saga.get("state") != "COMPLETED"):
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": "DEPLOYMENT_NOT_VERIFIED", "result": dict(result) if isinstance(result, Mapping) else {}}
|
||||
return {"phase": "DEPLOYED", "mode": "AUTHORITY", "result": dict(result)}
|
||||
except Exception as exc:
|
||||
return {"phase": "BLOCKED", "mode": "AUTHORITY", "error": _error_code(exc)}
|
||||
@@ -0,0 +1,136 @@
|
||||
"""Local adapter for the root-owned registered Owner signer service.
|
||||
|
||||
The adapter intentionally has no public arbitrary-payload signing method.
|
||||
``LocalOwnerAuthority`` calls the private typed seam after validating and
|
||||
canonicalizing a structured Owner authorization object.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Callable
|
||||
|
||||
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND = Path("/usr/local/libexec/aios-grant-signer-adapter")
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_SIGNATURE_BYTES = 128 * 1024
|
||||
MAX_KEY_ID_BYTES = 512
|
||||
|
||||
|
||||
class RegisteredOwnerSignerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class RegisteredOwnerSignerAdapter:
|
||||
"""Invoke the registered signer without reading or copying private keys."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
command: Path | str = DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
*,
|
||||
key_id: str,
|
||||
owner_verifier: Any,
|
||||
require_root: bool = True,
|
||||
test_mode: bool = False,
|
||||
runner: Callable[..., Any] = subprocess.run,
|
||||
) -> None:
|
||||
self.command = str(command)
|
||||
self.key_id = key_id
|
||||
self.owner_verifier = owner_verifier
|
||||
self.require_root = require_root
|
||||
self.test_mode = test_mode
|
||||
self.runner = runner
|
||||
if not isinstance(key_id, str) or not key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_REQUIRED")
|
||||
if not test_mode:
|
||||
self._validate_command()
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.key_id
|
||||
|
||||
def _validate_command(self) -> None:
|
||||
path = Path(self.command)
|
||||
if not path.is_absolute() or path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_INVALID")
|
||||
st = path.stat()
|
||||
if not hasattr(os, "geteuid") or st.st_uid != os.geteuid():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_OWNER_INVALID")
|
||||
if stat.S_IMODE(st.st_mode) & 0o022:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_COMMAND_PERMISSIONS_INVALID")
|
||||
|
||||
def _read_output(self, path: Path, maximum: int) -> bytes:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_INVALID")
|
||||
st = path.stat()
|
||||
if not self.test_mode and (not hasattr(os, "geteuid") or st.st_uid != os.geteuid()):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_OWNER_INVALID")
|
||||
if st.st_size > maximum:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_TOO_LARGE")
|
||||
return path.read_bytes()
|
||||
|
||||
def _sign_canonical_payload(self, namespace: str, payload: bytes) -> str:
|
||||
"""Private seam used only by typed ``LocalOwnerAuthority`` issuance."""
|
||||
if namespace != OWNER_NAMESPACE:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_NAMESPACE_INVALID")
|
||||
if not isinstance(payload, bytes) or not payload:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_PAYLOAD_INVALID")
|
||||
if self.require_root:
|
||||
if not hasattr(os, "geteuid") or os.geteuid() != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_ROOT_REQUIRED")
|
||||
if not self.test_mode:
|
||||
self._validate_command()
|
||||
with tempfile.TemporaryDirectory(prefix="aios-owner-sign-") as directory:
|
||||
root = Path(directory)
|
||||
input_path = root / "payload"
|
||||
signature_path = root / "payload.sig"
|
||||
key_id_path = root / "key-id"
|
||||
input_path.write_bytes(payload)
|
||||
try:
|
||||
result = self.runner(
|
||||
[
|
||||
self.command,
|
||||
"sign",
|
||||
"--namespace",
|
||||
namespace,
|
||||
"--input",
|
||||
str(input_path),
|
||||
"--signature-out",
|
||||
str(signature_path),
|
||||
"--key-id-out",
|
||||
str(key_id_path),
|
||||
],
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=30,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED") from exc
|
||||
if getattr(result, "returncode", None) != 0:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_FAILED")
|
||||
if not signature_path.is_file() or not key_id_path.is_file():
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_OUTPUT_MISSING")
|
||||
supplied_key_id = self._read_output(key_id_path, MAX_KEY_ID_BYTES).decode("utf-8").strip()
|
||||
if supplied_key_id != self.key_id:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_KEY_ID_MISMATCH")
|
||||
signature = self._read_output(signature_path, MAX_SIGNATURE_BYTES).decode("utf-8")
|
||||
if not signature.startswith("-----BEGIN SSH SIGNATURE-----"):
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = self.owner_verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=payload,
|
||||
signature=signature,
|
||||
key_id=self.key_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_FAILED") from exc
|
||||
if verified is not True:
|
||||
raise RegisteredOwnerSignerError("REGISTERED_SIGNER_VERIFY_REJECTED")
|
||||
return signature
|
||||
@@ -4203,6 +4203,32 @@ class SilentReleaseActionService:
|
||||
"side_effects_executed": False,
|
||||
}
|
||||
|
||||
def verify_release_acceptance(self, candidate_id: str) -> dict[str, Any]:
|
||||
"""Reverify durable originals without exposing signed data via the API."""
|
||||
if self.builder_attestation_verifier is None:
|
||||
raise CapabilityAuthorizationError("builder verifier is required")
|
||||
candidate = self.candidates.load(candidate_id)
|
||||
plan = self.release_plans.load(candidate_id)
|
||||
record = json.loads(
|
||||
_safe_child(self.acceptance_root, candidate_id).read_text(encoding="utf-8")
|
||||
)
|
||||
if (record.get("candidate_id") != candidate_id
|
||||
or record.get("release_plan_sha256") != plan.sha256
|
||||
or record.get("status") != "ACCEPTED"
|
||||
or not isinstance(record.get("attestation"), Mapping)):
|
||||
raise CapabilityAuthorizationError("acceptance binding is invalid")
|
||||
attestation = BuilderAttestation.from_dict(
|
||||
record["attestation"], require_fingerprint=True
|
||||
)
|
||||
self.builder_attestation_verifier.verify(attestation, candidate, plan)
|
||||
return {
|
||||
"candidate_id": candidate_id,
|
||||
"candidate_fingerprint": candidate.fingerprint,
|
||||
"release_plan_sha256": plan.sha256,
|
||||
"attestation_fingerprint": attestation.fingerprint,
|
||||
"status": "VERIFIED",
|
||||
}
|
||||
|
||||
def get_capability_status(
|
||||
self, candidate_id: str
|
||||
) -> dict[str, Any]:
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""Exact source-only admission composition within the existing Bridge.
|
||||
|
||||
This module neither installs itself nor issues authority. A host activation must
|
||||
pin an Owner-signed policy and an independently verified epoch. The existing
|
||||
Task admission store, V3 producer, Task Plane and principal verifier are reused.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import base64
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
SignedArtifact, canonical_json_bytes, _open_secure,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_bridge_contract import TaskAdmissionBridgeContract
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission, SourcePublicationPolicyVerifier,
|
||||
)
|
||||
|
||||
|
||||
def _read_object(path: Path) -> tuple[bytes, dict[str, Any]]:
|
||||
raw = _open_secure(path, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
value = json.loads(raw)
|
||||
if not isinstance(value, dict):
|
||||
raise ValueError("SOURCE_ACTIVATION_OBJECT_REQUIRED")
|
||||
return raw, value
|
||||
|
||||
|
||||
class ScopedPrincipalProvider:
|
||||
"""Policy revocation also fences principals from already admitted Tasks."""
|
||||
|
||||
def __init__(self, guarded: ScopedSourcePublicationPolicyAdmission, delegate: Any):
|
||||
self.guarded = guarded
|
||||
self.delegate = delegate
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
try:
|
||||
self.guarded.verifier.require(self.guarded.artifact, self.guarded.request)
|
||||
return self.delegate.readiness()
|
||||
except Exception as exc:
|
||||
return {"ready": False, "mode": "source-publication-only", "error": str(exc)}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]) -> Any:
|
||||
grant = self.delegate.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is None or grant.parent_epoch_id != self.delegate.epoch_id:
|
||||
raise TaskAdmissionError("SOURCE_PRINCIPAL_GRANT_REQUIRED", 403)
|
||||
# status rechecks the signed policy, exact durable envelope and original
|
||||
# signed epoch. The delegate then verifies the live TaskLease/state.
|
||||
self.guarded.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
class _AdmissionContext:
|
||||
"""Metadata only for legacy hosts retaining their signed principal verifier."""
|
||||
def __init__(self, bundle):
|
||||
self.store, self.authorization = bundle.store, bundle.authorization
|
||||
self.task_port, self.epoch_id = bundle.task_port, bundle.epoch_id
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED"}
|
||||
|
||||
def __call__(self, *_args, **_kwargs):
|
||||
raise TaskAdmissionError("REGISTERED_SIGNED_PRINCIPAL_VERIFIER_REQUIRED", 403)
|
||||
|
||||
|
||||
def guard_source_runtime(
|
||||
runtime: TaskAdmissionRuntimeBundle, *, artifact: SignedArtifact,
|
||||
verifier: SourcePublicationPolicyVerifier, expected_agent_id: str,
|
||||
) -> TaskAdmissionRuntimeBundle:
|
||||
service = runtime.service
|
||||
if service is None:
|
||||
raise ValueError("SOURCE_ACTIVATION_BOOTSTRAP_REQUIRED")
|
||||
guarded = ScopedSourcePublicationPolicyAdmission(
|
||||
verifier, bootstrap=service, artifact=artifact,
|
||||
expected_agent_id=expected_agent_id,
|
||||
)
|
||||
# Build the typed request through the same exact-envelope guard used by the
|
||||
# HTTP contract, instead of accepting a caller-supplied request projection.
|
||||
payload = artifact.payload
|
||||
request = guarded._request_for_raw({
|
||||
"repository": payload["repository"],
|
||||
"authority_base_oid": payload["authority_base_oid"],
|
||||
"target_branch": payload["target_branch"], "generation": payload["generation"],
|
||||
"execution_digest": payload["execution_digest"], "risk": payload["risk"],
|
||||
"file_allowlist": payload["exact_files"], "requested_capabilities": payload["capabilities"],
|
||||
})
|
||||
guarded.request = request
|
||||
verifier.require(artifact, request)
|
||||
principal = runtime.principal_verifier or _AdmissionContext(runtime)
|
||||
epoch = principal.authorization.verify_delegation_epoch(principal.epoch_id)
|
||||
# The complete per-envelope check runs again on authorization and every
|
||||
# principal use; reject a production-capable epoch already at composition.
|
||||
if (getattr(epoch, "production_scope", None) != "none"
|
||||
or getattr(epoch, "destructive_policy", None) != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers=TaskAdmissionBridgeContract(guarded).handlers(),
|
||||
principal_verifier=ScopedPrincipalProvider(guarded, principal),
|
||||
service=guarded,
|
||||
)
|
||||
|
||||
|
||||
def build_source_runtime_from_activation_file(path: Path, *, post_admission: Any,
|
||||
ordinary_activation_file: Path):
|
||||
from aios_task_admission_live_adapters import (
|
||||
build_task_admission_runtime_from_activation_file,
|
||||
load_owner_approval_verifier,
|
||||
)
|
||||
_, config = _read_object(path)
|
||||
if set(config) != {"schema", "policy_artifact", "policy_sha256", "policy_revocations",
|
||||
"admission_activation", "expected_agent_id"} or config["schema"] != "aios.source-publication-host-activation.v1":
|
||||
raise ValueError("SOURCE_ACTIVATION_FIELDS_INVALID")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = config[key]
|
||||
if not isinstance(value, str) or not value.startswith("/") or ".." in Path(value).parts:
|
||||
raise ValueError("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
raw, signed = _read_object(Path(config["policy_artifact"]))
|
||||
if hashlib.sha256(raw).hexdigest() != config["policy_sha256"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
if set(signed) != {"payload", "signature", "key_id", "namespace"}:
|
||||
raise ValueError("SOURCE_ACTIVATION_ARTIFACT_INVALID")
|
||||
artifact = SignedArtifact(canonical_payload=canonical_json_bytes(signed["payload"]), **signed)
|
||||
_, ordinary_config = _read_object(ordinary_activation_file)
|
||||
_, source_config = _read_object(Path(config["admission_activation"]))
|
||||
for field in ("state_db", "task_api_url", "task_control_token_file", "owner_verifier_module"):
|
||||
if not ordinary_config.get(field) or source_config.get(field) != ordinary_config[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH")
|
||||
|
||||
def not_revoked(payload: Mapping[str, Any]) -> bool:
|
||||
_, value = _read_object(Path(config["policy_revocations"]))
|
||||
if (set(value) != {"schema", "revoked_nonces"}
|
||||
or value["schema"] != "aios.source-publication-revocations.v1"
|
||||
or not isinstance(value["revoked_nonces"], list)
|
||||
or any(not isinstance(nonce, str) or not nonce for nonce in value["revoked_nonces"])):
|
||||
return False
|
||||
return payload["nonce"] not in value["revoked_nonces"]
|
||||
|
||||
runtime = build_task_admission_runtime_from_activation_file(
|
||||
Path(config["admission_activation"]), post_admission=post_admission)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
load_owner_approval_verifier(Path(source_config["owner_verifier_module"])),
|
||||
revocation_check=not_revoked,
|
||||
expected_policy_digest=runtime.service.policy.normalized_policy_digest(),
|
||||
)
|
||||
guarded_runtime = guard_source_runtime(runtime, artifact=artifact, verifier=verifier,
|
||||
expected_agent_id=config["expected_agent_id"])
|
||||
payload = artifact.payload
|
||||
plan_path = Path(source_config["execution_plan_dir"]) / (payload["execution_digest"] + ".json")
|
||||
plan_bytes, plan = _read_object(plan_path)
|
||||
if (hashlib.sha256(plan_bytes).hexdigest() != payload["execution_digest"]
|
||||
or canonical_json_bytes(plan) != plan_bytes):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_DIGEST_MISMATCH")
|
||||
hashes = plan.get("file_sha256")
|
||||
if not isinstance(hashes, dict) or set(hashes) != set(payload["exact_files"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_FILE_HASH_SET_MISMATCH")
|
||||
for field in ("repository", "authority_base_oid", "target_branch", "generation", "risk"):
|
||||
if plan.get(field) != payload[field]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_SCOPE_MISMATCH")
|
||||
if sorted(plan.get("capabilities", [])) != sorted(payload["capabilities"]):
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_CAPABILITIES_MISMATCH")
|
||||
if not isinstance(plan.get("task_id"), str) or not plan["task_id"]:
|
||||
raise ValueError("SOURCE_ACTIVATION_PLAN_TASK_REQUIRED")
|
||||
guarded_runtime.service.expected_file_sha256 = dict(hashes)
|
||||
guarded_runtime.service.expected_task_id = plan["task_id"]
|
||||
return guarded_runtime
|
||||
|
||||
|
||||
class AdmissionRuntimeRouter:
|
||||
"""Route an exact branch/epoch without widening the ordinary policy."""
|
||||
|
||||
def __init__(self, ordinary: TaskAdmissionRuntimeBundle, source: TaskAdmissionRuntimeBundle):
|
||||
self.ordinary = ordinary
|
||||
self.source = source
|
||||
self.guard = source.service
|
||||
self.store = self.guard.bootstrap.store
|
||||
|
||||
def _source_request(self, operation: str, request: Mapping[str, Any]) -> bool:
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return request.get("target_branch") == self.guard.artifact.payload["target_branch"]
|
||||
envelope_id = str(request.get("envelope_id") or "")
|
||||
row = self.store.get_envelope(envelope_id)
|
||||
if row is None:
|
||||
# Never fall through after finding a source envelope, including
|
||||
# when it has expired or been revoked.
|
||||
return False
|
||||
envelope = self.guard.bootstrap._load_envelope(envelope_id)
|
||||
return envelope.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
def dispatch(operation: str, request: Mapping[str, Any]):
|
||||
runtime = self.source if self._source_request(operation, request) else self.ordinary
|
||||
return runtime.handlers[operation](request)
|
||||
return {op: (lambda request, op=op: dispatch(op, request)) for op in self.source.handlers}
|
||||
|
||||
def readiness(self) -> Mapping[str, Any]:
|
||||
return {"mode": "scoped-admission-router", "ordinary": self.ordinary.principal_verifier.readiness(),
|
||||
"source_publication": self.source.principal_verifier.readiness()}
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
provider = (self.source.principal_verifier if grant is not None
|
||||
and grant.target_branch == self.guard.artifact.payload["target_branch"]
|
||||
else self.ordinary.principal_verifier)
|
||||
return provider(transport, request)
|
||||
|
||||
def bundle(self) -> TaskAdmissionRuntimeBundle:
|
||||
return TaskAdmissionRuntimeBundle(handlers=self.handlers(), principal_verifier=self)
|
||||
|
||||
|
||||
class ExistingPrincipalPolicyFence:
|
||||
"""Add a C5 policy fence while retaining the registered signed verifier.
|
||||
|
||||
Layered staging hosts use a signed TaskPrincipal verifier with their own
|
||||
storage scope. They must not be migrated to a server attestation merely to
|
||||
add source admission. Every request still reaches the original verifier.
|
||||
"""
|
||||
|
||||
def __init__(self, delegate: Any, source_runtime: TaskAdmissionRuntimeBundle):
|
||||
self.delegate = delegate
|
||||
self.guard = source_runtime.service
|
||||
|
||||
def __getattr__(self, name: str):
|
||||
return getattr(self.delegate, name)
|
||||
|
||||
def __call__(self, transport: Mapping[str, Any], request: Mapping[str, Any]):
|
||||
grant = self.guard.bootstrap.store.get_grant_by_task_id(str(request.get("task_id") or ""))
|
||||
if grant is not None and grant.target_branch == self.guard.artifact.payload["target_branch"]:
|
||||
self.guard.status({"envelope_id": grant.envelope_id,
|
||||
"delegation_epoch_id": grant.parent_epoch_id})
|
||||
if transport.get("operation") in {"commitFilesGoverned", "commitFileGoverned"}:
|
||||
expected = getattr(self.guard, "expected_file_sha256", None)
|
||||
if not isinstance(expected, dict) or not expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_HASHES_REQUIRED", 403)
|
||||
try:
|
||||
raw = request.get("files")
|
||||
if raw is None:
|
||||
raw = {request["path"]: request["content_base64"]}
|
||||
pairs = list(raw.items()) if isinstance(raw, dict) else [(item["path"], item["content_base64"]) for item in raw]
|
||||
if len({path for path, _ in pairs}) != len(pairs):
|
||||
raise ValueError("duplicate path")
|
||||
actual = {path: hashlib.sha256(base64.b64decode(data, validate=True)).hexdigest()
|
||||
for path, data in pairs}
|
||||
except Exception as exc:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_FILE_PAYLOAD_INVALID", 403) from exc
|
||||
if actual != expected:
|
||||
raise TaskAdmissionError("SOURCE_COMMIT_CONTENT_SCOPE_MISMATCH", 403)
|
||||
return self.delegate(transport, request)
|
||||
|
||||
|
||||
def expired_admission_runtime() -> TaskAdmissionRuntimeBundle:
|
||||
"""Keep a verified-expired ordinary epoch denied while serving other routes."""
|
||||
def deny(*_args: Any, **_kwargs: Any):
|
||||
raise TaskAdmissionError("TASK_ADMISSION_EPOCH_EXPIRED", 403)
|
||||
|
||||
class ClosedPrincipal:
|
||||
__call__ = staticmethod(deny)
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": False, "error": "TASK_ADMISSION_EPOCH_EXPIRED"}
|
||||
|
||||
from aios_task_admission_bridge_contract import TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
return TaskAdmissionRuntimeBundle(
|
||||
handlers={op: deny for op in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS},
|
||||
principal_verifier=ClosedPrincipal(),
|
||||
)
|
||||
@@ -117,6 +117,9 @@ class TaskPlaneService:
|
||||
else None
|
||||
)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
self.store.evidence_attestation_verifier = (
|
||||
self.policy.evidence_attestation_verifier
|
||||
)
|
||||
|
||||
def consume_one_root_action(
|
||||
self,
|
||||
|
||||
@@ -0,0 +1,972 @@
|
||||
"""AIOS-native binding layer for the unattended resume controller.
|
||||
|
||||
This module intentionally contains no HTTP client, Unix-socket client, database,
|
||||
systemd integration, scheduler loop, secret handling, or privileged identity
|
||||
mutation.
|
||||
|
||||
It converts existing AIOS authorities into the generic callbacks defined by
|
||||
``autonomous_resume_integration``. Field code supplies the current authoritative
|
||||
implementations for:
|
||||
|
||||
- durable Owner authorization/grant projection;
|
||||
- canonical governed-worker activation/status;
|
||||
- governed task admission;
|
||||
- task/claim/TaskLease/TaskPrincipal/WriterLease/CAS ownership;
|
||||
- bounded source-lane execution and integration;
|
||||
- Gate0;
|
||||
- ReleaseReady/deployability;
|
||||
- PromotionController;
|
||||
- final production acceptance;
|
||||
- mission resume lease/checkpoint/effect reconciliation.
|
||||
|
||||
The adapter is deterministic and fail-closed. It never fabricates any credential,
|
||||
principal, lease, fence, task, commit, receipt, approval, or deployment result.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol, Sequence
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from .autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
def _canonical(value) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class WorkerState(str, Enum):
|
||||
ACTIVE = "ACTIVE"
|
||||
STARTING = "STARTING"
|
||||
INACTIVE = "INACTIVE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class WorkState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETE = "COMPLETE"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class GateState(str, Enum):
|
||||
PASS = "PASS"
|
||||
HOLD = "HOLD"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReadyState(str, Enum):
|
||||
READY = "READY"
|
||||
NOT_READY = "NOT_READY"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class PromotionState(str, Enum):
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
VERIFIED = "VERIFIED"
|
||||
ROLLED_BACK = "ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class AcceptanceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL_ROLLED_BACK = "FAIL_ROLLED_BACK"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DurableGrantView:
|
||||
grant_id: str
|
||||
active: bool
|
||||
repository: str
|
||||
allowed_target_prefixes: tuple[str, ...]
|
||||
max_task_risk: str
|
||||
expires_at: float | None = None
|
||||
owner_action_required: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def permits(self, *, repository: str, branch: str, risk: str) -> bool:
|
||||
if not self.active:
|
||||
return False
|
||||
if self.repository != repository:
|
||||
return False
|
||||
if not any(branch.startswith(prefix) for prefix in self.allowed_target_prefixes):
|
||||
return False
|
||||
rank = {"low": 0, "medium": 1, "engineering": 2, "high": 3}
|
||||
if risk not in rank or self.max_task_risk not in rank:
|
||||
return False
|
||||
return rank[risk] <= rank[self.max_task_risk]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLaneSpec:
|
||||
lane: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
write_set: tuple[str, ...]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lane or not self.task_id or not self.repository:
|
||||
raise ValueError("lane_task_repository_required")
|
||||
if not self.target_branch.startswith("agent/"):
|
||||
raise ValueError("target_branch_must_use_agent_prefix")
|
||||
if not self.write_set:
|
||||
raise ValueError("write_set_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionRequest:
|
||||
task_id: str
|
||||
idempotency_key: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
expected_branch_oid: str
|
||||
risk: str
|
||||
description: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskAdmissionReceipt:
|
||||
task_id: str
|
||||
generation: int
|
||||
target_branch: str
|
||||
persisted: bool
|
||||
claimable: bool
|
||||
receipt_ref: str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.generation < 1:
|
||||
raise ValueError("task_generation_must_be_positive")
|
||||
if not self.receipt_ref:
|
||||
raise ValueError("task_admission_receipt_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerActivationReceipt:
|
||||
state: WorkerState
|
||||
receipt_ref: str | None = None
|
||||
retry_after_seconds: int | None = None
|
||||
owner_action_required: bool = False
|
||||
code: str = "WORKER_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LaneExecutionView:
|
||||
lane: str
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "LANE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class IntegrationView:
|
||||
state: WorkState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "INTEGRATION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GateView:
|
||||
state: GateState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "GATE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReleaseReadyView:
|
||||
state: ReadyState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "READY_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionView:
|
||||
state: PromotionState
|
||||
condition_fingerprint: str
|
||||
material_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
code: str = "PROMOTION_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AcceptanceView:
|
||||
state: AcceptanceState
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
code: str = "ACCEPTANCE_STATE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionEffectView:
|
||||
effect_state: EffectState
|
||||
condition_fingerprint: str
|
||||
requires_reconcile: bool
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AiosMissionAuthorities(Protocol):
|
||||
"""Existing AIOS authority surface consumed by this adapter."""
|
||||
|
||||
# Live truth.
|
||||
def read_head_oid(self, repository: str) -> str: ...
|
||||
def read_material_digest(self, mission_id: str) -> str: ...
|
||||
def read_generation(self, mission_id: str) -> int: ...
|
||||
def read_effect_view(self, mission_id: str) -> MissionEffectView: ...
|
||||
def owner_action_resolved(self, mission_id: str) -> bool: ...
|
||||
|
||||
# Durable mission state / one-winner resume authority.
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, object],
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
def reconcile_effects(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
# Admission.
|
||||
def read_durable_grant(self, mission_id: str) -> DurableGrantView: ...
|
||||
def ensure_canonical_worker(
|
||||
self,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WorkerActivationReceipt: ...
|
||||
def create_or_resume_task(
|
||||
self,
|
||||
request: TaskAdmissionRequest,
|
||||
) -> TaskAdmissionReceipt: ...
|
||||
def read_task_admission(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
) -> TaskAdmissionReceipt | None: ...
|
||||
|
||||
# Phase execution.
|
||||
def read_lane_execution(
|
||||
self,
|
||||
lane: TaskLaneSpec,
|
||||
task: TaskAdmissionReceipt,
|
||||
) -> LaneExecutionView: ...
|
||||
def drive_integration(
|
||||
self,
|
||||
lanes: Sequence[TaskLaneSpec],
|
||||
tasks: Sequence[TaskAdmissionReceipt],
|
||||
) -> IntegrationView: ...
|
||||
def run_gate0(self, mission_id: str) -> GateView: ...
|
||||
def build_release_ready(self, mission_id: str) -> ReleaseReadyView: ...
|
||||
def run_promotion(self, mission_id: str) -> PromotionView: ...
|
||||
def run_final_acceptance(self, mission_id: str) -> AcceptanceView: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AiosUnattendedMissionConfig:
|
||||
mission_id: str
|
||||
repository: str
|
||||
lanes: tuple[TaskLaneSpec, ...]
|
||||
admission_risk: str = "medium"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id or not self.repository:
|
||||
raise ValueError("mission_and_repository_required")
|
||||
if not self.lanes:
|
||||
raise ValueError("at_least_one_lane_required")
|
||||
names = [lane.lane for lane in self.lanes]
|
||||
task_ids = [lane.task_id for lane in self.lanes]
|
||||
branches = [lane.target_branch for lane in self.lanes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("duplicate_lane")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("duplicate_task_id")
|
||||
if len(branches) != len(set(branches)):
|
||||
raise ValueError("duplicate_target_branch")
|
||||
owners: dict[str, str] = {}
|
||||
for lane in self.lanes:
|
||||
if lane.repository != self.repository:
|
||||
raise ValueError("lane_repository_mismatch")
|
||||
for resource in lane.write_set:
|
||||
if resource in owners:
|
||||
raise ValueError(
|
||||
f"overlapping_lane_resource:{resource}:"
|
||||
f"{owners[resource]}:{lane.lane}"
|
||||
)
|
||||
owners[resource] = lane.lane
|
||||
|
||||
|
||||
class AiosUnattendedCallbackFactory:
|
||||
"""Construct generic controller callbacks from current AIOS authorities."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AiosMissionAuthorities,
|
||||
config: AiosUnattendedMissionConfig,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.config = config
|
||||
self._admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
|
||||
def build_port(self) -> CallableAutonomousExecutionPort:
|
||||
return CallableAutonomousExecutionPort(
|
||||
AuthorityCallbacks(
|
||||
read_observation=self.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile_effects,
|
||||
),
|
||||
PhaseCallbacks(
|
||||
admission=self.admission,
|
||||
source_execution=self.source_execution,
|
||||
gate0=self.gate0,
|
||||
release_ready=self.release_ready,
|
||||
promotion=self.promotion,
|
||||
final_acceptance=self.final_acceptance,
|
||||
),
|
||||
)
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
if mission_id != self.config.mission_id:
|
||||
raise ValueError("mission_id_mismatch")
|
||||
generation = self.authority.read_generation(mission_id)
|
||||
head_oid = self.authority.read_head_oid(self.config.repository)
|
||||
material = self.authority.read_material_digest(mission_id)
|
||||
effect = self.authority.read_effect_view(mission_id)
|
||||
fingerprint = _digest({
|
||||
"schema": "aios.unattended-condition.v1",
|
||||
"mission_id": mission_id,
|
||||
"generation": generation,
|
||||
"head_oid": head_oid,
|
||||
"material_digest": material,
|
||||
"effect_state": effect.effect_state.value,
|
||||
"effect_condition": effect.condition_fingerprint,
|
||||
"requires_reconcile": effect.requires_reconcile,
|
||||
})
|
||||
return MissionObservation(
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
head_oid=head_oid,
|
||||
material_digest=material,
|
||||
condition_fingerprint=fingerprint,
|
||||
effect_state=effect.effect_state,
|
||||
requires_reconcile=effect.requires_reconcile,
|
||||
owner_action_resolved=self.authority.owner_action_resolved(mission_id),
|
||||
evidence_refs=effect.receipt_refs,
|
||||
)
|
||||
|
||||
def admission(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
grant = self.authority.read_durable_grant(observation.mission_id)
|
||||
grant_refs = tuple(grant.evidence_refs)
|
||||
|
||||
if not grant.active:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_NOT_ACTIVE",
|
||||
self._phase_fingerprint(
|
||||
"admission", observation, {"grant_id": grant.grant_id, "active": False}
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_NOT_ACTIVE",
|
||||
"grant_id": grant.grant_id,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
for lane in self.config.lanes:
|
||||
if not grant.permits(
|
||||
repository=lane.repository,
|
||||
branch=lane.target_branch,
|
||||
risk=lane.risk,
|
||||
):
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if grant.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
"branch": lane.target_branch,
|
||||
"risk": lane.risk,
|
||||
},
|
||||
),
|
||||
receipt_refs=grant_refs,
|
||||
owner_action_payload=(
|
||||
{
|
||||
"boundary": "DURABLE_GRANT_SCOPE_MISMATCH",
|
||||
"grant_id": grant.grant_id,
|
||||
"lane": lane.lane,
|
||||
}
|
||||
if grant.owner_action_required else None
|
||||
),
|
||||
)
|
||||
|
||||
worker = self.authority.ensure_canonical_worker(
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
self._idempotency(
|
||||
"worker",
|
||||
observation.mission_id,
|
||||
observation.generation,
|
||||
),
|
||||
)
|
||||
worker_refs = self._refs(grant_refs, (worker.receipt_ref or "",))
|
||||
|
||||
if worker.state is WorkerState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
if worker.state is WorkerState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_OWNER if worker.owner_action_required
|
||||
else StepStatus.BLOCKED_DETERMINISTIC,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
owner_action_payload=(
|
||||
{"boundary": worker.code}
|
||||
if worker.owner_action_required else None
|
||||
),
|
||||
)
|
||||
if worker.state in {WorkerState.STARTING, WorkerState.INACTIVE}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
worker.code,
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
retry_after_seconds=worker.retry_after_seconds,
|
||||
)
|
||||
if worker.state is WorkerState.ACTIVE and not worker.receipt_ref:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"WORKER_ACTIVE_WITHOUT_RECEIPT",
|
||||
self._phase_fingerprint(
|
||||
"worker", observation, {"state": worker.state.value}
|
||||
),
|
||||
receipt_refs=worker_refs,
|
||||
)
|
||||
|
||||
task_refs: list[str] = list(worker_refs)
|
||||
admissions: dict[str, TaskAdmissionReceipt] = {}
|
||||
for lane in self.config.lanes:
|
||||
request = TaskAdmissionRequest(
|
||||
task_id=lane.task_id,
|
||||
idempotency_key=self._idempotency(
|
||||
"task",
|
||||
observation.generation,
|
||||
lane.task_id,
|
||||
observation.head_oid,
|
||||
),
|
||||
repository=lane.repository,
|
||||
base_oid=observation.head_oid,
|
||||
target_branch=lane.target_branch,
|
||||
expected_branch_oid=observation.head_oid,
|
||||
risk=lane.risk,
|
||||
description=lane.description,
|
||||
)
|
||||
receipt = self.authority.create_or_resume_task(request)
|
||||
task_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"task",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
)
|
||||
admissions[lane.lane] = receipt
|
||||
|
||||
self._admissions = admissions
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"GOVERNED_TASKS_ADMITTED",
|
||||
self._phase_fingerprint(
|
||||
"admission",
|
||||
observation,
|
||||
{"tasks": sorted(r.task_id for r in admissions.values())},
|
||||
),
|
||||
receipt_refs=tuple(task_refs),
|
||||
next_phase=MissionPhase.S2_EXECUTION,
|
||||
)
|
||||
|
||||
def source_execution(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
recovery_refs: list[str] = []
|
||||
for lane in self.config.lanes:
|
||||
if lane.lane in self._admissions:
|
||||
continue
|
||||
receipt = self.authority.read_task_admission(lane)
|
||||
if receipt is None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_ADMISSION_NOT_RESOLVED",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
recovery_refs.append(receipt.receipt_ref)
|
||||
receipt_error = self._admission_receipt_error(
|
||||
lane,
|
||||
receipt,
|
||||
observation=observation,
|
||||
)
|
||||
if receipt_error is not None:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
receipt_error,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.persisted:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"TASK_PERSISTENCE_UNKNOWN",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
if not receipt.claimable:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"TASK_NOT_CLAIMABLE_YET",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"lane": lane.lane, "task_id": lane.task_id},
|
||||
),
|
||||
receipt_refs=tuple(recovery_refs),
|
||||
)
|
||||
self._admissions[lane.lane] = receipt
|
||||
|
||||
views: list[LaneExecutionView] = []
|
||||
refs: list[str] = list(recovery_refs)
|
||||
for lane in self.config.lanes:
|
||||
view = self.authority.read_lane_execution(
|
||||
lane,
|
||||
self._admissions[lane.lane],
|
||||
)
|
||||
if view.lane != lane.lane:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_IDENTITY_MISMATCH",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"expected": lane.lane, "actual": view.lane},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if view.state is WorkState.COMPLETE and not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"LANE_COMPLETE_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
views.append(view)
|
||||
refs.extend(view.receipt_refs)
|
||||
|
||||
unknown = [v for v in views if v.state is WorkState.UNKNOWN]
|
||||
if unknown:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
unknown[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
blocked = [v for v in views if v.state is WorkState.BLOCKED]
|
||||
if blocked:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
blocked[0].code,
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
|
||||
pending = [
|
||||
v for v in views
|
||||
if v.state in {WorkState.PENDING, WorkState.RUNNING}
|
||||
]
|
||||
if pending:
|
||||
retry_values = [
|
||||
v.retry_after_seconds
|
||||
for v in pending
|
||||
if v.retry_after_seconds is not None
|
||||
]
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
"SOURCE_LANES_IN_PROGRESS",
|
||||
self._phase_fingerprint(
|
||||
"source",
|
||||
observation,
|
||||
{"states": [(v.lane, v.state.value) for v in views]},
|
||||
),
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=min(retry_values) if retry_values else None,
|
||||
)
|
||||
|
||||
integration = self.authority.drive_integration(
|
||||
self.config.lanes,
|
||||
tuple(self._admissions[lane.lane] for lane in self.config.lanes),
|
||||
)
|
||||
refs.extend(integration.receipt_refs)
|
||||
if integration.state is WorkState.UNKNOWN:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state is WorkState.BLOCKED:
|
||||
return StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
if integration.state in {WorkState.PENDING, WorkState.RUNNING}:
|
||||
return StepResult(
|
||||
StepStatus.IN_PROGRESS,
|
||||
integration.code,
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
retry_after_seconds=integration.retry_after_seconds,
|
||||
)
|
||||
if integration.state is WorkState.COMPLETE and not integration.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"INTEGRATION_COMPLETE_WITHOUT_RECEIPT",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"SOURCE_INTEGRATION_COMPLETE",
|
||||
integration.condition_fingerprint,
|
||||
receipt_refs=tuple(refs),
|
||||
next_phase=MissionPhase.GATE0,
|
||||
)
|
||||
|
||||
def gate0(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_gate0(observation.mission_id)
|
||||
if view.state is GateState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is GateState.HOLD:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"GATE0_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.RELEASE_READY
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
def release_ready(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.build_release_ready(observation.mission_id)
|
||||
if view.state is ReadyState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is ReadyState.NOT_READY:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
if not view.material_digest:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_MATERIAL_DIGEST",
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
)
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"READY_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.PROMOTION
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def promotion(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_promotion(observation.mission_id)
|
||||
if view.state is PromotionState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is PromotionState.IN_PROGRESS:
|
||||
status = StepStatus.IN_PROGRESS
|
||||
elif view.state is PromotionState.VERIFIED:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"PROMOTION_VERIFIED_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
elif view.state is PromotionState.ROLLED_BACK:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
retry_after_seconds=view.retry_after_seconds,
|
||||
next_phase=(
|
||||
MissionPhase.FINAL_ACCEPTANCE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
material_digest=view.material_digest,
|
||||
)
|
||||
|
||||
def final_acceptance(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
view = self.authority.run_final_acceptance(observation.mission_id)
|
||||
if view.state is AcceptanceState.UNKNOWN:
|
||||
status = StepStatus.RECONCILE
|
||||
elif view.state is AcceptanceState.PASS:
|
||||
if not view.receipt_refs:
|
||||
return StepResult(
|
||||
StepStatus.RECONCILE,
|
||||
"FINAL_ACCEPTANCE_PASS_WITHOUT_RECEIPT",
|
||||
view.condition_fingerprint,
|
||||
)
|
||||
status = StepStatus.SUCCEEDED
|
||||
else:
|
||||
status = StepStatus.BLOCKED_DETERMINISTIC
|
||||
return StepResult(
|
||||
status,
|
||||
view.code,
|
||||
view.condition_fingerprint,
|
||||
receipt_refs=view.receipt_refs,
|
||||
next_phase=(
|
||||
MissionPhase.COMPLETE
|
||||
if status is StepStatus.SUCCEEDED else None
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _admission_receipt_error(
|
||||
lane: TaskLaneSpec,
|
||||
receipt: TaskAdmissionReceipt,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
) -> str | None:
|
||||
if receipt.task_id != lane.task_id:
|
||||
return "TASK_IDENTITY_MISMATCH"
|
||||
if receipt.target_branch != lane.target_branch:
|
||||
return "TASK_BRANCH_MISMATCH"
|
||||
if receipt.generation != observation.generation:
|
||||
return "TASK_GENERATION_MISMATCH"
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
seen: set[str] = set()
|
||||
values: list[str] = []
|
||||
for group in groups:
|
||||
for item in group:
|
||||
if item and item not in seen:
|
||||
seen.add(item)
|
||||
values.append(item)
|
||||
return tuple(values)
|
||||
|
||||
@staticmethod
|
||||
def _idempotency(*parts: object) -> str:
|
||||
return "aios:auto:" + _digest(
|
||||
{"schema": "aios.unattended-idempotency.v1", "parts": list(parts)}
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _phase_fingerprint(
|
||||
phase: str,
|
||||
observation: MissionObservation,
|
||||
details: Mapping[str, object],
|
||||
) -> str:
|
||||
return _digest({
|
||||
"schema": "aios.unattended-phase-condition.v1",
|
||||
"phase": phase,
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
"head_oid": observation.head_oid,
|
||||
"material_digest": observation.material_digest,
|
||||
"details": details,
|
||||
})
|
||||
@@ -0,0 +1,877 @@
|
||||
"""Deterministic unattended mission-resume controller for AIOS.
|
||||
|
||||
The controller is deliberately small and does not create a second scheduler,
|
||||
database, identity system, or release control plane.
|
||||
|
||||
It executes exactly one reconciliation/progress cycle per ``run_once`` call.
|
||||
An existing AIOS scheduler/timer/worker loop should invoke it periodically.
|
||||
|
||||
All consequential effects are delegated through ``AutonomousExecutionPort`` and
|
||||
must use the existing AIOS authorities: durable Owner authorization, task
|
||||
admission, claim/TaskLease/TaskPrincipal, WriterLease/CAS/fencing, Gate0,
|
||||
ReleaseReady, PromotionController and governed rollback.
|
||||
|
||||
The controller's responsibilities are limited to:
|
||||
- one-winner resume lease acquisition;
|
||||
- durable checkpoint progression;
|
||||
- bounded retry/backoff;
|
||||
- unchanged deterministic blocker suppression;
|
||||
- UNKNOWN/ambiguous-effect reconciliation before retry;
|
||||
- non-interactive owner-boundary recording;
|
||||
- crash-safe phase resumption.
|
||||
|
||||
It never manufactures credentials, group membership, principals, leases,
|
||||
signatures, branch OIDs, or release receipts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field, replace
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class MissionPhase(str, Enum):
|
||||
S2_ADMISSION = "S2_ADMISSION"
|
||||
S2_EXECUTION = "S2_EXECUTION"
|
||||
GATE0 = "GATE0"
|
||||
RELEASE_READY = "RELEASE_READY"
|
||||
PROMOTION = "PROMOTION"
|
||||
FINAL_ACCEPTANCE = "FINAL_ACCEPTANCE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
COMPLETE = "COMPLETE"
|
||||
|
||||
|
||||
class EffectState(str, Enum):
|
||||
ABSENT = "ABSENT"
|
||||
ACTIVE = "ACTIVE"
|
||||
COMPLETED = "COMPLETED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class StepStatus(str, Enum):
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRYABLE = "RETRYABLE"
|
||||
RECONCILE = "RECONCILE"
|
||||
BLOCKED_DETERMINISTIC = "BLOCKED_DETERMINISTIC"
|
||||
BLOCKED_OWNER = "BLOCKED_OWNER"
|
||||
|
||||
|
||||
class OutcomeStatus(str, Enum):
|
||||
COMPLETE = "COMPLETE"
|
||||
PROGRESSED = "PROGRESSED"
|
||||
IN_PROGRESS = "IN_PROGRESS"
|
||||
RETRY_SCHEDULED = "RETRY_SCHEDULED"
|
||||
WAIT_CONDITION_CHANGE = "WAIT_CONDITION_CHANGE"
|
||||
ACTIVE_ELSEWHERE = "ACTIVE_ELSEWHERE"
|
||||
OWNER_ACTION_REQUIRED = "OWNER_ACTION_REQUIRED"
|
||||
|
||||
|
||||
_PHASE_NEXT: dict[MissionPhase, MissionPhase] = {
|
||||
MissionPhase.S2_ADMISSION: MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.S2_EXECUTION: MissionPhase.GATE0,
|
||||
MissionPhase.GATE0: MissionPhase.RELEASE_READY,
|
||||
MissionPhase.RELEASE_READY: MissionPhase.PROMOTION,
|
||||
MissionPhase.PROMOTION: MissionPhase.FINAL_ACCEPTANCE,
|
||||
MissionPhase.FINAL_ACCEPTANCE: MissionPhase.COMPLETE,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumePolicy:
|
||||
resume_lease_seconds: int = 120
|
||||
default_poll_seconds: int = 30
|
||||
deterministic_block_poll_seconds: int = 300
|
||||
transient_max_attempts: int = 3
|
||||
transient_base_delay_seconds: int = 2
|
||||
transient_max_delay_seconds: int = 60
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"resume_lease_seconds",
|
||||
"default_poll_seconds",
|
||||
"deterministic_block_poll_seconds",
|
||||
"transient_max_attempts",
|
||||
"transient_base_delay_seconds",
|
||||
"transient_max_delay_seconds",
|
||||
):
|
||||
if getattr(self, name) < 1:
|
||||
raise ValueError(f"{name}_must_be_positive")
|
||||
if self.transient_max_delay_seconds < self.transient_base_delay_seconds:
|
||||
raise ValueError("transient_max_delay_seconds_too_small")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionObservation:
|
||||
mission_id: str
|
||||
generation: int
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
condition_fingerprint: str
|
||||
effect_state: EffectState = EffectState.ABSENT
|
||||
requires_reconcile: bool = False
|
||||
owner_action_resolved: bool = False
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.head_oid:
|
||||
raise ValueError("head_oid_required")
|
||||
if not self.material_digest:
|
||||
raise ValueError("material_digest_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumeLease:
|
||||
lease_id: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
fencing_token: str
|
||||
expires_at: float
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.lease_id:
|
||||
raise ValueError("lease_id_required")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if not self.fencing_token:
|
||||
raise ValueError("fencing_token_required")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StepResult:
|
||||
status: StepStatus
|
||||
code: str
|
||||
condition_fingerprint: str
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
retry_after_seconds: int | None = None
|
||||
next_phase: MissionPhase | None = None
|
||||
material_digest: str | None = None
|
||||
owner_action_payload: Mapping[str, Any] | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.code:
|
||||
raise ValueError("step_code_required")
|
||||
if not self.condition_fingerprint:
|
||||
raise ValueError("condition_fingerprint_required")
|
||||
if self.retry_after_seconds is not None and self.retry_after_seconds < 0:
|
||||
raise ValueError("retry_after_seconds_must_be_non_negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MissionCheckpoint:
|
||||
schema: str
|
||||
mission_id: str
|
||||
generation: int
|
||||
phase: MissionPhase
|
||||
head_oid: str
|
||||
material_digest: str
|
||||
last_condition_fingerprint: str
|
||||
attempt_count: int
|
||||
blocked_condition_fingerprint: str | None
|
||||
reconcile_required: bool
|
||||
frozen_material_digest: str | None
|
||||
resume_phase_after_owner: MissionPhase | None
|
||||
receipt_refs: tuple[str, ...]
|
||||
updated_at: float
|
||||
revision: int = 1
|
||||
|
||||
@classmethod
|
||||
def initial(
|
||||
cls,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
observed_at: float,
|
||||
) -> "MissionCheckpoint":
|
||||
return cls(
|
||||
schema="aios.autonomous-resume-checkpoint.v1",
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=tuple(observation.evidence_refs),
|
||||
updated_at=observed_at,
|
||||
revision=1,
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.schema != "aios.autonomous-resume-checkpoint.v1":
|
||||
raise ValueError("unsupported_checkpoint_schema")
|
||||
if not self.mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
if self.generation < 1:
|
||||
raise ValueError("generation_must_be_positive")
|
||||
if self.attempt_count < 0:
|
||||
raise ValueError("attempt_count_must_be_non_negative")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision_must_be_positive")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
payload = asdict(self)
|
||||
payload["phase"] = self.phase.value
|
||||
payload["resume_phase_after_owner"] = (
|
||||
None
|
||||
if self.resume_phase_after_owner is None
|
||||
else self.resume_phase_after_owner.value
|
||||
)
|
||||
payload["receipt_refs"] = list(self.receipt_refs)
|
||||
return _sha256(_canonical(payload))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerOutcome:
|
||||
status: OutcomeStatus
|
||||
mission_id: str
|
||||
phase: MissionPhase
|
||||
code: str
|
||||
next_wake_after_seconds: int | None
|
||||
checkpoint_digest: str | None
|
||||
receipt_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class AutonomousExecutionPort(Protocol):
|
||||
"""Adapter boundary to existing AIOS authorities and executors."""
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation: ...
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None: ...
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None: ...
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult: ...
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None: ...
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str: ...
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class AutonomousResumeController:
|
||||
"""One-cycle deterministic unattended mission controller."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
port: AutonomousExecutionPort,
|
||||
*,
|
||||
policy: ResumePolicy | None = None,
|
||||
clock: Callable[[], float] | None = None,
|
||||
) -> None:
|
||||
self.port = port
|
||||
self.policy = policy or ResumePolicy()
|
||||
self.clock = clock or time.time
|
||||
|
||||
def run_once(self, mission_id: str) -> ControllerOutcome:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
|
||||
now = float(self.clock())
|
||||
observation = self.port.read_observation(mission_id)
|
||||
checkpoint = self.port.load_checkpoint(mission_id)
|
||||
if checkpoint is None:
|
||||
checkpoint = MissionCheckpoint.initial(observation, observed_at=now)
|
||||
forced_reconcile = False
|
||||
else:
|
||||
checkpoint, forced_reconcile = self._validate_and_refresh_checkpoint(
|
||||
checkpoint,
|
||||
observation,
|
||||
now=now,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.COMPLETE:
|
||||
return self._outcome(
|
||||
OutcomeStatus.COMPLETE,
|
||||
checkpoint,
|
||||
"MISSION_ALREADY_COMPLETE",
|
||||
next_wake=None,
|
||||
)
|
||||
|
||||
if checkpoint.phase is MissionPhase.OWNER_ACTION_REQUIRED:
|
||||
if not observation.owner_action_resolved:
|
||||
return self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
checkpoint,
|
||||
"OWNER_ACTION_STILL_REQUIRED",
|
||||
next_wake=None,
|
||||
)
|
||||
resume_phase = (
|
||||
checkpoint.resume_phase_after_owner
|
||||
or MissionPhase.S2_ADMISSION
|
||||
)
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
phase=resume_phase,
|
||||
resume_phase_after_owner=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
== observation.condition_fingerprint
|
||||
):
|
||||
return self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
checkpoint,
|
||||
"UNCHANGED_DETERMINISTIC_BLOCKER",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
)
|
||||
|
||||
if (
|
||||
checkpoint.blocked_condition_fingerprint is not None
|
||||
and checkpoint.blocked_condition_fingerprint
|
||||
!= observation.condition_fingerprint
|
||||
):
|
||||
checkpoint = replace(
|
||||
checkpoint,
|
||||
blocked_condition_fingerprint=None,
|
||||
attempt_count=0,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
lease = self.port.acquire_resume_lease(
|
||||
mission_id=observation.mission_id,
|
||||
generation=observation.generation,
|
||||
idempotency_key=self._resume_idempotency_key(observation),
|
||||
lease_seconds=self.policy.resume_lease_seconds,
|
||||
)
|
||||
if lease is None:
|
||||
return self._outcome(
|
||||
OutcomeStatus.ACTIVE_ELSEWHERE,
|
||||
checkpoint,
|
||||
"RESUME_LEASE_HELD_ELSEWHERE",
|
||||
next_wake=self.policy.default_poll_seconds,
|
||||
)
|
||||
|
||||
try:
|
||||
self._validate_resume_lease(
|
||||
lease,
|
||||
observation=observation,
|
||||
now=now,
|
||||
)
|
||||
except Exception:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code="INVALID_RESUME_LEASE",
|
||||
)
|
||||
raise
|
||||
|
||||
outcome_code = "UNSET"
|
||||
try:
|
||||
reconciling = (
|
||||
forced_reconcile
|
||||
or checkpoint.reconcile_required
|
||||
or observation.requires_reconcile
|
||||
or observation.effect_state is EffectState.UNKNOWN
|
||||
)
|
||||
if reconciling:
|
||||
result = self.port.reconcile(
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
else:
|
||||
result = self.port.step(
|
||||
phase=checkpoint.phase,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
outcome, new_checkpoint = self._apply_result(
|
||||
result=result,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
now=now,
|
||||
lease=lease,
|
||||
reconciling=reconciling,
|
||||
)
|
||||
outcome_code = outcome.code
|
||||
self.port.persist_checkpoint(new_checkpoint, lease=lease)
|
||||
return replace(
|
||||
outcome,
|
||||
checkpoint_digest=new_checkpoint.digest,
|
||||
)
|
||||
finally:
|
||||
self.port.release_resume_lease(
|
||||
lease,
|
||||
outcome_code=outcome_code,
|
||||
)
|
||||
|
||||
def _validate_and_refresh_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
observation: MissionObservation,
|
||||
*,
|
||||
now: float,
|
||||
) -> tuple[MissionCheckpoint, bool]:
|
||||
if checkpoint.mission_id != observation.mission_id:
|
||||
raise ValueError("checkpoint_mission_mismatch")
|
||||
if checkpoint.generation > observation.generation:
|
||||
raise ValueError("checkpoint_generation_ahead_of_authority")
|
||||
|
||||
refreshed = checkpoint
|
||||
forced_reconcile = False
|
||||
if checkpoint.generation < observation.generation:
|
||||
refreshed = replace(
|
||||
checkpoint,
|
||||
generation=observation.generation,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=None,
|
||||
resume_phase_after_owner=None,
|
||||
receipt_refs=self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
refreshed.phase in {
|
||||
MissionPhase.RELEASE_READY,
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and refreshed.frozen_material_digest is not None
|
||||
and refreshed.frozen_material_digest
|
||||
!= observation.material_digest
|
||||
):
|
||||
# Material changed after READY. Promotion/final may already have
|
||||
# observable side effects, so reconcile before rebuilding PREPARE.
|
||||
previous_phase = refreshed.phase
|
||||
force_for_effect = (
|
||||
previous_phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and observation.effect_state in {
|
||||
EffectState.ACTIVE,
|
||||
EffectState.UNKNOWN,
|
||||
}
|
||||
)
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=observation.material_digest,
|
||||
head_oid=observation.head_oid,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=force_for_effect,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
forced_reconcile = forced_reconcile or force_for_effect
|
||||
elif (
|
||||
refreshed.phase in {
|
||||
MissionPhase.S2_ADMISSION,
|
||||
MissionPhase.S2_EXECUTION,
|
||||
MissionPhase.GATE0,
|
||||
}
|
||||
and refreshed.head_oid != observation.head_oid
|
||||
):
|
||||
# Source moved before READY. Re-enter S2 admission/replan.
|
||||
refreshed = replace(
|
||||
refreshed,
|
||||
phase=MissionPhase.S2_ADMISSION,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=observation.material_digest,
|
||||
frozen_material_digest=None,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
last_condition_fingerprint=observation.condition_fingerprint,
|
||||
receipt_refs=self._merge_refs(
|
||||
refreshed.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
),
|
||||
updated_at=now,
|
||||
revision=refreshed.revision + 1,
|
||||
)
|
||||
|
||||
return refreshed, forced_reconcile
|
||||
|
||||
def _apply_result(
|
||||
self,
|
||||
*,
|
||||
result: StepResult,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
now: float,
|
||||
lease: ResumeLease,
|
||||
reconciling: bool,
|
||||
) -> tuple[ControllerOutcome, MissionCheckpoint]:
|
||||
refs = self._merge_refs(
|
||||
checkpoint.receipt_refs,
|
||||
observation.evidence_refs,
|
||||
result.receipt_refs,
|
||||
)
|
||||
base = replace(
|
||||
checkpoint,
|
||||
head_oid=observation.head_oid,
|
||||
material_digest=(
|
||||
result.material_digest
|
||||
or observation.material_digest
|
||||
),
|
||||
last_condition_fingerprint=result.condition_fingerprint,
|
||||
receipt_refs=refs,
|
||||
updated_at=now,
|
||||
revision=checkpoint.revision + 1,
|
||||
)
|
||||
|
||||
if (
|
||||
result.status is StepStatus.SUCCEEDED
|
||||
and checkpoint.phase in {
|
||||
MissionPhase.PROMOTION,
|
||||
MissionPhase.FINAL_ACCEPTANCE,
|
||||
}
|
||||
and checkpoint.frozen_material_digest is not None
|
||||
and result.material_digest is not None
|
||||
and result.material_digest != checkpoint.frozen_material_digest
|
||||
):
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.RELEASE_READY,
|
||||
material_digest=result.material_digest,
|
||||
frozen_material_digest=None,
|
||||
reconcile_required=True,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:MATERIAL_CHANGED_DURING_PROMOTION",
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.SUCCEEDED:
|
||||
next_phase = (
|
||||
result.next_phase
|
||||
or _PHASE_NEXT.get(checkpoint.phase, checkpoint.phase)
|
||||
)
|
||||
frozen = base.frozen_material_digest
|
||||
if checkpoint.phase is MissionPhase.RELEASE_READY:
|
||||
frozen = result.material_digest or observation.material_digest
|
||||
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=next_phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
frozen_material_digest=frozen,
|
||||
)
|
||||
status = (
|
||||
OutcomeStatus.COMPLETE
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else OutcomeStatus.PROGRESSED
|
||||
)
|
||||
code = (
|
||||
"MISSION_COMPLETE"
|
||||
if next_phase is MissionPhase.COMPLETE
|
||||
else result.code
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
status,
|
||||
new_checkpoint,
|
||||
code,
|
||||
next_wake=(
|
||||
None
|
||||
if status is OutcomeStatus.COMPLETE
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.IN_PROGRESS:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=(
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else self.policy.default_poll_seconds
|
||||
),
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RETRYABLE:
|
||||
attempts = checkpoint.attempt_count + 1
|
||||
if attempts > self.policy.transient_max_attempts:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
"RETRY_BUDGET_EXHAUSTED",
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
delay = (
|
||||
result.retry_after_seconds
|
||||
if result.retry_after_seconds is not None
|
||||
else min(
|
||||
self.policy.transient_base_delay_seconds
|
||||
* (2 ** (attempts - 1)),
|
||||
self.policy.transient_max_delay_seconds,
|
||||
)
|
||||
)
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=attempts,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.RETRY_SCHEDULED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=delay,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_DETERMINISTIC:
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=checkpoint.attempt_count,
|
||||
blocked_condition_fingerprint=result.condition_fingerprint,
|
||||
reconcile_required=reconciling,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.WAIT_CONDITION_CHANGE,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=self.policy.deterministic_block_poll_seconds,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.BLOCKED_OWNER:
|
||||
payload = dict(result.owner_action_payload or {})
|
||||
payload.setdefault("mission_id", observation.mission_id)
|
||||
payload.setdefault("generation", observation.generation)
|
||||
payload.setdefault("phase", checkpoint.phase.value)
|
||||
payload.setdefault("code", result.code)
|
||||
record_ref = self.port.persist_owner_action_required(
|
||||
payload,
|
||||
observation=observation,
|
||||
checkpoint=checkpoint,
|
||||
lease=lease,
|
||||
)
|
||||
if not record_ref:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=checkpoint.phase,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=False,
|
||||
receipt_refs=self._merge_refs(refs, (record_ref,)),
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.OWNER_ACTION_REQUIRED,
|
||||
new_checkpoint,
|
||||
result.code,
|
||||
next_wake=None,
|
||||
refs=(record_ref,),
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
if result.status is StepStatus.RECONCILE:
|
||||
# The next scheduler cycle must reconcile before any retry.
|
||||
new_checkpoint = replace(
|
||||
base,
|
||||
attempt_count=0,
|
||||
blocked_condition_fingerprint=None,
|
||||
reconcile_required=True,
|
||||
)
|
||||
return (
|
||||
self._outcome(
|
||||
OutcomeStatus.IN_PROGRESS,
|
||||
new_checkpoint,
|
||||
"RECONCILE_REQUIRED:" + result.code,
|
||||
next_wake=0,
|
||||
refs=result.receipt_refs,
|
||||
),
|
||||
new_checkpoint,
|
||||
)
|
||||
|
||||
raise AssertionError(f"unsupported_step_status:{result.status}")
|
||||
|
||||
@staticmethod
|
||||
def _validate_resume_lease(
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
now: float,
|
||||
) -> None:
|
||||
if lease.mission_id != observation.mission_id:
|
||||
raise ValueError("resume_lease_mission_mismatch")
|
||||
if lease.generation != observation.generation:
|
||||
raise ValueError("resume_lease_generation_mismatch")
|
||||
if lease.expires_at <= now:
|
||||
raise ValueError("resume_lease_expired")
|
||||
|
||||
def _resume_idempotency_key(
|
||||
self,
|
||||
observation: MissionObservation,
|
||||
) -> str:
|
||||
payload = {
|
||||
"schema": "aios.resume-effect.v1",
|
||||
"mission_id": observation.mission_id,
|
||||
"generation": observation.generation,
|
||||
}
|
||||
return "resume:sha256:" + _sha256(_canonical(payload))
|
||||
|
||||
@staticmethod
|
||||
def _merge_refs(*groups: Sequence[str]) -> tuple[str, ...]:
|
||||
merged: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for group in groups:
|
||||
for item in group:
|
||||
value = str(item)
|
||||
if value and value not in seen:
|
||||
seen.add(value)
|
||||
merged.append(value)
|
||||
return tuple(merged)
|
||||
|
||||
@staticmethod
|
||||
def _outcome(
|
||||
status: OutcomeStatus,
|
||||
checkpoint: MissionCheckpoint,
|
||||
code: str,
|
||||
*,
|
||||
next_wake: int | None,
|
||||
refs: Sequence[str] = (),
|
||||
) -> ControllerOutcome:
|
||||
return ControllerOutcome(
|
||||
status=status,
|
||||
mission_id=checkpoint.mission_id,
|
||||
phase=checkpoint.phase,
|
||||
code=code,
|
||||
next_wake_after_seconds=next_wake,
|
||||
checkpoint_digest=None,
|
||||
receipt_refs=tuple(refs),
|
||||
)
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Thin callable adapter for :mod:`autonomous_resume_controller`.
|
||||
|
||||
This module contains no transport, daemon, database or scheduler loop. It only
|
||||
binds current AIOS runtime functions to the stable ``AutonomousExecutionPort``
|
||||
contract and exposes one scheduler tick.
|
||||
|
||||
A field integrator should:
|
||||
- resolve the live current APIs;
|
||||
- supply callables that use existing governed authorities;
|
||||
- register ``AutonomousSchedulerTick.run`` with the existing scheduler/timer.
|
||||
|
||||
No additional control plane is created here.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
|
||||
from .autonomous_resume_controller import (
|
||||
AutonomousExecutionPort,
|
||||
AutonomousResumeController,
|
||||
ControllerOutcome,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
)
|
||||
|
||||
|
||||
PhaseHandler = Callable[
|
||||
[MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
StepResult,
|
||||
]
|
||||
ReconcileHandler = PhaseHandler
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhaseCallbacks:
|
||||
admission: PhaseHandler
|
||||
source_execution: PhaseHandler
|
||||
gate0: PhaseHandler
|
||||
release_ready: PhaseHandler
|
||||
promotion: PhaseHandler
|
||||
final_acceptance: PhaseHandler
|
||||
|
||||
def for_phase(self, phase: MissionPhase) -> PhaseHandler:
|
||||
mapping = {
|
||||
MissionPhase.S2_ADMISSION: self.admission,
|
||||
MissionPhase.S2_EXECUTION: self.source_execution,
|
||||
MissionPhase.GATE0: self.gate0,
|
||||
MissionPhase.RELEASE_READY: self.release_ready,
|
||||
MissionPhase.PROMOTION: self.promotion,
|
||||
MissionPhase.FINAL_ACCEPTANCE: self.final_acceptance,
|
||||
}
|
||||
try:
|
||||
return mapping[phase]
|
||||
except KeyError as exc:
|
||||
raise ValueError(f"phase_has_no_executor:{phase.value}") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuthorityCallbacks:
|
||||
read_observation: Callable[[str], MissionObservation]
|
||||
load_checkpoint: Callable[[str], MissionCheckpoint | None]
|
||||
acquire_resume_lease: Callable[[str, int, str, int], ResumeLease | None]
|
||||
persist_checkpoint: Callable[[MissionCheckpoint, ResumeLease], None]
|
||||
persist_owner_action_required: Callable[
|
||||
[dict, MissionObservation, MissionCheckpoint, ResumeLease],
|
||||
str,
|
||||
]
|
||||
release_resume_lease: Callable[[ResumeLease, str], None]
|
||||
reconcile: ReconcileHandler
|
||||
|
||||
|
||||
class CallableAutonomousExecutionPort(AutonomousExecutionPort):
|
||||
"""Runtime adapter composed from already-governed AIOS callables."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority: AuthorityCallbacks,
|
||||
phases: PhaseCallbacks,
|
||||
) -> None:
|
||||
self.authority = authority
|
||||
self.phases = phases
|
||||
|
||||
def read_observation(self, mission_id: str) -> MissionObservation:
|
||||
value = self.authority.read_observation(mission_id)
|
||||
if not isinstance(value, MissionObservation):
|
||||
raise TypeError("read_observation_must_return_MissionObservation")
|
||||
return value
|
||||
|
||||
def load_checkpoint(self, mission_id: str) -> MissionCheckpoint | None:
|
||||
value = self.authority.load_checkpoint(mission_id)
|
||||
if value is not None and not isinstance(value, MissionCheckpoint):
|
||||
raise TypeError("load_checkpoint_must_return_checkpoint_or_none")
|
||||
return value
|
||||
|
||||
def acquire_resume_lease(
|
||||
self,
|
||||
*,
|
||||
mission_id: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
lease_seconds: int,
|
||||
) -> ResumeLease | None:
|
||||
value = self.authority.acquire_resume_lease(
|
||||
mission_id,
|
||||
generation,
|
||||
idempotency_key,
|
||||
lease_seconds,
|
||||
)
|
||||
if value is not None and not isinstance(value, ResumeLease):
|
||||
raise TypeError("acquire_resume_lease_must_return_lease_or_none")
|
||||
return value
|
||||
|
||||
def step(
|
||||
self,
|
||||
*,
|
||||
phase: MissionPhase,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
handler = self.phases.for_phase(phase)
|
||||
value = handler(observation, checkpoint, lease)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("phase_handler_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> StepResult:
|
||||
value = self.authority.reconcile(
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, StepResult):
|
||||
raise TypeError("reconcile_must_return_StepResult")
|
||||
return value
|
||||
|
||||
def persist_checkpoint(
|
||||
self,
|
||||
checkpoint: MissionCheckpoint,
|
||||
*,
|
||||
lease: ResumeLease,
|
||||
) -> None:
|
||||
self.authority.persist_checkpoint(checkpoint, lease)
|
||||
|
||||
def persist_owner_action_required(
|
||||
self,
|
||||
payload,
|
||||
*,
|
||||
observation: MissionObservation,
|
||||
checkpoint: MissionCheckpoint,
|
||||
lease: ResumeLease,
|
||||
) -> str:
|
||||
value = self.authority.persist_owner_action_required(
|
||||
dict(payload),
|
||||
observation,
|
||||
checkpoint,
|
||||
lease,
|
||||
)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ValueError("owner_action_record_ref_required")
|
||||
return value
|
||||
|
||||
def release_resume_lease(
|
||||
self,
|
||||
lease: ResumeLease,
|
||||
*,
|
||||
outcome_code: str,
|
||||
) -> None:
|
||||
self.authority.release_resume_lease(lease, outcome_code)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SchedulerTickResult:
|
||||
outcome: ControllerOutcome
|
||||
next_wake_after_seconds: int | None
|
||||
|
||||
|
||||
class AutonomousSchedulerTick:
|
||||
"""One invocation for an existing scheduler/timer.
|
||||
|
||||
This object never sleeps or loops. The host scheduler owns cadence.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller: AutonomousResumeController,
|
||||
mission_id: str,
|
||||
) -> None:
|
||||
if not mission_id:
|
||||
raise ValueError("mission_id_required")
|
||||
self.controller = controller
|
||||
self.mission_id = mission_id
|
||||
|
||||
def run(self) -> SchedulerTickResult:
|
||||
outcome = self.controller.run_once(self.mission_id)
|
||||
return SchedulerTickResult(
|
||||
outcome=outcome,
|
||||
next_wake_after_seconds=outcome.next_wake_after_seconds,
|
||||
)
|
||||
@@ -0,0 +1,401 @@
|
||||
"""现场(field)组合根:让"Owner 基于授权"真正可接线、可执行。
|
||||
|
||||
本模块是 V2 governed-writer 机制的**现场装配层**,职责:
|
||||
- 实现正式的 `OwnerAuthorizationAuthority`:持有 Owner 登记的授权记录
|
||||
(authorization_ref -> OwnerAuthorizationPolicy),`verify()` 解析引用、
|
||||
核验策略、签发 verification receipt;未知引用 / 策略失效一律 fail-closed。
|
||||
- 提供 `FieldGovernedWriterFactory`(组合根):把 7 个协议适配器(signer /
|
||||
execution / principal_issuer / jobs / git / writer_lease / owner_authority)
|
||||
装配到 `GovernedWriterAutomation.acquire_verified()` 生产入口上。
|
||||
- 提供默认的生产适配器参考实现(`_DefaultOwnerAuthority`),供现场在真实
|
||||
端点未就绪时先闭环;真实端点就绪后替换对应 adapter 即可,组合根接口不变。
|
||||
|
||||
设计原则(与 OWNER_AUTHORIZATION_MODEL.md 的 `aios.owner-authorization.v1` 一致):
|
||||
1. 生产入口唯一授权来源是 Owner 登记的 `authorization_ref`;Agent 本地自造的
|
||||
`OwnerAuthorizationPolicy` 不构成 proof(fail-closed)。
|
||||
2. 长期业务授权(Policy)与短时执行凭证(epoch/lease/principal)分离。
|
||||
3. `legacy_global_writes_enabled=false` 是安全不变式,不是 governed-writer blocker。
|
||||
|
||||
本模块不修改 S6 frozen 9 文件,不触碰 legacy/global 写;只做 governed 写路径的
|
||||
现场装配。适配器接口全部复用 `governed_writer_automation.py` 的 Protocol。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriteReadiness,
|
||||
JobBindingReceipt,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
VerifiedOwnerAuthorization,
|
||||
WriterLeaseReceipt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 正式 OwnerAuthorizationAuthority 现场实现
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldOwnerAuthorizationAuthority:
|
||||
"""现场 authority:登记 Owner 授权记录并按引用验证,签发核验回执。
|
||||
|
||||
生产语义:
|
||||
- `register(ref, policy)` 由 Owner/管理员在受信侧登记;登记即"一次授权"。
|
||||
- `verify(authorization_ref, request)` 解析引用 -> 核验 -> 返回
|
||||
`VerifiedOwnerAuthorization`(含非空 receipt)。
|
||||
- 任何未知引用、未登记、策略与请求越界,都抛 `AuthorizationDenied`
|
||||
(fail-closed:不猜测、不降级)。
|
||||
"""
|
||||
|
||||
_registry: dict[str, OwnerAuthorizationPolicy] = field(default_factory=dict)
|
||||
_receipt_counter: int = 0
|
||||
|
||||
def register(self, authorization_ref: str, policy: OwnerAuthorizationPolicy) -> None:
|
||||
if authorization_ref != policy.authorization_id:
|
||||
raise AuthorizationDenied(
|
||||
"authorization_ref must equal policy.authorization_id"
|
||||
)
|
||||
self._registry[authorization_ref] = policy
|
||||
|
||||
def contains(self, authorization_ref: str) -> bool:
|
||||
return authorization_ref in self._registry
|
||||
|
||||
def policy(self, authorization_ref: str) -> OwnerAuthorizationPolicy:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(f"unknown authorization reference: {authorization_ref}")
|
||||
return self._registry[authorization_ref]
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization:
|
||||
if authorization_ref not in self._registry:
|
||||
raise AuthorizationDenied(
|
||||
f"owner authorization reference not registered: {authorization_ref}"
|
||||
)
|
||||
policy = self._registry[authorization_ref]
|
||||
policy.validate(request, now=time.time())
|
||||
self._receipt_counter += 1
|
||||
receipt = f"receipt:owner-{authorization_ref}:{self._receipt_counter}"
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt=receipt,
|
||||
verified_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 默认生产适配器参考实现(真实端点未就绪时先闭环,接口与协议一一对应)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _DefaultTrustedSigner:
|
||||
"""TrustedSignerAdapter:JIT 重签同 scope 的 epoch(幂等由 ledger 守护)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus:
|
||||
self._counter += 1
|
||||
self.calls.append(
|
||||
{
|
||||
"key": idempotency_key,
|
||||
"scope": request.scope_fingerprint,
|
||||
"policy": owner_policy.policy_fingerprint,
|
||||
"prior": None if prior_epoch is None else prior_epoch.epoch_id,
|
||||
}
|
||||
)
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-gw-{self._counter}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() + 7200.0,
|
||||
policy_digest=owner_policy.policy_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultExecutionPlane:
|
||||
"""ExecutionPlane:QUEUED 自动 claim + lease;heartbeat 记录。"""
|
||||
|
||||
def __init__(self, initial: TaskState) -> None:
|
||||
self.task = initial
|
||||
self._lease_counter = 0
|
||||
self.claims: list = []
|
||||
self.heartbeats: list = []
|
||||
|
||||
def read_task(self, task_id: str) -> TaskState:
|
||||
if task_id != self.task.task_id:
|
||||
raise AuthorizationDenied(f"unknown task: {task_id}")
|
||||
return self.task
|
||||
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState:
|
||||
raise AuthorizationDenied("terminal recovery requires a real recovery endpoint")
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
):
|
||||
from app.task_plane.governed_writer_automation import ClaimReceipt
|
||||
|
||||
self.claims.append({"task": task.task_id, "actor": actor, "key": idempotency_key})
|
||||
return ClaimReceipt(
|
||||
claim_id=f"claim-gw-{len(self.claims)}",
|
||||
task_id=task.task_id,
|
||||
actor=actor,
|
||||
fencing=len(self.claims),
|
||||
)
|
||||
|
||||
def heartbeat(self, *, claim) -> None:
|
||||
self.heartbeats.append(claim.claim_id)
|
||||
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt:
|
||||
self._lease_counter += 1
|
||||
return TaskLeaseReceipt(
|
||||
task_lease_id=f"tasklease-gw-{self._lease_counter}",
|
||||
task_id=claim.task_id,
|
||||
token_ref=f"token-gw-{self._lease_counter}",
|
||||
expires_at=time.time() + 3600.0,
|
||||
fencing=claim.fencing,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultPrincipalIssuer:
|
||||
"""TaskPrincipalIssuer:按 lease 派发 scoped TaskPrincipal。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt:
|
||||
self._counter += 1
|
||||
return TaskPrincipalReceipt(
|
||||
principal_id=f"principal-gw-{self._counter}",
|
||||
task_id=lease.task_id,
|
||||
capabilities=requested_capabilities,
|
||||
expires_at=time.time() + 1800.0,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultJobs:
|
||||
"""JobAuthority:reconcile 到与请求一致的 job 绑定。"""
|
||||
|
||||
def __init__(self, *, repository: str, target_branch: str, generation: int) -> None:
|
||||
self._repo = repository
|
||||
self._branch = target_branch
|
||||
self._gen = generation
|
||||
self._counter = 0
|
||||
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt:
|
||||
self._counter += 1
|
||||
return JobBindingReceipt(
|
||||
job_id=f"job-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
repository=self._repo,
|
||||
target_branch=self._branch,
|
||||
generation=self._gen,
|
||||
)
|
||||
|
||||
|
||||
class _DefaultGit:
|
||||
"""GitAuthority:只读 branch 读回,决定 CAS 模式。"""
|
||||
|
||||
def __init__(self, *, exists: bool, head_oid: str | None = None) -> None:
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self._exists = exists
|
||||
self._head = head_oid
|
||||
self.calls: list = []
|
||||
|
||||
def read_branch(self, *, repository: str, target_branch: str):
|
||||
from app.task_plane.governed_writer_automation import BranchReadback
|
||||
|
||||
self.calls.append({"repo": repository, "branch": target_branch})
|
||||
return BranchReadback(exists=self._exists, head_oid=self._head)
|
||||
|
||||
|
||||
class _DefaultWriterLease:
|
||||
"""WriterLeaseAuthority:签发短时 writer lease(CAS 与 base 一致)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._counter = 0
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt:
|
||||
self._counter += 1
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id=f"writerlease-gw-{self._counter}",
|
||||
task_id=task_id,
|
||||
job_id=job.job_id,
|
||||
repository=repository,
|
||||
target_branch=target_branch,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=generation,
|
||||
expires_at=time.time() + 900.0,
|
||||
fencing=self._counter,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 组合根:装配 7 适配器 -> 生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class FieldRuntimeStatus:
|
||||
"""现场运行时状态:由 W-RUNTIME 只读线提供,用于派生 governed 写就绪。"""
|
||||
|
||||
legacy_global_writes_enabled: bool = False
|
||||
task_principal_writes_enabled: bool = True
|
||||
task_principal_verifier_ready: bool = True
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class FieldGovernedWriterFactory:
|
||||
"""组合根:把现场配置装配成 `acquire_verified()` 生产入口可直接执行的对象。
|
||||
|
||||
用法(现场接线):
|
||||
factory = FieldGovernedWriterFactory.build(
|
||||
authority=owner_authority,
|
||||
manifest_store=...,
|
||||
action_ledger=...,
|
||||
task_initial=TaskState(...),
|
||||
branch_exists=...,
|
||||
head_oid=...,
|
||||
repo=...,
|
||||
branch=...,
|
||||
generation=...,
|
||||
runtime_status=...,
|
||||
)
|
||||
session = factory.acquire(request, authorization_ref)
|
||||
"""
|
||||
|
||||
automation: GovernedWriterAutomation
|
||||
adapters: GovernedWriterAdapters
|
||||
readiness: GovernedWriteReadiness
|
||||
owner_authority: FieldOwnerAuthorizationAuthority
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
authority: FieldOwnerAuthorizationAuthority,
|
||||
manifest_store,
|
||||
action_ledger,
|
||||
task_initial: TaskState,
|
||||
branch_exists: bool,
|
||||
head_oid: str | None,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
runtime_status: FieldRuntimeStatus | None = None,
|
||||
decision_engine=None,
|
||||
) -> "FieldGovernedWriterFactory":
|
||||
runtime_status = runtime_status or FieldRuntimeStatus()
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_DefaultTrustedSigner(),
|
||||
execution=_DefaultExecutionPlane(task_initial),
|
||||
principal_issuer=_DefaultPrincipalIssuer(),
|
||||
jobs=_DefaultJobs(
|
||||
repository=repository, target_branch=target_branch, generation=generation
|
||||
),
|
||||
git=_DefaultGit(exists=branch_exists, head_oid=head_oid),
|
||||
writer_lease=_DefaultWriterLease(),
|
||||
owner_authority=authority,
|
||||
)
|
||||
automation = GovernedWriterAutomation(
|
||||
manifest_store=manifest_store,
|
||||
action_ledger=action_ledger,
|
||||
decision_engine=decision_engine,
|
||||
)
|
||||
readiness = GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=runtime_status.legacy_global_writes_enabled,
|
||||
task_principal_writes_enabled=runtime_status.task_principal_writes_enabled,
|
||||
task_principal_verifier_ready=runtime_status.task_principal_verifier_ready,
|
||||
governed_write_blockers=tuple(runtime_status.governed_write_blockers),
|
||||
)
|
||||
return cls(
|
||||
automation=automation,
|
||||
adapters=adapters,
|
||||
readiness=readiness,
|
||||
owner_authority=authority,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
*,
|
||||
epoch: EpochStatus | None = None,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""生产入口:先验 Owner 授权,再自动完成受治理写链。"""
|
||||
return self.automation.acquire_verified(
|
||||
request=request,
|
||||
authorization_ref=authorization_ref,
|
||||
readiness=self.readiness,
|
||||
epoch=epoch,
|
||||
adapters=self.adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
@@ -0,0 +1,147 @@
|
||||
|
||||
"""Fail-closed candidate-specific governed admission preflight.
|
||||
|
||||
Ordinary/global writes are deliberately NOT a prerequisite. This evaluates the
|
||||
governed Bridge/TaskPrincipal path only.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from typing import Sequence
|
||||
|
||||
from .governance_resolver import ResolutionResult, ResolutionState
|
||||
|
||||
|
||||
class AdmissionOperation(str, Enum):
|
||||
GOVERNED_COMMIT = "GOVERNED_COMMIT"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceFacts:
|
||||
governed_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
recovery_execution_enabled: bool
|
||||
recovery_shadow_mode: bool
|
||||
authenticated_principal_id: str | None
|
||||
target_branch: str | None
|
||||
branch_protection_known: bool
|
||||
expected_branch_oid: str | None
|
||||
generation: int | None
|
||||
task_lease_id: str | None
|
||||
task_principal_id: str | None
|
||||
writer_lease_id: str | None
|
||||
accepted_file_allowlist: tuple[str, ...] = ()
|
||||
acceptance_revision: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionBlocker:
|
||||
code: str
|
||||
detail: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AdmissionResult:
|
||||
ready: bool
|
||||
operation: AdmissionOperation
|
||||
blockers: tuple[AdmissionBlocker, ...]
|
||||
|
||||
|
||||
class AdmissionPreflight:
|
||||
def evaluate(
|
||||
self,
|
||||
*,
|
||||
resolution: ResolutionResult,
|
||||
facts: GovernanceFacts,
|
||||
operation: AdmissionOperation,
|
||||
) -> AdmissionResult:
|
||||
blockers: list[AdmissionBlocker] = []
|
||||
|
||||
if resolution.state is ResolutionState.NOT_FOUND:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_NOT_RESOLVED", "exact candidate task not found"
|
||||
))
|
||||
elif resolution.state is ResolutionState.AMBIGUOUS:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_AMBIGUOUS", "multiple exact candidate tasks"
|
||||
))
|
||||
elif resolution.state is not ResolutionState.RESOLVED:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_RESOLUTION_INVALID", resolution.detail or "resolver invalid"
|
||||
))
|
||||
|
||||
if not facts.governed_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GOVERNED_WRITES_DISABLED",
|
||||
"governed Bridge write path is disabled",
|
||||
))
|
||||
if not facts.task_principal_writes_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED",
|
||||
"TaskPrincipal governed writes are disabled",
|
||||
))
|
||||
if not facts.authenticated_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"authenticated governed caller principal is missing",
|
||||
))
|
||||
if not facts.target_branch:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TARGET_BRANCH_UNRESOLVED", "target branch is missing"
|
||||
))
|
||||
if not facts.branch_protection_known:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"BRANCH_PROTECTION_UNKNOWN",
|
||||
"branch protection has not been read through authority",
|
||||
))
|
||||
if not facts.expected_branch_oid:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED",
|
||||
"expected_branch_oid/CAS preimage is missing",
|
||||
))
|
||||
if facts.generation is None:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"GENERATION_UNRESOLVED", "governed generation is missing"
|
||||
))
|
||||
if not facts.task_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_LEASE_MISSING", "TaskLease not established"
|
||||
))
|
||||
if not facts.task_principal_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"TASK_PRINCIPAL_MISSING", "TaskPrincipal not established"
|
||||
))
|
||||
if not facts.writer_lease_id:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"WRITER_LEASE_MISSING", "WriterLease not established"
|
||||
))
|
||||
if not facts.accepted_file_allowlist:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"FILE_ALLOWLIST_MISSING", "accepted file allowlist is empty"
|
||||
))
|
||||
if not facts.acceptance_revision:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
"candidate acceptance revision is missing",
|
||||
))
|
||||
|
||||
if operation is AdmissionOperation.RECOVER_EXISTING_CHILD:
|
||||
if not facts.recovery_execution_enabled:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_EXECUTION_DISABLED",
|
||||
"recovery execution path is disabled",
|
||||
))
|
||||
if facts.recovery_shadow_mode:
|
||||
blockers.append(AdmissionBlocker(
|
||||
"RECOVERY_SHADOW_MODE",
|
||||
"recovery is still shadow-only",
|
||||
))
|
||||
|
||||
# Ordinary/global write state is intentionally absent from the model.
|
||||
return AdmissionResult(
|
||||
ready=not blockers,
|
||||
operation=operation,
|
||||
blockers=tuple(blockers),
|
||||
)
|
||||
@@ -0,0 +1,132 @@
|
||||
|
||||
"""Canonical recovery observation checkpoint and unchanged-blocker budget.
|
||||
|
||||
The checkpoint is NOT an authority. It records the last reconciled observation
|
||||
so agents do not repeatedly prove the same unchanged blocker.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol, Sequence
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryCheckpoint:
|
||||
schema: str
|
||||
goal_id: str
|
||||
repository: str
|
||||
source_oid: str
|
||||
candidate_digest: str
|
||||
task_id: str | None
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
principal_id: str | None
|
||||
blocker_code: str | None
|
||||
blocker_fingerprint: str | None
|
||||
next_legal_action: str
|
||||
last_verified_at: str
|
||||
evidence_refs: tuple[str, ...]
|
||||
unchanged_cycles: int
|
||||
mechanism_repair_required: bool
|
||||
recovery_idempotency_key: str | None = None
|
||||
recovery_receipt_ref: str | None = None
|
||||
recovery_context_digest: str | None = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
value = asdict(self)
|
||||
value["evidence_refs"] = list(self.evidence_refs)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return _digest(self.to_dict())
|
||||
|
||||
|
||||
class CheckpointStore(Protocol):
|
||||
def load(self) -> RecoveryCheckpoint | None: ...
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None: ...
|
||||
|
||||
|
||||
class JsonCheckpointStore:
|
||||
"""Atomic local observation cache; never used as authorization evidence."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RecoveryCheckpoint | None:
|
||||
if not self.path.exists():
|
||||
return None
|
||||
raw = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
raw["evidence_refs"] = tuple(raw.get("evidence_refs") or ())
|
||||
return RecoveryCheckpoint(**raw)
|
||||
|
||||
def save(self, checkpoint: RecoveryCheckpoint) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = self.path.with_suffix(self.path.suffix + ".tmp")
|
||||
temp.write_text(
|
||||
json.dumps(
|
||||
checkpoint.to_dict(),
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.replace(temp, self.path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RetryBudget:
|
||||
unchanged_threshold: int = 3
|
||||
escalation_eligible_codes: frozenset[str] = frozenset({
|
||||
"TASK_NOT_RESOLVED",
|
||||
"TASK_RESOLUTION_AMBIGUOUS",
|
||||
"CALLER_PRINCIPAL_UNRESOLVED",
|
||||
"CANONICAL_CHANNEL_MISSING",
|
||||
"RESOLVER_UNAVAILABLE",
|
||||
})
|
||||
|
||||
def __post_init__(self):
|
||||
if self.unchanged_threshold < 2 or self.unchanged_threshold > 100:
|
||||
raise ValueError("unchanged_threshold must be in [2,100]")
|
||||
|
||||
@staticmethod
|
||||
def blocker_fingerprint(code: str, details: Sequence[str]) -> str:
|
||||
return _digest({
|
||||
"code": code,
|
||||
"details": sorted(str(item) for item in details),
|
||||
})
|
||||
|
||||
def observe(
|
||||
self,
|
||||
*,
|
||||
previous: RecoveryCheckpoint | None,
|
||||
blocker_code: str,
|
||||
blocker_fingerprint: str,
|
||||
) -> tuple[int, bool]:
|
||||
same = (
|
||||
previous is not None
|
||||
and previous.blocker_code == blocker_code
|
||||
and previous.blocker_fingerprint == blocker_fingerprint
|
||||
)
|
||||
cycles = (previous.unchanged_cycles + 1) if same else 1
|
||||
repair = (
|
||||
blocker_code in self.escalation_eligible_codes
|
||||
and cycles >= self.unchanged_threshold
|
||||
)
|
||||
return cycles, repair
|
||||
@@ -0,0 +1,871 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from contextlib import closing
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
SCHEMA_RUNTIME_MANIFEST = "aios.runtime-manifest.v1"
|
||||
SCHEMA_DECISION = "aios.governance-decision.v1"
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
"""Private control-kernel encoding; not a replacement for published AIOS digests."""
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_hex(value: bytes) -> str:
|
||||
return hashlib.sha256(value).hexdigest()
|
||||
|
||||
|
||||
class GovernanceControlError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class PreflightNotReady(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class StaleManifest(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class IdempotencyConflict(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class RetryBudgetExhausted(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class ConcurrentWriterDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStateTransition(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveryObservation:
|
||||
worker: str
|
||||
observed_at: float
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
activation_digest: str
|
||||
epoch_id: str | None
|
||||
policy_digest: str | None
|
||||
plan_provider_root: str | None
|
||||
task_id: str | None
|
||||
grant_id: str | None
|
||||
facts: Mapping[str, Any] = field(default_factory=dict)
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def build(
|
||||
cls,
|
||||
*,
|
||||
worker: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
activation_digest: str,
|
||||
epoch_id: str | None,
|
||||
policy_digest: str | None,
|
||||
plan_provider_root: str | None,
|
||||
task_id: str | None,
|
||||
grant_id: str | None,
|
||||
facts: Mapping[str, Any] | None = None,
|
||||
evidence_refs: Sequence[str] = (),
|
||||
observed_at: float | None = None,
|
||||
) -> "DiscoveryObservation":
|
||||
return cls(
|
||||
worker=str(worker),
|
||||
observed_at=float(time.time() if observed_at is None else observed_at),
|
||||
runtime_fingerprint=str(runtime_fingerprint),
|
||||
process_start_identity=str(process_start_identity),
|
||||
activation_digest=str(activation_digest),
|
||||
epoch_id=None if epoch_id is None else str(epoch_id),
|
||||
policy_digest=None if policy_digest is None else str(policy_digest),
|
||||
plan_provider_root=None if plan_provider_root is None else str(plan_provider_root),
|
||||
task_id=None if task_id is None else str(task_id),
|
||||
grant_id=None if grant_id is None else str(grant_id),
|
||||
facts=dict(facts or {}),
|
||||
evidence_refs=tuple(str(x) for x in evidence_refs),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeManifest:
|
||||
observed_at: float
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
activation_digest: str
|
||||
epoch_id: str | None
|
||||
policy_digest: str | None
|
||||
plan_provider_root: str | None
|
||||
task_id: str | None
|
||||
grant_id: str | None
|
||||
worker_facts: Mapping[str, Mapping[str, Any]]
|
||||
evidence_refs: tuple[str, ...]
|
||||
schema: str = SCHEMA_RUNTIME_MANIFEST
|
||||
|
||||
def payload(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"observed_at": self.observed_at,
|
||||
"runtime_fingerprint": self.runtime_fingerprint,
|
||||
"process_start_identity": self.process_start_identity,
|
||||
"activation_digest": self.activation_digest,
|
||||
"epoch_id": self.epoch_id,
|
||||
"policy_digest": self.policy_digest,
|
||||
"plan_provider_root": self.plan_provider_root,
|
||||
"task_id": self.task_id,
|
||||
"grant_id": self.grant_id,
|
||||
"worker_facts": {k: dict(v) for k, v in sorted(self.worker_facts.items())},
|
||||
"evidence_refs": list(self.evidence_refs),
|
||||
}
|
||||
|
||||
@property
|
||||
def manifest_id(self) -> str:
|
||||
return "sha256:" + sha256_hex(canonical_json_bytes(self.payload()))
|
||||
|
||||
|
||||
class ConsistencyBarrier:
|
||||
_IDENTITY_FIELDS = (
|
||||
"runtime_fingerprint",
|
||||
"process_start_identity",
|
||||
"activation_digest",
|
||||
"epoch_id",
|
||||
"policy_digest",
|
||||
"plan_provider_root",
|
||||
"task_id",
|
||||
"grant_id",
|
||||
)
|
||||
|
||||
def __init__(self, expected_workers: Iterable[str]):
|
||||
self.expected_workers = frozenset(str(x) for x in expected_workers)
|
||||
if not self.expected_workers:
|
||||
raise ValueError("expected_workers must not be empty")
|
||||
self._observations: dict[str, DiscoveryObservation] = {}
|
||||
|
||||
def submit(self, observation: DiscoveryObservation) -> None:
|
||||
if observation.worker not in self.expected_workers:
|
||||
raise PreflightNotReady(f"unexpected discovery worker: {observation.worker}")
|
||||
if observation.worker in self._observations:
|
||||
raise PreflightNotReady(f"duplicate discovery worker: {observation.worker}")
|
||||
self._observations[observation.worker] = observation
|
||||
|
||||
def freeze(self) -> RuntimeManifest:
|
||||
missing = sorted(self.expected_workers - self._observations.keys())
|
||||
if missing:
|
||||
raise PreflightNotReady("missing discovery workers: " + ",".join(missing))
|
||||
ordered = [self._observations[k] for k in sorted(self._observations)]
|
||||
anchor = ordered[0]
|
||||
for other in ordered[1:]:
|
||||
for name in self._IDENTITY_FIELDS:
|
||||
if getattr(anchor, name) != getattr(other, name):
|
||||
raise StaleManifest(
|
||||
f"consistency barrier mismatch field={name} "
|
||||
f"anchor={getattr(anchor, name)!r} "
|
||||
f"worker={other.worker} value={getattr(other, name)!r}"
|
||||
)
|
||||
evidence: list[str] = []
|
||||
facts: dict[str, Mapping[str, Any]] = {}
|
||||
observed_at = 0.0
|
||||
for item in ordered:
|
||||
observed_at = max(observed_at, item.observed_at)
|
||||
evidence.extend(item.evidence_refs)
|
||||
facts[item.worker] = dict(item.facts)
|
||||
return RuntimeManifest(
|
||||
observed_at=observed_at,
|
||||
runtime_fingerprint=anchor.runtime_fingerprint,
|
||||
process_start_identity=anchor.process_start_identity,
|
||||
activation_digest=anchor.activation_digest,
|
||||
epoch_id=anchor.epoch_id,
|
||||
policy_digest=anchor.policy_digest,
|
||||
plan_provider_root=anchor.plan_provider_root,
|
||||
task_id=anchor.task_id,
|
||||
grant_id=anchor.grant_id,
|
||||
worker_facts=facts,
|
||||
evidence_refs=tuple(dict.fromkeys(evidence)),
|
||||
)
|
||||
|
||||
|
||||
class ParallelDiscoveryCoordinator:
|
||||
def __init__(self, workers: Mapping[str, Callable[[], DiscoveryObservation]], *, max_workers: int | None = None):
|
||||
if not workers:
|
||||
raise ValueError("workers must not be empty")
|
||||
self.workers = dict(workers)
|
||||
self.max_workers = max_workers or min(8, len(self.workers))
|
||||
|
||||
def discover(self) -> RuntimeManifest:
|
||||
barrier = ConsistencyBarrier(self.workers.keys())
|
||||
errors: dict[str, str] = {}
|
||||
with ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix="aios-discovery") as pool:
|
||||
futures = {pool.submit(fn): name for name, fn in self.workers.items()}
|
||||
for future in as_completed(futures):
|
||||
name = futures[future]
|
||||
try:
|
||||
observation = future.result()
|
||||
if observation.worker != name:
|
||||
raise PreflightNotReady(
|
||||
f"worker name mismatch expected={name!r} got={observation.worker!r}"
|
||||
)
|
||||
barrier.submit(observation)
|
||||
except Exception as exc:
|
||||
errors[name] = f"{type(exc).__name__}:{exc}"
|
||||
if errors:
|
||||
details = ",".join(f"{k}={v}" for k, v in sorted(errors.items()))
|
||||
raise PreflightNotReady(f"discovery failed: {details}")
|
||||
return barrier.freeze()
|
||||
|
||||
|
||||
class ManifestStore:
|
||||
def __init__(self, db_path: str | Path):
|
||||
self.db_path = str(db_path)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS frozen_manifests (
|
||||
manifest_id TEXT PRIMARY KEY,
|
||||
runtime_fingerprint TEXT NOT NULL,
|
||||
process_start_identity TEXT NOT NULL,
|
||||
canonical_json TEXT NOT NULL,
|
||||
frozen_at REAL NOT NULL,
|
||||
superseded_at REAL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def freeze(self, manifest: RuntimeManifest) -> str:
|
||||
manifest_id = manifest.manifest_id
|
||||
canonical = canonical_json_bytes(manifest.payload()).decode("utf-8")
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT canonical_json FROM frozen_manifests WHERE manifest_id=?",
|
||||
(manifest_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO frozen_manifests(
|
||||
manifest_id,runtime_fingerprint,process_start_identity,
|
||||
canonical_json,frozen_at,superseded_at
|
||||
) VALUES(?,?,?,?,?,NULL)
|
||||
""",
|
||||
(
|
||||
manifest_id,
|
||||
manifest.runtime_fingerprint,
|
||||
manifest.process_start_identity,
|
||||
canonical,
|
||||
time.time(),
|
||||
),
|
||||
)
|
||||
elif row["canonical_json"] != canonical:
|
||||
conn.rollback()
|
||||
raise IdempotencyConflict("manifest_id collision with different canonical bytes")
|
||||
conn.commit()
|
||||
return manifest_id
|
||||
|
||||
def assert_current(self, *, manifest_id: str, runtime_fingerprint: str, process_start_identity: str) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT runtime_fingerprint,process_start_identity,superseded_at
|
||||
FROM frozen_manifests WHERE manifest_id=?
|
||||
""",
|
||||
(manifest_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise StaleManifest("unknown manifest_id")
|
||||
if row["superseded_at"] is not None:
|
||||
raise StaleManifest("manifest superseded")
|
||||
if row["runtime_fingerprint"] != runtime_fingerprint:
|
||||
raise StaleManifest("runtime fingerprint drift")
|
||||
if row["process_start_identity"] != process_start_identity:
|
||||
raise StaleManifest("process start identity drift")
|
||||
|
||||
def supersede(self, manifest_id: str) -> None:
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"UPDATE frozen_manifests SET superseded_at=? WHERE manifest_id=? AND superseded_at IS NULL",
|
||||
(time.time(), manifest_id),
|
||||
)
|
||||
|
||||
|
||||
class ActionState(str, Enum):
|
||||
RUNNING = "RUNNING"
|
||||
SUCCEEDED = "SUCCEEDED"
|
||||
FAILED = "FAILED"
|
||||
EFFECT_UNKNOWN = "EFFECT_UNKNOWN"
|
||||
|
||||
|
||||
class ActionKind(str, Enum):
|
||||
READ_ONLY = "READ_ONLY"
|
||||
MALFORMED_TOOL_REQUEST = "MALFORMED_TOOL_REQUEST"
|
||||
ACTIVATION = "ACTIVATION"
|
||||
RESTART = "RESTART"
|
||||
EPOCH_ISSUANCE = "EPOCH_ISSUANCE"
|
||||
ADMISSION = "ADMISSION"
|
||||
CLAIM = "CLAIM"
|
||||
COMMIT = "COMMIT"
|
||||
GENERIC_MUTATION = "GENERIC_MUTATION"
|
||||
|
||||
|
||||
DEFAULT_RETRY_BUDGETS: Mapping[ActionKind, int] = {
|
||||
ActionKind.READ_ONLY: 2,
|
||||
ActionKind.MALFORMED_TOOL_REQUEST: 1,
|
||||
ActionKind.ACTIVATION: 1,
|
||||
ActionKind.RESTART: 1,
|
||||
ActionKind.EPOCH_ISSUANCE: 1,
|
||||
ActionKind.ADMISSION: 1,
|
||||
ActionKind.CLAIM: 1,
|
||||
ActionKind.COMMIT: 1,
|
||||
ActionKind.GENERIC_MUTATION: 1,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionTicket:
|
||||
action_id: str
|
||||
idempotency_key: str
|
||||
manifest_id: str
|
||||
phase: str
|
||||
kind: ActionKind
|
||||
target: str
|
||||
precondition_hash: str
|
||||
attempt_count: int
|
||||
max_attempts: int
|
||||
state: ActionState
|
||||
reused: bool = False
|
||||
requires_reconcile: bool = False
|
||||
|
||||
|
||||
class ActionLedger:
|
||||
def __init__(self, db_path: str | Path, *, retry_budgets: Mapping[ActionKind, int] | None = None):
|
||||
self.db_path = str(db_path)
|
||||
self.retry_budgets = dict(DEFAULT_RETRY_BUDGETS)
|
||||
if retry_budgets:
|
||||
self.retry_budgets.update(retry_budgets)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute("PRAGMA journal_mode=WAL")
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS actions (
|
||||
action_id TEXT PRIMARY KEY,
|
||||
idempotency_key TEXT UNIQUE NOT NULL,
|
||||
manifest_id TEXT NOT NULL,
|
||||
phase TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
precondition_hash TEXT NOT NULL,
|
||||
attempt_count INTEGER NOT NULL,
|
||||
max_attempts INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
result_json TEXT,
|
||||
evidence_json TEXT,
|
||||
expected_readback_json TEXT,
|
||||
rollback_ref TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def begin(
|
||||
self,
|
||||
*,
|
||||
idempotency_key: str,
|
||||
manifest_id: str,
|
||||
phase: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition_hash: str,
|
||||
expected_readback: Mapping[str, Any] | None = None,
|
||||
rollback_ref: str | None = None,
|
||||
) -> ActionTicket:
|
||||
max_attempts = int(self.retry_budgets[kind])
|
||||
now = time.time()
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT * FROM actions WHERE idempotency_key=?",
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
action_id = "act-" + uuid.uuid4().hex
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO actions(
|
||||
action_id,idempotency_key,manifest_id,phase,kind,target,
|
||||
precondition_hash,attempt_count,max_attempts,state,
|
||||
result_json,evidence_json,expected_readback_json,rollback_ref,
|
||||
created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
action_id,idempotency_key,manifest_id,phase,kind.value,target,
|
||||
precondition_hash,1,max_attempts,ActionState.RUNNING.value,
|
||||
None,"[]",json.dumps(dict(expected_readback or {}), sort_keys=True),
|
||||
rollback_ref,now,now,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return ActionTicket(
|
||||
action_id,idempotency_key,manifest_id,phase,kind,target,
|
||||
precondition_hash,1,max_attempts,ActionState.RUNNING
|
||||
)
|
||||
if row["manifest_id"] != manifest_id or row["precondition_hash"] != precondition_hash:
|
||||
conn.rollback()
|
||||
raise IdempotencyConflict(
|
||||
"same idempotency key used with different manifest/precondition"
|
||||
)
|
||||
state = ActionState(row["state"])
|
||||
if state == ActionState.SUCCEEDED:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True)
|
||||
if state == ActionState.RUNNING:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True)
|
||||
if state == ActionState.EFFECT_UNKNOWN:
|
||||
conn.commit()
|
||||
return self._ticket(row, reused=True, requires_reconcile=True)
|
||||
attempts = int(row["attempt_count"])
|
||||
if attempts >= int(row["max_attempts"]):
|
||||
conn.rollback()
|
||||
raise RetryBudgetExhausted(
|
||||
f"retry budget exhausted key={idempotency_key} attempts={attempts}"
|
||||
)
|
||||
attempts += 1
|
||||
conn.execute(
|
||||
"UPDATE actions SET attempt_count=?,state=?,updated_at=? WHERE action_id=?",
|
||||
(attempts,ActionState.RUNNING.value,now,row["action_id"]),
|
||||
)
|
||||
conn.commit()
|
||||
updated = dict(row)
|
||||
updated["attempt_count"] = attempts
|
||||
updated["state"] = ActionState.RUNNING.value
|
||||
return self._ticket(updated)
|
||||
|
||||
def _ticket(self, row: Mapping[str, Any], *, reused: bool = False, requires_reconcile: bool = False) -> ActionTicket:
|
||||
return ActionTicket(
|
||||
action_id=str(row["action_id"]),
|
||||
idempotency_key=str(row["idempotency_key"]),
|
||||
manifest_id=str(row["manifest_id"]),
|
||||
phase=str(row["phase"]),
|
||||
kind=ActionKind(str(row["kind"])),
|
||||
target=str(row["target"]),
|
||||
precondition_hash=str(row["precondition_hash"]),
|
||||
attempt_count=int(row["attempt_count"]),
|
||||
max_attempts=int(row["max_attempts"]),
|
||||
state=ActionState(str(row["state"])),
|
||||
reused=reused,
|
||||
requires_reconcile=requires_reconcile,
|
||||
)
|
||||
|
||||
def _finish(self, action_id: str, state: ActionState, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
if state == ActionState.RUNNING:
|
||||
raise ValueError("finish state cannot be RUNNING")
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute("SELECT state FROM actions WHERE action_id=?", (action_id,)).fetchone()
|
||||
if row is None:
|
||||
conn.rollback()
|
||||
raise GovernanceControlError("unknown action_id")
|
||||
if row["state"] == ActionState.SUCCEEDED.value and state != ActionState.SUCCEEDED:
|
||||
conn.rollback()
|
||||
raise InvalidStateTransition("cannot move succeeded action backwards")
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE actions SET state=?,result_json=?,evidence_json=?,updated_at=?
|
||||
WHERE action_id=?
|
||||
""",
|
||||
(
|
||||
state.value,json.dumps(dict(result or {}), sort_keys=True),
|
||||
json.dumps([str(x) for x in evidence_refs], sort_keys=True),
|
||||
time.time(),action_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def mark_succeeded(self, action_id: str, *, result: Mapping[str, Any], evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.SUCCEEDED, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def mark_failed(self, action_id: str, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.FAILED, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
def mark_effect_unknown(self, action_id: str, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
self._finish(action_id, ActionState.EFFECT_UNKNOWN, result=result, evidence_refs=evidence_refs)
|
||||
|
||||
|
||||
class HypothesisStatus(str, Enum):
|
||||
OPEN = "OPEN"
|
||||
CONFIRMED = "CONFIRMED"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
class HypothesisLedger:
|
||||
def __init__(self, db_path: str | Path):
|
||||
self.db_path = str(db_path)
|
||||
self._lock = threading.RLock()
|
||||
self._init_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.db_path, timeout=5, isolation_level=None)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _init_schema(self) -> None:
|
||||
with closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hypotheses (
|
||||
hypothesis_id TEXT PRIMARY KEY,
|
||||
claim TEXT NOT NULL,
|
||||
evidence_for_json TEXT NOT NULL,
|
||||
evidence_against_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
next_probe TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def record(
|
||||
self,
|
||||
*,
|
||||
hypothesis_id: str,
|
||||
claim: str,
|
||||
next_probe: str | None = None,
|
||||
evidence_for: Sequence[str] = (),
|
||||
evidence_against: Sequence[str] = (),
|
||||
) -> None:
|
||||
now = time.time()
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO hypotheses(
|
||||
hypothesis_id,claim,evidence_for_json,evidence_against_json,
|
||||
status,next_probe,created_at,updated_at
|
||||
) VALUES(?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(hypothesis_id) DO UPDATE SET
|
||||
claim=excluded.claim,
|
||||
evidence_for_json=excluded.evidence_for_json,
|
||||
evidence_against_json=excluded.evidence_against_json,
|
||||
next_probe=excluded.next_probe,
|
||||
updated_at=excluded.updated_at
|
||||
""",
|
||||
(
|
||||
hypothesis_id,claim,json.dumps(list(evidence_for), sort_keys=True),
|
||||
json.dumps(list(evidence_against), sort_keys=True),
|
||||
HypothesisStatus.OPEN.value,next_probe,now,now,
|
||||
),
|
||||
)
|
||||
|
||||
def set_status(self, hypothesis_id: str, status: HypothesisStatus) -> None:
|
||||
with self._lock, closing(self._connect()) as conn:
|
||||
cur = conn.execute(
|
||||
"UPDATE hypotheses SET status=?,updated_at=? WHERE hypothesis_id=?",
|
||||
(status.value,time.time(),hypothesis_id),
|
||||
)
|
||||
if cur.rowcount != 1:
|
||||
raise GovernanceControlError("unknown hypothesis_id")
|
||||
|
||||
def assert_probe_discriminates(self, candidate_hypothesis_ids: Sequence[str]) -> None:
|
||||
ids = tuple(dict.fromkeys(str(x) for x in candidate_hypothesis_ids))
|
||||
if len(ids) < 2:
|
||||
raise GovernanceControlError("probe must discriminate at least two candidate hypotheses")
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
with closing(self._connect()) as conn:
|
||||
rows = conn.execute(
|
||||
f"SELECT hypothesis_id,status FROM hypotheses WHERE hypothesis_id IN ({placeholders})",
|
||||
ids,
|
||||
).fetchall()
|
||||
open_ids = {
|
||||
str(r["hypothesis_id"]) for r in rows
|
||||
if r["status"] == HypothesisStatus.OPEN.value
|
||||
}
|
||||
if len(open_ids) < 2:
|
||||
raise GovernanceControlError("probe does not discriminate at least two OPEN hypotheses")
|
||||
|
||||
|
||||
class BlockerClassification(str, Enum):
|
||||
EXTERNAL_DEPENDENCY = "EXTERNAL_DEPENDENCY"
|
||||
MECHANISM_DEFECT = "MECHANISM_DEFECT"
|
||||
TRANSIENT = "TRANSIENT"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
SELF_ACTIONABLE = "SELF_ACTIONABLE"
|
||||
|
||||
|
||||
class ProducerKind(str, Enum):
|
||||
SELF = "SELF"
|
||||
AUTHORITY_SIDE = "AUTHORITY_SIDE"
|
||||
IMPLEMENTER = "IMPLEMENTER"
|
||||
CONTROL_PLANE_OWNER = "CONTROL_PLANE_OWNER"
|
||||
OWNER = "OWNER"
|
||||
|
||||
|
||||
class NextAction(str, Enum):
|
||||
ROOT_COMPLETE = "ROOT_COMPLETE"
|
||||
CREATE_OR_RESUME_NEXT_CHILD = "CREATE_OR_RESUME_NEXT_CHILD"
|
||||
WAIT_DEPENDENCY = "WAIT_DEPENDENCY"
|
||||
RECOVER_EXISTING_CHILD = "RECOVER_EXISTING_CHILD"
|
||||
OWNER_BOUNDARY = "OWNER_BOUNDARY"
|
||||
REPAIR_MECHANISM = "REPAIR_MECHANISM"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ProducerBinding:
|
||||
classification: BlockerClassification
|
||||
producer_kind: ProducerKind
|
||||
producer_id: str
|
||||
action_required: str
|
||||
|
||||
|
||||
DEFAULT_PRODUCER_TABLE: Mapping[str, ProducerBinding] = {
|
||||
"TASK_NOT_RESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "task-mission-authority", "PROVISION_EXACT_CANDIDATE"),
|
||||
"TASK_RESOLUTION_AMBIGUOUS": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "task-mission-authority", "PROVISION_EXACT_CANDIDATE"),
|
||||
"LIVE_SURFACE_UNAVAILABLE": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-authority-owner", "EXPOSE_READ_SURFACE"),
|
||||
"LIVE_BINDING_VIOLATION": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "agent-side-adapter-owner", "FIX_BINDING_ADAPTER"),
|
||||
"GOVERNED_WRITES_DISABLED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.CONTROL_PLANE_OWNER, "bridge-runtime-config", "ENABLE_GOVERNED_WRITES"),
|
||||
"TASK_PRINCIPAL_WRITES_DISABLED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.CONTROL_PLANE_OWNER, "bridge-runtime-config", "ENABLE_TASK_PRINCIPAL_WRITES"),
|
||||
"TASK_LEASE_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ACQUIRE_LEASE_VIA_GOVERNED_PATH"),
|
||||
"TASK_PRINCIPAL_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ISSUE_TASK_PRINCIPAL"),
|
||||
"WRITER_LEASE_MISSING": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "ACQUIRE_WRITER_LEASE"),
|
||||
"EXPECTED_BRANCH_OID_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"GENERATION_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"TARGET_BRANCH_UNRESOLVED": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "governed-bridge-provisioner", "PROVISION_CANDIDATE_BINDING"),
|
||||
"FILE_ALLOWLIST_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"ACCEPTANCE_REVISION_MISSING": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner-delegation-epoch", "ISSUE_OWNER_EPOCH"),
|
||||
"RECOVERY_RESULT_UNKNOWN": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "agent-recovery-executor", "RECONCILE_BEFORE_RETRY"),
|
||||
"CAPABILITY_OUT_OF_POLICY": ProducerBinding(BlockerClassification.EXTERNAL_DEPENDENCY, ProducerKind.AUTHORITY_SIDE, "admission-policy-authority", "ALIGN_POLICY_AND_EPOCH"),
|
||||
"EXECUTION_DIGEST_INVALID": ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "candidate-plan-builder", "REGENERATE_CANONICAL_PLAN"),
|
||||
"TASK_EXPIRED_OWNER_RECOVERY_REQUIRED": ProducerBinding(BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "agent-task-terminal-recovery", "AUTHORIZE_TERMINAL_RECOVERY_OR_SUCCESSOR"),
|
||||
"EPOCH_EXPIRED": ProducerBinding(BlockerClassification.SELF_ACTIONABLE, ProducerKind.SELF, "trusted-grant-signer-adapter", "JIT_REISSUE_EPOCH_IF_SCOPE_UNCHANGED"),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DecisionInput:
|
||||
goal_id: str
|
||||
blocker_codes: tuple[str, ...] = ()
|
||||
owner_boundary_required: bool = False
|
||||
surfaces_ready: bool = True
|
||||
resolution_state: str = "RESOLVED"
|
||||
admission_ready: bool = False
|
||||
acceptance_complete: bool = False
|
||||
descendant_statuses: tuple[str, ...] = ()
|
||||
unchanged_cycles: int = 0
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceDecision:
|
||||
goal_id: str
|
||||
next_valid_action: NextAction
|
||||
owner_boundary: bool
|
||||
blocker_code: str | None
|
||||
classification: BlockerClassification | None
|
||||
producer_kind: ProducerKind | None
|
||||
producer_id: str | None
|
||||
action_required: str | None
|
||||
evidence_refs: tuple[str, ...]
|
||||
schema: str = SCHEMA_DECISION
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["next_valid_action"] = self.next_valid_action.value
|
||||
result["classification"] = None if self.classification is None else self.classification.value
|
||||
result["producer_kind"] = None if self.producer_kind is None else self.producer_kind.value
|
||||
return result
|
||||
|
||||
|
||||
class AutonomousDecisionEngine:
|
||||
RECOVERY_STATUSES = frozenset({"FAILED_RECOVERABLE", "EXPIRED", "RECOVERY_REQUIRED"})
|
||||
BLOCKED_STATUSES = frozenset({"BLOCKED", "WAIT_DEPENDENCY", "TERMINAL_UNPROVEN"})
|
||||
|
||||
def __init__(self, producer_table: Mapping[str, ProducerBinding] | None = None):
|
||||
self.producer_table = dict(DEFAULT_PRODUCER_TABLE)
|
||||
if producer_table:
|
||||
self.producer_table.update(producer_table)
|
||||
|
||||
def _binding(self, blocker: str) -> ProducerBinding:
|
||||
return self.producer_table.get(
|
||||
blocker,
|
||||
ProducerBinding(BlockerClassification.MECHANISM_DEFECT, ProducerKind.IMPLEMENTER, "unmapped-blocker-owner", "CLASSIFY_BLOCKER"),
|
||||
)
|
||||
|
||||
def decide(self, inp: DecisionInput) -> GovernanceDecision:
|
||||
blockers = tuple(dict.fromkeys(inp.blocker_codes))
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.MECHANISM_DEFECT:
|
||||
return self._decision(inp, blocker, binding, NextAction.REPAIR_MECHANISM)
|
||||
if inp.owner_boundary_required:
|
||||
blocker = blockers[0] if blockers else "OWNER_BOUNDARY"
|
||||
binding = self._binding(blocker) if blockers else ProducerBinding(
|
||||
BlockerClassification.OWNER_BOUNDARY, ProducerKind.OWNER, "owner", "OWNER_DECISION_REQUIRED"
|
||||
)
|
||||
return self._decision(inp, blocker, binding, NextAction.OWNER_BOUNDARY)
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.OWNER_BOUNDARY:
|
||||
return self._decision(inp, blocker, binding, NextAction.OWNER_BOUNDARY)
|
||||
if not inp.surfaces_ready:
|
||||
blocker = "LIVE_SURFACE_UNAVAILABLE"
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
if inp.resolution_state != "RESOLVED":
|
||||
blocker = "TASK_RESOLUTION_AMBIGUOUS" if inp.resolution_state == "AMBIGUOUS" else "TASK_NOT_RESOLVED"
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
if not inp.admission_ready:
|
||||
for blocker in blockers:
|
||||
binding = self._binding(blocker)
|
||||
if binding.classification == BlockerClassification.SELF_ACTIONABLE:
|
||||
return self._decision(inp, blocker, binding, NextAction.RECOVER_EXISTING_CHILD)
|
||||
if blockers:
|
||||
blocker = blockers[0]
|
||||
return self._decision(inp, blocker, self._binding(blocker), NextAction.WAIT_DEPENDENCY)
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.WAIT_DEPENDENCY,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="admission-authority",
|
||||
action_required="MAKE_ADMISSION_READY",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
if inp.acceptance_complete:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.ROOT_COMPLETE,
|
||||
owner_boundary=False,blocker_code=None,classification=None,
|
||||
producer_kind=None,producer_id=None,action_required=None,evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
statuses = {str(x).upper() for x in inp.descendant_statuses}
|
||||
if statuses & self.RECOVERY_STATUSES:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.RECOVER_EXISTING_CHILD,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="agent-recovery-executor",
|
||||
action_required="RECOVER_EXISTING_CHILD",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
if statuses & self.BLOCKED_STATUSES:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.WAIT_DEPENDENCY,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.EXTERNAL_DEPENDENCY,
|
||||
producer_kind=ProducerKind.AUTHORITY_SIDE,producer_id="dependency-authority",
|
||||
action_required="UNBLOCK_DEPENDENCY",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=NextAction.CREATE_OR_RESUME_NEXT_CHILD,
|
||||
owner_boundary=False,blocker_code=None,
|
||||
classification=BlockerClassification.SELF_ACTIONABLE,
|
||||
producer_kind=ProducerKind.SELF,producer_id="autonomy-controller",
|
||||
action_required="CREATE_OR_RESUME_NEXT_CHILD",evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
|
||||
def _decision(self, inp: DecisionInput, blocker: str, binding: ProducerBinding, next_action: NextAction) -> GovernanceDecision:
|
||||
return GovernanceDecision(
|
||||
goal_id=inp.goal_id,next_valid_action=next_action,
|
||||
owner_boundary=binding.classification == BlockerClassification.OWNER_BOUNDARY,
|
||||
blocker_code=blocker,classification=binding.classification,
|
||||
producer_kind=binding.producer_kind,producer_id=binding.producer_id,
|
||||
action_required=binding.action_required,evidence_refs=inp.evidence_refs,
|
||||
)
|
||||
|
||||
|
||||
class MutationGuard:
|
||||
def __init__(self, manifest_store: ManifestStore, action_ledger: ActionLedger):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self._writer_lock = threading.Lock()
|
||||
|
||||
def begin_mutation(
|
||||
self,
|
||||
*,
|
||||
manifest_id: str,
|
||||
runtime_fingerprint: str,
|
||||
process_start_identity: str,
|
||||
idempotency_key: str,
|
||||
phase: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition_hash: str,
|
||||
expected_readback: Mapping[str, Any] | None = None,
|
||||
rollback_ref: str | None = None,
|
||||
) -> ActionTicket:
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint=runtime_fingerprint,
|
||||
process_start_identity=process_start_identity,
|
||||
)
|
||||
if not self._writer_lock.acquire(blocking=False):
|
||||
raise ConcurrentWriterDenied("another governed mutation is in progress")
|
||||
try:
|
||||
ticket = self.action_ledger.begin(
|
||||
idempotency_key=idempotency_key,manifest_id=manifest_id,phase=phase,
|
||||
kind=kind,target=target,precondition_hash=precondition_hash,
|
||||
expected_readback=expected_readback,rollback_ref=rollback_ref,
|
||||
)
|
||||
if ticket.reused:
|
||||
self._writer_lock.release()
|
||||
return ticket
|
||||
except Exception:
|
||||
self._writer_lock.release()
|
||||
raise
|
||||
|
||||
def finish_success(self, ticket: ActionTicket, *, result: Mapping[str, Any], evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_succeeded(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
|
||||
def finish_failed(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
|
||||
def finish_effect_unknown(self, ticket: ActionTicket, *, result: Mapping[str, Any] | None = None, evidence_refs: Sequence[str] = ()) -> None:
|
||||
try:
|
||||
self.action_ledger.mark_effect_unknown(ticket.action_id, result=result, evidence_refs=evidence_refs)
|
||||
finally:
|
||||
if self._writer_lock.locked():
|
||||
self._writer_lock.release()
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Live driver that converts external surface failures into one checkpoint.
|
||||
|
||||
The controller remains authority-neutral. This driver owns the site boundary:
|
||||
surface availability, binding-contract failures, and poll fallback.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from .governance_checkpoint import RecoveryCheckpoint, RetryBudget
|
||||
from .governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
ReconcileResult,
|
||||
)
|
||||
from .site_adapters import LiveBindingViolation, LiveSurfaceUnavailable
|
||||
|
||||
|
||||
class GovernanceLiveDriver:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GovernanceRecoveryController,
|
||||
*,
|
||||
retry_budget: RetryBudget | None = None,
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.retry_budget = retry_budget or controller.retry_budget
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
query = self.controller.query
|
||||
return (
|
||||
query.patch_digest
|
||||
or query.artifact_sha256
|
||||
or query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _persist_external_blocker(
|
||||
self,
|
||||
event: GovernanceEvent,
|
||||
*,
|
||||
code: str,
|
||||
detail: str,
|
||||
mechanism_repair_required: bool,
|
||||
) -> ReconcileResult:
|
||||
previous = self.controller.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(code, (detail,))
|
||||
if mechanism_repair_required:
|
||||
cycles = (
|
||||
previous.unchanged_cycles + 1
|
||||
if previous
|
||||
and previous.blocker_code == code
|
||||
and previous.blocker_fingerprint == fingerprint
|
||||
else 1
|
||||
)
|
||||
else:
|
||||
cycles, _ = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = (
|
||||
"REPAIR_MECHANISM"
|
||||
if mechanism_repair_required
|
||||
else "WAIT_DEPENDENCY"
|
||||
)
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.controller.goal_id,
|
||||
repository=self.controller.query.repository,
|
||||
source_oid=self.controller.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self.controller._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
self.controller.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=None,
|
||||
blocker_code=code,
|
||||
mechanism_repair_required=mechanism_repair_required,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
try:
|
||||
return self.controller.on_event(event)
|
||||
except LiveSurfaceUnavailable as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_SURFACE_UNAVAILABLE",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
except LiveBindingViolation as exc:
|
||||
return self._persist_external_blocker(
|
||||
event,
|
||||
code="LIVE_BINDING_VIOLATION",
|
||||
detail=str(exc),
|
||||
mechanism_repair_required=True,
|
||||
)
|
||||
|
||||
def poll_once(
|
||||
self, *, revision: str, occurred_at: str
|
||||
) -> ReconcileResult:
|
||||
return self.on_event(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,474 @@
|
||||
|
||||
"""Event-driven governed recovery reconciler with polling fallback.
|
||||
|
||||
The controller cannot create a task and cannot bypass admission. It resumes
|
||||
only an exact, already-existing child through an injected governed executor.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Mapping, Protocol
|
||||
|
||||
from .governance_admission import (
|
||||
AdmissionOperation,
|
||||
AdmissionPreflight,
|
||||
GovernanceFacts,
|
||||
)
|
||||
from .governance_checkpoint import (
|
||||
CheckpointStore,
|
||||
RecoveryCheckpoint,
|
||||
RetryBudget,
|
||||
)
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionState
|
||||
|
||||
|
||||
def _canonical(value) -> str:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False
|
||||
)
|
||||
|
||||
|
||||
def _digest(value) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryReceipt:
|
||||
receipt_ref: str
|
||||
task_id: str
|
||||
accepted: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ReconcileResult:
|
||||
status: str
|
||||
next_legal_action: str
|
||||
task_id: str | None
|
||||
blocker_code: str | None
|
||||
mechanism_repair_required: bool
|
||||
receipt_ref: str | None = None
|
||||
|
||||
|
||||
class GovernanceFactsProvider(Protocol):
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts: ...
|
||||
|
||||
|
||||
class ExistingChildRecoveryExecutor(Protocol):
|
||||
def recover_existing_child(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
facts: GovernanceFacts,
|
||||
) -> RecoveryReceipt: ...
|
||||
|
||||
# Optional at runtime. If implemented, this is preferred after an
|
||||
# unknown-result write so the controller reconciles before re-executing.
|
||||
def reconcile_recovery_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
idempotency_key: str,
|
||||
) -> RecoveryReceipt | None: ...
|
||||
|
||||
|
||||
class GovernanceRecoveryController:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
goal_id: str,
|
||||
query: ResolutionQuery,
|
||||
actor_identity: str,
|
||||
resolver: ExactTaskResolver,
|
||||
facts_provider: GovernanceFactsProvider,
|
||||
executor: ExistingChildRecoveryExecutor,
|
||||
checkpoint_store: CheckpointStore,
|
||||
retry_budget: RetryBudget = RetryBudget(),
|
||||
) -> None:
|
||||
self.goal_id = goal_id
|
||||
self.query = query
|
||||
self.actor_identity = actor_identity
|
||||
self.resolver = resolver
|
||||
self.facts_provider = facts_provider
|
||||
self.executor = executor
|
||||
self.checkpoints = checkpoint_store
|
||||
self.retry_budget = retry_budget
|
||||
self.preflight = AdmissionPreflight()
|
||||
|
||||
def _timestamp(self, event: GovernanceEvent) -> str:
|
||||
return event.occurred_at or datetime.now(timezone.utc).isoformat()
|
||||
|
||||
def _save_blocked(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
task_id: str | None,
|
||||
mission_id: str | None,
|
||||
root_task_id: str | None,
|
||||
parent_task_id: str | None,
|
||||
principal_id: str | None,
|
||||
blocker_code: str,
|
||||
blocker_details: tuple[str, ...],
|
||||
) -> ReconcileResult:
|
||||
previous = self.checkpoints.load()
|
||||
fingerprint = self.retry_budget.blocker_fingerprint(
|
||||
blocker_code, blocker_details
|
||||
)
|
||||
cycles, repair = self.retry_budget.observe(
|
||||
previous=previous,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
)
|
||||
next_action = "REPAIR_MECHANISM" if repair else "WAIT_DEPENDENCY"
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=task_id,
|
||||
mission_id=mission_id,
|
||||
root_task_id=root_task_id,
|
||||
parent_task_id=parent_task_id,
|
||||
principal_id=principal_id,
|
||||
blocker_code=blocker_code,
|
||||
blocker_fingerprint=fingerprint,
|
||||
next_legal_action=next_action,
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=cycles,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="BLOCKED",
|
||||
next_legal_action=next_action,
|
||||
task_id=task_id,
|
||||
blocker_code=blocker_code,
|
||||
mechanism_repair_required=repair,
|
||||
)
|
||||
|
||||
def _candidate_digest(self) -> str:
|
||||
return (
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
)
|
||||
|
||||
def _recovery_context_digest(
|
||||
self, *, task_id: str, facts: GovernanceFacts
|
||||
) -> str:
|
||||
return _digest({
|
||||
"task_id": task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": self._candidate_digest(),
|
||||
"principal": facts.authenticated_principal_id,
|
||||
"branch": facts.target_branch,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"task_lease_id": facts.task_lease_id,
|
||||
"task_principal_id": facts.task_principal_id,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
"acceptance_revision": facts.acceptance_revision,
|
||||
"file_allowlist": sorted(facts.accepted_file_allowlist),
|
||||
})
|
||||
|
||||
def _finish_recovered(
|
||||
self,
|
||||
*,
|
||||
event: GovernanceEvent,
|
||||
resolved,
|
||||
facts: GovernanceFacts,
|
||||
idempotency_key: str,
|
||||
context_digest: str,
|
||||
receipt: RecoveryReceipt,
|
||||
) -> ReconcileResult:
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=self._candidate_digest(),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=None,
|
||||
blocker_fingerprint=None,
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(
|
||||
f"event:{event.kind}:{event.revision}",
|
||||
receipt.receipt_ref,
|
||||
),
|
||||
unchanged_cycles=0,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_receipt_ref=receipt.receipt_ref,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RECOVERED",
|
||||
next_legal_action="ROOT_REVIEW",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code=None,
|
||||
mechanism_repair_required=False,
|
||||
receipt_ref=receipt.receipt_ref,
|
||||
)
|
||||
|
||||
def reconcile(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
resolution = self.resolver.resolve(
|
||||
self.query, actor_identity=self.actor_identity
|
||||
)
|
||||
if resolution.state is not ResolutionState.RESOLVED:
|
||||
code = {
|
||||
ResolutionState.NOT_FOUND: "TASK_NOT_RESOLVED",
|
||||
ResolutionState.AMBIGUOUS: "TASK_RESOLUTION_AMBIGUOUS",
|
||||
ResolutionState.INVALID_QUERY: "TASK_RESOLUTION_INVALID",
|
||||
}[resolution.state]
|
||||
details = (
|
||||
resolution.detail,
|
||||
*resolution.candidate_task_ids,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=None,
|
||||
mission_id=None,
|
||||
root_task_id=None,
|
||||
parent_task_id=None,
|
||||
principal_id=None,
|
||||
blocker_code=code,
|
||||
blocker_details=tuple(str(v) for v in details if v),
|
||||
)
|
||||
|
||||
resolved = resolution.resolved
|
||||
assert resolved is not None
|
||||
facts = self.facts_provider.snapshot(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
admission = self.preflight.evaluate(
|
||||
resolution=resolution,
|
||||
facts=facts,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
if not admission.ready:
|
||||
primary = admission.blockers[0]
|
||||
details = tuple(
|
||||
f"{blocker.code}:{blocker.detail}"
|
||||
for blocker in admission.blockers
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code=primary.code,
|
||||
blocker_details=details,
|
||||
)
|
||||
|
||||
context_digest = self._recovery_context_digest(
|
||||
task_id=resolved.task_id, facts=facts
|
||||
)
|
||||
previous = self.checkpoints.load()
|
||||
candidate_digest = self._candidate_digest()
|
||||
unknown_previous = (
|
||||
previous is not None
|
||||
and previous.blocker_code == "RECOVERY_RESULT_UNKNOWN"
|
||||
and previous.task_id == resolved.task_id
|
||||
and previous.candidate_digest == candidate_digest
|
||||
and bool(previous.recovery_idempotency_key)
|
||||
)
|
||||
|
||||
if unknown_previous:
|
||||
idempotency_key = previous.recovery_idempotency_key
|
||||
reconcile = getattr(
|
||||
self.executor, "reconcile_recovery_result", None
|
||||
)
|
||||
if callable(reconcile):
|
||||
receipt = reconcile(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
if receipt is not None:
|
||||
if receipt.accepted:
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=(
|
||||
previous.recovery_context_digest
|
||||
or context_digest
|
||||
),
|
||||
receipt=receipt,
|
||||
)
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
if (
|
||||
previous.recovery_context_digest is not None
|
||||
and previous.recovery_context_digest != context_digest
|
||||
):
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=candidate_digest,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"context_changed": True,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=(
|
||||
previous.recovery_context_digest
|
||||
),
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
else:
|
||||
idempotency_key = "recover-" + _digest({
|
||||
"task_id": resolved.task_id,
|
||||
"repository": self.query.repository,
|
||||
"candidate": candidate_digest,
|
||||
"branch_oid": facts.expected_branch_oid,
|
||||
"generation": facts.generation,
|
||||
"writer_lease_id": facts.writer_lease_id,
|
||||
})[:40]
|
||||
|
||||
try:
|
||||
receipt = self.executor.recover_existing_child(
|
||||
task_id=resolved.task_id,
|
||||
actor_identity=self.actor_identity,
|
||||
idempotency_key=idempotency_key,
|
||||
facts=facts,
|
||||
)
|
||||
except Exception:
|
||||
# Unknown-result write: retain the SAME idempotency key for the next
|
||||
# reconcile. Never assume the side effect did not happen.
|
||||
checkpoint = RecoveryCheckpoint(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id=self.goal_id,
|
||||
repository=self.query.repository,
|
||||
source_oid=self.query.base_oid,
|
||||
candidate_digest=(
|
||||
self.query.patch_digest
|
||||
or self.query.artifact_sha256
|
||||
or self.query.candidate_oid
|
||||
or ""
|
||||
),
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
blocker_fingerprint=_digest({
|
||||
"task_id": resolved.task_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
}),
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
last_verified_at=self._timestamp(event),
|
||||
evidence_refs=(f"event:{event.kind}:{event.revision}",),
|
||||
unchanged_cycles=1,
|
||||
mechanism_repair_required=False,
|
||||
recovery_idempotency_key=idempotency_key,
|
||||
recovery_context_digest=context_digest,
|
||||
)
|
||||
self.checkpoints.save(checkpoint)
|
||||
return ReconcileResult(
|
||||
status="RESULT_UNKNOWN",
|
||||
next_legal_action="RECOVER_EXISTING_CHILD",
|
||||
task_id=resolved.task_id,
|
||||
blocker_code="RECOVERY_RESULT_UNKNOWN",
|
||||
mechanism_repair_required=False,
|
||||
)
|
||||
|
||||
if not receipt.accepted:
|
||||
return self._save_blocked(
|
||||
event=event,
|
||||
task_id=resolved.task_id,
|
||||
mission_id=resolved.mission_id,
|
||||
root_task_id=resolved.root_task_id,
|
||||
parent_task_id=resolved.parent_task_id,
|
||||
principal_id=facts.authenticated_principal_id,
|
||||
blocker_code="RECOVERY_NOT_ACCEPTED",
|
||||
blocker_details=(receipt.receipt_ref,),
|
||||
)
|
||||
|
||||
return self._finish_recovered(
|
||||
event=event,
|
||||
resolved=resolved,
|
||||
facts=facts,
|
||||
idempotency_key=idempotency_key,
|
||||
context_digest=context_digest,
|
||||
receipt=receipt,
|
||||
)
|
||||
|
||||
def on_event(self, event: GovernanceEvent) -> ReconcileResult:
|
||||
# Event-driven primary path: reconcile immediately in the same call.
|
||||
return self.reconcile(event)
|
||||
|
||||
def poll_once(self, *, revision: str, occurred_at: str) -> ReconcileResult:
|
||||
# Polling is only a fallback and uses exactly the same reconcile path.
|
||||
return self.reconcile(GovernanceEvent(
|
||||
kind="POLL_FALLBACK",
|
||||
revision=revision,
|
||||
occurred_at=occurred_at,
|
||||
))
|
||||
@@ -0,0 +1,229 @@
|
||||
|
||||
"""Read-only exact Task/lineage resolver.
|
||||
|
||||
This is an index/projection over existing TaskPlane authority, not a second task
|
||||
authority. It never creates, claims, mutates, or guesses a task.
|
||||
|
||||
The source supplied by the host MUST enforce the caller's existing TaskPlane
|
||||
read authorization before returning records.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class ResolutionState(str, Enum):
|
||||
RESOLVED = "RESOLVED"
|
||||
NOT_FOUND = "NOT_FOUND"
|
||||
AMBIGUOUS = "AMBIGUOUS"
|
||||
INVALID_QUERY = "INVALID_QUERY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionQuery:
|
||||
repository: str
|
||||
base_oid: str
|
||||
task_id: str | None = None
|
||||
candidate_oid: str | None = None
|
||||
patch_digest: str | None = None
|
||||
artifact_sha256: str | None = None
|
||||
pr_id: str | None = None
|
||||
release_intent: str | None = None
|
||||
mission_id: str | None = None
|
||||
root_task_id: str | None = None
|
||||
parent_task_id: str | None = None
|
||||
owner_principal_id: str | None = None
|
||||
|
||||
def selectors(self) -> Mapping[str, str]:
|
||||
values = {
|
||||
"repository": self.repository,
|
||||
"base_oid": self.base_oid,
|
||||
"task_id": self.task_id,
|
||||
"candidate_oid": self.candidate_oid,
|
||||
"patch_digest": self.patch_digest,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"pr_id": self.pr_id,
|
||||
"release_intent": self.release_intent,
|
||||
"mission_id": self.mission_id,
|
||||
"root_task_id": self.root_task_id,
|
||||
"parent_task_id": self.parent_task_id,
|
||||
"owner_principal_id": self.owner_principal_id,
|
||||
}
|
||||
return {key: str(value) for key, value in values.items() if value}
|
||||
|
||||
def validate(self) -> None:
|
||||
if not self.repository.strip() or not self.base_oid.strip():
|
||||
raise ValueError("repository and base_oid required")
|
||||
strong = (
|
||||
self.task_id,
|
||||
self.candidate_oid,
|
||||
self.patch_digest,
|
||||
self.artifact_sha256,
|
||||
self.pr_id,
|
||||
self.release_intent,
|
||||
self.mission_id,
|
||||
self.root_task_id,
|
||||
)
|
||||
if not any(value for value in strong):
|
||||
raise ValueError(
|
||||
"at least one strong selector required "
|
||||
"(task/candidate/patch/artifact/pr/intent/mission/root)"
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolvedTask:
|
||||
task_id: str
|
||||
mission_id: str | None
|
||||
root_task_id: str | None
|
||||
parent_task_id: str | None
|
||||
owner_principal_id: str | None
|
||||
record: Mapping[str, Any]
|
||||
matched_selectors: tuple[str, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResolutionResult:
|
||||
state: ResolutionState
|
||||
resolved: ResolvedTask | None = None
|
||||
candidate_task_ids: tuple[str, ...] = ()
|
||||
detail: str = ""
|
||||
|
||||
|
||||
class TaskResolutionSource(Protocol):
|
||||
"""Caller-authorized read projection over the existing TaskPlane."""
|
||||
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> Sequence[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
return value if isinstance(value, Mapping) else {}
|
||||
|
||||
|
||||
def _metadata(record: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
plan = _mapping(record.get("plan") or record.get("task_plan"))
|
||||
merged: dict[str, Any] = {}
|
||||
for source in (
|
||||
_mapping(record.get("metadata")),
|
||||
_mapping(plan.get("metadata")),
|
||||
):
|
||||
merged.update(source)
|
||||
# Some existing lineage metadata is nested.
|
||||
for key in ("aios_task_lineage", "lineage", "task_lineage"):
|
||||
nested = _mapping(merged.get(key))
|
||||
for nkey, value in nested.items():
|
||||
merged.setdefault(nkey, value)
|
||||
return merged
|
||||
|
||||
|
||||
def _first(record: Mapping[str, Any], metadata: Mapping[str, Any], *keys: str):
|
||||
for key in keys:
|
||||
if key in record and record.get(key) not in (None, ""):
|
||||
return record.get(key)
|
||||
if key in metadata and metadata.get(key) not in (None, ""):
|
||||
return metadata.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _projection(record: Mapping[str, Any]) -> Mapping[str, str]:
|
||||
metadata = _metadata(record)
|
||||
values = {
|
||||
"repository": _first(record, metadata, "repository", "repo"),
|
||||
"base_oid": _first(record, metadata, "base_oid", "source_oid"),
|
||||
"task_id": _first(record, metadata, "task_id"),
|
||||
"candidate_oid": _first(record, metadata, "candidate_oid", "head_oid"),
|
||||
"patch_digest": _first(record, metadata, "patch_digest", "candidate_digest"),
|
||||
"artifact_sha256": _first(record, metadata, "artifact_sha256", "artifact_digest"),
|
||||
"pr_id": _first(record, metadata, "pr_id", "pull_request_id"),
|
||||
"release_intent": _first(record, metadata, "release_intent", "release_intent_id"),
|
||||
"mission_id": _first(record, metadata, "mission_id"),
|
||||
"root_task_id": _first(record, metadata, "root_task_id"),
|
||||
"parent_task_id": _first(record, metadata, "parent_task_id"),
|
||||
"owner_principal_id": _first(record, metadata, "owner_principal_id"),
|
||||
}
|
||||
return {
|
||||
key: str(value)
|
||||
for key, value in values.items()
|
||||
if value not in (None, "")
|
||||
}
|
||||
|
||||
|
||||
def _matches(record: Mapping[str, Any], selectors: Mapping[str, str]) -> bool:
|
||||
projection = _projection(record)
|
||||
# Exact-only: a missing selector never counts as a partial match.
|
||||
return all(projection.get(key) == value for key, value in selectors.items())
|
||||
|
||||
|
||||
class ExactTaskResolver:
|
||||
def __init__(self, source: TaskResolutionSource) -> None:
|
||||
self.source = source
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
try:
|
||||
query.validate()
|
||||
except ValueError as exc:
|
||||
return ResolutionResult(
|
||||
ResolutionState.INVALID_QUERY, detail=str(exc)
|
||||
)
|
||||
|
||||
selectors = query.selectors()
|
||||
|
||||
if query.task_id:
|
||||
record = self.source.get_task(
|
||||
query.task_id, actor_identity=actor_identity
|
||||
)
|
||||
records = [record] if record is not None else []
|
||||
else:
|
||||
records = list(
|
||||
self.source.list_tasks(actor_identity=actor_identity)
|
||||
)
|
||||
|
||||
matches = [
|
||||
record
|
||||
for record in records
|
||||
if record is not None and _matches(record, selectors)
|
||||
]
|
||||
if not matches:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="no caller-authorized task exactly matches all selectors",
|
||||
)
|
||||
if len(matches) > 1:
|
||||
ids = sorted(
|
||||
str(_projection(record).get("task_id") or "")
|
||||
for record in matches
|
||||
)
|
||||
return ResolutionResult(
|
||||
ResolutionState.AMBIGUOUS,
|
||||
candidate_task_ids=tuple(ids),
|
||||
detail="multiple exact matches; resolver refuses to guess",
|
||||
)
|
||||
|
||||
record = matches[0]
|
||||
projected = _projection(record)
|
||||
task_id = projected.get("task_id")
|
||||
if not task_id:
|
||||
return ResolutionResult(
|
||||
ResolutionState.NOT_FOUND,
|
||||
detail="matching record lacks authoritative task_id",
|
||||
)
|
||||
resolved = ResolvedTask(
|
||||
task_id=task_id,
|
||||
mission_id=projected.get("mission_id"),
|
||||
root_task_id=projected.get("root_task_id"),
|
||||
parent_task_id=projected.get("parent_task_id"),
|
||||
owner_principal_id=projected.get("owner_principal_id"),
|
||||
record=record,
|
||||
matched_selectors=tuple(sorted(selectors)),
|
||||
)
|
||||
return ResolutionResult(ResolutionState.RESOLVED, resolved=resolved)
|
||||
@@ -0,0 +1,912 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol, Sequence
|
||||
|
||||
from .governance_control_kernel import (
|
||||
ActionKind,
|
||||
ActionLedger,
|
||||
AutonomousDecisionEngine,
|
||||
BlockerClassification,
|
||||
GovernanceControlError,
|
||||
ManifestStore,
|
||||
NextAction,
|
||||
ProducerKind,
|
||||
)
|
||||
|
||||
|
||||
class AuthorizationDenied(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterUnavailable(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class OwnerActionRequired(GovernanceControlError):
|
||||
pass
|
||||
|
||||
|
||||
class GovernedWriterState(str, Enum):
|
||||
PREFLIGHT_FROZEN = "PREFLIGHT_FROZEN"
|
||||
OWNER_AUTHORIZED = "OWNER_AUTHORIZED"
|
||||
EPOCH_VALID = "EPOCH_VALID"
|
||||
TASK_CLAIMED = "TASK_CLAIMED"
|
||||
TASK_LEASED = "TASK_LEASED"
|
||||
PRINCIPAL_ISSUED = "PRINCIPAL_ISSUED"
|
||||
JOB_BOUND = "JOB_BOUND"
|
||||
CAS_VERIFIED = "CAS_VERIFIED"
|
||||
WRITER_LEASED = "WRITER_LEASED"
|
||||
|
||||
|
||||
RISK_ORDER = {
|
||||
"low": 10,
|
||||
"medium": 20,
|
||||
"engineering": 25,
|
||||
"high": 30,
|
||||
"critical": 40,
|
||||
}
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OwnerAuthorizationPolicy:
|
||||
"""Long-lived business authorization, separate from short-lived credentials."""
|
||||
|
||||
authorization_id: str
|
||||
subject: str
|
||||
repository: str
|
||||
allowed_branch_prefixes: tuple[str, ...]
|
||||
allowed_capabilities: frozenset[str]
|
||||
risk_ceiling: str
|
||||
trust_domain: str
|
||||
revocation_epoch: int
|
||||
not_before: float
|
||||
expires_at: float | None = None
|
||||
revoked: bool = False
|
||||
allowed_operations: frozenset[str] = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"heartbeat",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"job_reconcile",
|
||||
"writer_lease",
|
||||
"governed_commit",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"epoch_reissue",
|
||||
"successor_recovery",
|
||||
}
|
||||
)
|
||||
|
||||
def validate(self, request: "GovernedWriterRequest", *, now: float | None = None) -> None:
|
||||
now = time.time() if now is None else float(now)
|
||||
if self.revoked:
|
||||
raise AuthorizationDenied("owner authorization revoked")
|
||||
if now < self.not_before:
|
||||
raise AuthorizationDenied("owner authorization not yet valid")
|
||||
if self.expires_at is not None and now >= self.expires_at:
|
||||
raise AuthorizationDenied("owner authorization expired")
|
||||
if request.subject != self.subject:
|
||||
raise AuthorizationDenied("subject mismatch")
|
||||
if request.repository != self.repository:
|
||||
raise AuthorizationDenied("repository outside authorization")
|
||||
if not any(request.target_branch.startswith(p) for p in self.allowed_branch_prefixes):
|
||||
raise AuthorizationDenied("branch outside authorization")
|
||||
if not request.required_capabilities.issubset(self.allowed_capabilities):
|
||||
missing = sorted(request.required_capabilities - self.allowed_capabilities)
|
||||
raise AuthorizationDenied("capability outside authorization: " + ",".join(missing))
|
||||
ceiling = RISK_ORDER.get(self.risk_ceiling.lower())
|
||||
requested = RISK_ORDER.get(request.risk.lower())
|
||||
if ceiling is None or requested is None:
|
||||
raise AuthorizationDenied("unknown risk level")
|
||||
if requested > ceiling:
|
||||
raise AuthorizationDenied("risk exceeds authorization ceiling")
|
||||
|
||||
@property
|
||||
def policy_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"authorization_id": self.authorization_id,
|
||||
"subject": self.subject,
|
||||
"repository": self.repository,
|
||||
"allowed_branch_prefixes": list(self.allowed_branch_prefixes),
|
||||
"allowed_capabilities": sorted(self.allowed_capabilities),
|
||||
"risk_ceiling": self.risk_ceiling,
|
||||
"trust_domain": self.trust_domain,
|
||||
"revocation_epoch": self.revocation_epoch,
|
||||
"not_before": self.not_before,
|
||||
"expires_at": self.expires_at,
|
||||
"allowed_operations": sorted(self.allowed_operations),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterRequest:
|
||||
subject: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
required_capabilities: frozenset[str]
|
||||
file_allowlist: tuple[str, ...]
|
||||
plan_digest: str
|
||||
manifest_id: str
|
||||
runtime_fingerprint: str
|
||||
process_start_identity: str
|
||||
|
||||
@property
|
||||
def scope_fingerprint(self) -> str:
|
||||
return _digest(
|
||||
{
|
||||
"subject": self.subject,
|
||||
"task_id": self.task_id,
|
||||
"repository": self.repository,
|
||||
"target_branch": self.target_branch,
|
||||
"risk": self.risk,
|
||||
"generation": self.generation,
|
||||
"base_oid": self.base_oid,
|
||||
"required_capabilities": sorted(self.required_capabilities),
|
||||
"file_allowlist": list(self.file_allowlist),
|
||||
"plan_digest": self.plan_digest,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriteReadiness:
|
||||
"""Unambiguous write-path semantics.
|
||||
|
||||
`legacy_global_writes_enabled=False` is a required safety invariant, not a
|
||||
blocker. `governed_write_path_open` is derived from the scoped writer
|
||||
prerequisites below and is intentionally independent from any ambiguous
|
||||
legacy `writes_enabled` field.
|
||||
"""
|
||||
|
||||
legacy_global_writes_enabled: bool
|
||||
task_principal_writes_enabled: bool
|
||||
task_principal_verifier_ready: bool
|
||||
governed_write_blockers: tuple[str, ...] = ()
|
||||
|
||||
@property
|
||||
def blockers(self) -> tuple[str, ...]:
|
||||
blockers = list(self.governed_write_blockers)
|
||||
if self.legacy_global_writes_enabled:
|
||||
blockers.append("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED")
|
||||
if not self.task_principal_writes_enabled:
|
||||
blockers.append("TASK_PRINCIPAL_WRITES_DISABLED")
|
||||
if not self.task_principal_verifier_ready:
|
||||
blockers.append("TASK_PRINCIPAL_VERIFIER_NOT_READY")
|
||||
return tuple(dict.fromkeys(blockers))
|
||||
|
||||
@property
|
||||
def governed_write_path_open(self) -> bool:
|
||||
return not self.blockers
|
||||
|
||||
@classmethod
|
||||
def from_runtime_status(cls, status: Mapping[str, Any]) -> "GovernedWriteReadiness":
|
||||
# Deliberately ignore ambiguous `writes_enabled` /
|
||||
# `AIOS_GITOPS_WRITES_ENABLED`; those describe legacy/global surfaces
|
||||
# in older control planes and must never be used as the sole governed
|
||||
# writer readiness signal.
|
||||
readiness = status.get("task_principal_readiness") or {}
|
||||
blockers = status.get("governed_write_blockers")
|
||||
if blockers is None:
|
||||
blockers = status.get("blockers") or ()
|
||||
return cls(
|
||||
legacy_global_writes_enabled=bool(
|
||||
status.get(
|
||||
"legacy_global_writes_enabled",
|
||||
status.get("legacy_writes_enabled", False),
|
||||
)
|
||||
),
|
||||
task_principal_writes_enabled=bool(
|
||||
status.get("task_principal_writes_enabled", False)
|
||||
),
|
||||
task_principal_verifier_ready=bool(
|
||||
readiness.get("ready", status.get("task_principal_verifier_ready", False))
|
||||
),
|
||||
governed_write_blockers=tuple(str(x) for x in blockers),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EpochStatus:
|
||||
epoch_id: str
|
||||
scope_fingerprint: str
|
||||
expires_at: float
|
||||
policy_digest: str
|
||||
|
||||
def is_valid(self, *, now: float, minimum_remaining_ttl: float) -> bool:
|
||||
return (self.expires_at - now) >= minimum_remaining_ttl
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskState:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
claimed_by: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaimReceipt:
|
||||
claim_id: str
|
||||
task_id: str
|
||||
actor: str
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskLeaseReceipt:
|
||||
task_lease_id: str
|
||||
task_id: str
|
||||
token_ref: str
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskPrincipalReceipt:
|
||||
principal_id: str
|
||||
task_id: str
|
||||
capabilities: frozenset[str]
|
||||
expires_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JobBindingReceipt:
|
||||
job_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchReadback:
|
||||
exists: bool
|
||||
head_oid: str | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WriterLeaseReceipt:
|
||||
writer_lease_id: str
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float
|
||||
fencing: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedWriterSession:
|
||||
state: GovernedWriterState
|
||||
authorization_id: str
|
||||
policy_fingerprint: str
|
||||
scope_fingerprint: str
|
||||
epoch_id: str
|
||||
task_id: str
|
||||
claim_id: str
|
||||
task_lease_id: str
|
||||
principal_id: str
|
||||
job_id: str
|
||||
writer_lease_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
expires_at: float
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
result = asdict(self)
|
||||
result["state"] = self.state.value
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecoveryAuthorization:
|
||||
"""Pre-approved signed recovery/successor authorization reference."""
|
||||
|
||||
authorization_ref: str
|
||||
task_id: str
|
||||
action: str # TERMINAL_RECOVERY | SUCCESSOR
|
||||
expires_at: float
|
||||
|
||||
|
||||
class TrustedSignerAdapter(Protocol):
|
||||
def reissue_epoch(
|
||||
self,
|
||||
*,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
request: GovernedWriterRequest,
|
||||
prior_epoch: EpochStatus | None,
|
||||
minimum_remaining_ttl: float,
|
||||
idempotency_key: str,
|
||||
) -> EpochStatus: ...
|
||||
|
||||
|
||||
class ExecutionPlane(Protocol):
|
||||
def read_task(self, task_id: str) -> TaskState: ...
|
||||
def apply_recovery(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
authorization: RecoveryAuthorization,
|
||||
idempotency_key: str,
|
||||
) -> TaskState: ...
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task: TaskState,
|
||||
actor: str,
|
||||
idempotency_key: str,
|
||||
) -> ClaimReceipt: ...
|
||||
def heartbeat(self, *, claim: ClaimReceipt) -> None: ...
|
||||
def issue_task_lease(
|
||||
self,
|
||||
*,
|
||||
claim: ClaimReceipt,
|
||||
idempotency_key: str,
|
||||
) -> TaskLeaseReceipt: ...
|
||||
|
||||
|
||||
class TaskPrincipalIssuer(Protocol):
|
||||
def issue(
|
||||
self,
|
||||
*,
|
||||
lease: TaskLeaseReceipt,
|
||||
requested_capabilities: frozenset[str],
|
||||
idempotency_key: str,
|
||||
) -> TaskPrincipalReceipt: ...
|
||||
|
||||
|
||||
class JobAuthority(Protocol):
|
||||
def read_or_reconcile(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
principal: TaskPrincipalReceipt,
|
||||
idempotency_key: str,
|
||||
) -> JobBindingReceipt: ...
|
||||
|
||||
|
||||
class GitAuthority(Protocol):
|
||||
def read_branch(self, *, repository: str, target_branch: str) -> BranchReadback: ...
|
||||
|
||||
|
||||
class WriterLeaseAuthority(Protocol):
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job: JobBindingReceipt,
|
||||
principal: TaskPrincipalReceipt,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
branch_mode: str,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
generation: int,
|
||||
idempotency_key: str,
|
||||
) -> WriterLeaseReceipt: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedOwnerAuthorization:
|
||||
policy: OwnerAuthorizationPolicy
|
||||
authorization_ref: str
|
||||
verification_receipt: str
|
||||
verified_at: float
|
||||
|
||||
|
||||
class OwnerAuthorizationAuthority(Protocol):
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
authorization_ref: str,
|
||||
request: GovernedWriterRequest,
|
||||
) -> VerifiedOwnerAuthorization: ...
|
||||
|
||||
|
||||
@dataclass
|
||||
class GovernedWriterAdapters:
|
||||
signer: TrustedSignerAdapter
|
||||
execution: ExecutionPlane
|
||||
principal_issuer: TaskPrincipalIssuer
|
||||
jobs: JobAuthority
|
||||
git: GitAuthority
|
||||
writer_lease: WriterLeaseAuthority
|
||||
owner_authority: OwnerAuthorizationAuthority | None = None
|
||||
|
||||
|
||||
class GovernedWriterAutomation:
|
||||
"""Owner-policy-driven acquisition of a scoped governed writer capability.
|
||||
|
||||
This is the mechanism that removes repeated manual intervention. Long-lived
|
||||
OwnerAuthorizationPolicy establishes allowed scope. Every concrete effect is
|
||||
still protected by fresh runtime manifest, short-lived TaskLease,
|
||||
TaskPrincipal, WriterLease, CAS and idempotency.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
manifest_store: ManifestStore,
|
||||
action_ledger: ActionLedger,
|
||||
decision_engine: AutonomousDecisionEngine | None = None,
|
||||
):
|
||||
self.manifest_store = manifest_store
|
||||
self.action_ledger = action_ledger
|
||||
self.decision_engine = decision_engine or AutonomousDecisionEngine()
|
||||
|
||||
def _begin_action(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
suffix: str,
|
||||
kind: ActionKind,
|
||||
target: str,
|
||||
precondition: Mapping[str, Any],
|
||||
):
|
||||
return self.action_ledger.begin(
|
||||
idempotency_key=f"writer:{request.scope_fingerprint}:{suffix}",
|
||||
manifest_id=request.manifest_id,
|
||||
phase="GOVERNED_WRITER",
|
||||
kind=kind,
|
||||
target=target,
|
||||
precondition_hash=_digest(precondition),
|
||||
)
|
||||
|
||||
def _ensure_epoch(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
minimum_remaining_ttl: float,
|
||||
) -> EpochStatus:
|
||||
if (
|
||||
epoch is not None
|
||||
and epoch.scope_fingerprint == request.scope_fingerprint
|
||||
and epoch.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl)
|
||||
):
|
||||
return epoch
|
||||
|
||||
# Changed scope may not silently inherit an old Owner grant.
|
||||
if epoch is not None and epoch.scope_fingerprint != request.scope_fingerprint:
|
||||
raise OwnerActionRequired("epoch scope changed; new Owner authorization required")
|
||||
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="epoch",
|
||||
kind=ActionKind.EPOCH_ISSUANCE,
|
||||
target="trusted-grant-signer-adapter",
|
||||
precondition={
|
||||
"owner_policy": owner_policy.policy_fingerprint,
|
||||
"scope": request.scope_fingerprint,
|
||||
"prior_epoch": None if epoch is None else epoch.epoch_id,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("epoch issuance effect unknown; reconcile required")
|
||||
if ticket.reused and ticket.state.value == "SUCCEEDED":
|
||||
# The ledger proves the action succeeded, but the concrete receipt
|
||||
# must be obtained from the signer/readback, not fabricated locally.
|
||||
raise GovernedWriterUnavailable("epoch already issued; signer readback required")
|
||||
|
||||
try:
|
||||
fresh = adapters.signer.reissue_epoch(
|
||||
owner_policy=owner_policy,
|
||||
request=request,
|
||||
prior_epoch=epoch,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if fresh.scope_fingerprint != request.scope_fingerprint:
|
||||
raise GovernedWriterUnavailable("signer returned mismatched scope")
|
||||
if not fresh.is_valid(now=now, minimum_remaining_ttl=minimum_remaining_ttl):
|
||||
raise GovernedWriterUnavailable("signer returned insufficient TTL")
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"epoch_id": fresh.epoch_id,
|
||||
"expires_at": fresh.expires_at,
|
||||
"scope_fingerprint": fresh.scope_fingerprint,
|
||||
},
|
||||
)
|
||||
return fresh
|
||||
except OwnerActionRequired:
|
||||
self.action_ledger.mark_failed(ticket.action_id, result={"owner_action": True})
|
||||
raise
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
def _ensure_claimable_task(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
recovery_authorization: RecoveryAuthorization | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
now: float,
|
||||
) -> TaskState:
|
||||
task = adapters.execution.read_task(request.task_id)
|
||||
status = task.status.upper()
|
||||
if status == "QUEUED":
|
||||
return task
|
||||
if status in {"CLAIMED", "RUNNING"}:
|
||||
return task
|
||||
if status == "EXPIRED":
|
||||
if recovery_authorization is None:
|
||||
raise OwnerActionRequired(
|
||||
"terminal task expired; owner-signed recovery/successor authorization required"
|
||||
)
|
||||
if recovery_authorization.task_id != task.task_id:
|
||||
raise AuthorizationDenied("recovery authorization task mismatch")
|
||||
if recovery_authorization.expires_at <= now:
|
||||
raise AuthorizationDenied("recovery authorization expired")
|
||||
if recovery_authorization.action not in {"TERMINAL_RECOVERY", "SUCCESSOR"}:
|
||||
raise AuthorizationDenied("unsupported recovery action")
|
||||
ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="task-recovery",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="agent-task-terminal-recovery",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"revision": task.revision,
|
||||
"status": task.status,
|
||||
"authorization_ref": recovery_authorization.authorization_ref,
|
||||
"action": recovery_authorization.action,
|
||||
},
|
||||
)
|
||||
if ticket.reused and ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("task recovery effect unknown; reconcile required")
|
||||
try:
|
||||
recovered = adapters.execution.apply_recovery(
|
||||
task=task,
|
||||
authorization=recovery_authorization,
|
||||
idempotency_key=ticket.idempotency_key,
|
||||
)
|
||||
if recovered.status.upper() != "QUEUED":
|
||||
raise GovernedWriterUnavailable(
|
||||
"recovery did not return claimable QUEUED task"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
ticket.action_id,
|
||||
result={
|
||||
"task_id": recovered.task_id,
|
||||
"status": recovered.status,
|
||||
"revision": recovered.revision,
|
||||
},
|
||||
)
|
||||
return recovered
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
raise GovernedWriterUnavailable(f"task status not claimable: {task.status}")
|
||||
|
||||
def acquire_verified(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
authorization_ref: str,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
"""Production entry: verify Owner authorization before any effect.
|
||||
|
||||
The field composition root MUST use this method. `acquire()` is an
|
||||
internal/test hook for already-verified policy objects.
|
||||
"""
|
||||
if adapters.owner_authority is None:
|
||||
raise AuthorizationDenied("OwnerAuthorizationAuthority is not configured")
|
||||
verified = adapters.owner_authority.verify(
|
||||
authorization_ref=authorization_ref,
|
||||
request=request,
|
||||
)
|
||||
if verified.authorization_ref != authorization_ref:
|
||||
raise AuthorizationDenied("owner authorization reference mismatch")
|
||||
if not verified.verification_receipt:
|
||||
raise AuthorizationDenied("owner authorization verification receipt missing")
|
||||
return self.acquire(
|
||||
request=request,
|
||||
owner_policy=verified.policy,
|
||||
readiness=readiness,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
recovery_authorization=recovery_authorization,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
readiness: GovernedWriteReadiness,
|
||||
epoch: EpochStatus | None,
|
||||
adapters: GovernedWriterAdapters,
|
||||
recovery_authorization: RecoveryAuthorization | None = None,
|
||||
now: float | None = None,
|
||||
minimum_remaining_ttl: float = 1800.0,
|
||||
) -> GovernedWriterSession:
|
||||
now = time.time() if now is None else float(now)
|
||||
|
||||
# Gate 1: authoritative frozen runtime.
|
||||
self.manifest_store.assert_current(
|
||||
manifest_id=request.manifest_id,
|
||||
runtime_fingerprint=request.runtime_fingerprint,
|
||||
process_start_identity=request.process_start_identity,
|
||||
)
|
||||
|
||||
# Gate 2: long-lived Owner policy.
|
||||
owner_policy.validate(request, now=now)
|
||||
|
||||
# Gate 3: scoped writer readiness, deliberately independent from legacy
|
||||
# global-write switches.
|
||||
if not readiness.governed_write_path_open:
|
||||
raise GovernedWriterUnavailable(
|
||||
"governed writer not ready: " + ",".join(readiness.blockers)
|
||||
)
|
||||
|
||||
# Gate 4: JIT delegation epoch. If unchanged scope is authorized, the
|
||||
# Agent may use the trusted signer adapter autonomously.
|
||||
epoch = self._ensure_epoch(
|
||||
request=request,
|
||||
owner_policy=owner_policy,
|
||||
epoch=epoch,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
minimum_remaining_ttl=minimum_remaining_ttl,
|
||||
)
|
||||
|
||||
# Gate 5: task must be claimable, with Owner recovery only when the
|
||||
# lifecycle contract explicitly requires a signed terminal recovery.
|
||||
task = self._ensure_claimable_task(
|
||||
request=request,
|
||||
recovery_authorization=recovery_authorization,
|
||||
adapters=adapters,
|
||||
now=now,
|
||||
)
|
||||
|
||||
# Claim is idempotent per scope/task revision.
|
||||
claim_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"claim-r{task.revision}",
|
||||
kind=ActionKind.CLAIM,
|
||||
target=f"task:{task.task_id}",
|
||||
precondition={"task_id": task.task_id, "revision": task.revision, "status": task.status},
|
||||
)
|
||||
if claim_ticket.reused and claim_ticket.requires_reconcile:
|
||||
raise GovernedWriterUnavailable("claim effect unknown; reconcile required")
|
||||
if task.status.upper() in {"CLAIMED", "RUNNING"}:
|
||||
raise GovernedWriterUnavailable(
|
||||
"existing claim must be reconciled into a concrete claim receipt"
|
||||
)
|
||||
try:
|
||||
claim = adapters.execution.claim(
|
||||
task=task,
|
||||
actor=request.subject,
|
||||
idempotency_key=claim_ticket.idempotency_key,
|
||||
)
|
||||
adapters.execution.heartbeat(claim=claim)
|
||||
self.action_ledger.mark_succeeded(
|
||||
claim_ticket.action_id,
|
||||
result={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
claim_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
lease_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"tasklease-{claim.claim_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-lease",
|
||||
precondition={"claim_id": claim.claim_id, "fencing": claim.fencing},
|
||||
)
|
||||
try:
|
||||
task_lease = adapters.execution.issue_task_lease(
|
||||
claim=claim,
|
||||
idempotency_key=lease_ticket.idempotency_key,
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
lease_ticket.action_id,
|
||||
result={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"expires_at": task_lease.expires_at,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
lease_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
principal_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"principal-{task_lease.task_lease_id}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="task-principal-issuer",
|
||||
precondition={
|
||||
"task_lease_id": task_lease.task_lease_id,
|
||||
"capabilities": sorted(request.required_capabilities),
|
||||
},
|
||||
)
|
||||
try:
|
||||
principal = adapters.principal_issuer.issue(
|
||||
lease=task_lease,
|
||||
requested_capabilities=request.required_capabilities,
|
||||
idempotency_key=principal_ticket.idempotency_key,
|
||||
)
|
||||
if not request.required_capabilities.issubset(principal.capabilities):
|
||||
raise GovernedWriterUnavailable(
|
||||
"issued TaskPrincipal lacks required capabilities"
|
||||
)
|
||||
self.action_ledger.mark_succeeded(
|
||||
principal_ticket.action_id,
|
||||
result={"principal_id": principal.principal_id, "expires_at": principal.expires_at},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
principal_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
job_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix="job",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="gitops-job",
|
||||
precondition={
|
||||
"task_id": task.task_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"plan_digest": request.plan_digest,
|
||||
},
|
||||
)
|
||||
try:
|
||||
job = adapters.jobs.read_or_reconcile(
|
||||
task_id=task.task_id,
|
||||
principal=principal,
|
||||
idempotency_key=job_ticket.idempotency_key,
|
||||
)
|
||||
if (
|
||||
job.repository != request.repository
|
||||
or job.target_branch != request.target_branch
|
||||
or job.generation != request.generation
|
||||
):
|
||||
raise GovernedWriterUnavailable("job binding mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
job_ticket.action_id,
|
||||
result={"job_id": job.job_id},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
job_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
branch = adapters.git.read_branch(
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
)
|
||||
if branch.exists:
|
||||
if not branch.head_oid:
|
||||
raise GovernedWriterUnavailable("branch exists without readable HEAD")
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = branch.head_oid
|
||||
else:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
|
||||
writer_ticket = self._begin_action(
|
||||
request=request,
|
||||
suffix=f"writer-{job.job_id}-{expected_branch_oid}",
|
||||
kind=ActionKind.GENERIC_MUTATION,
|
||||
target="writer-lease",
|
||||
precondition={
|
||||
"job_id": job.job_id,
|
||||
"principal_id": principal.principal_id,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"generation": request.generation,
|
||||
},
|
||||
)
|
||||
try:
|
||||
writer = adapters.writer_lease.acquire(
|
||||
task_id=task.task_id,
|
||||
job=job,
|
||||
principal=principal,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
branch_mode=branch_mode,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=request.generation,
|
||||
idempotency_key=writer_ticket.idempotency_key,
|
||||
)
|
||||
if writer.expected_branch_oid != expected_branch_oid:
|
||||
raise GovernedWriterUnavailable("WriterLease CAS mismatch")
|
||||
self.action_ledger.mark_succeeded(
|
||||
writer_ticket.action_id,
|
||||
result={
|
||||
"writer_lease_id": writer.writer_lease_id,
|
||||
"expires_at": writer.expires_at,
|
||||
"expected_branch_oid": writer.expected_branch_oid,
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
self.action_ledger.mark_effect_unknown(
|
||||
writer_ticket.action_id,
|
||||
result={"error": f"{type(exc).__name__}:{exc}"},
|
||||
)
|
||||
raise
|
||||
|
||||
expires_at = min(
|
||||
epoch.expires_at,
|
||||
task_lease.expires_at,
|
||||
principal.expires_at,
|
||||
writer.expires_at,
|
||||
)
|
||||
return GovernedWriterSession(
|
||||
state=GovernedWriterState.WRITER_LEASED,
|
||||
authorization_id=owner_policy.authorization_id,
|
||||
policy_fingerprint=owner_policy.policy_fingerprint,
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
epoch_id=epoch.epoch_id,
|
||||
task_id=task.task_id,
|
||||
claim_id=claim.claim_id,
|
||||
task_lease_id=task_lease.task_lease_id,
|
||||
principal_id=principal.principal_id,
|
||||
job_id=job.job_id,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
@@ -0,0 +1,972 @@
|
||||
"""Live AIOS protocol adapters for governed-writer execution.
|
||||
|
||||
This module closes the gap between the generic governed-writer model and the
|
||||
live field contracts observed on the V2R2C3 control plane:
|
||||
|
||||
- agent-task-api :18940
|
||||
* POST /v1/agents/register
|
||||
* GET /v1/tasks/{task_id}
|
||||
* POST /v1/tasks/{task_id}/claim -> lease_token
|
||||
* POST /v1/tasks/{task_id}/heartbeat
|
||||
- task-principal-issuer :18946
|
||||
* POST /v1/task-principal/assertions
|
||||
* Authorization: TaskLease <lease-token>
|
||||
* one assertion per concrete operation + bridge_request
|
||||
- bridge :18892
|
||||
* GET /v2/runtime
|
||||
* POST /v2/writer-lease
|
||||
* POST /v2/commit-files
|
||||
* Authorization: TaskPrincipal <operation-assertion>
|
||||
|
||||
WriterLease and commit-files share one continuation idempotency key.
|
||||
|
||||
The module never prints or returns the agent token, lease token or TaskPrincipal
|
||||
assertion. Secret values are kept only in an in-memory handle store.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriteReadiness,
|
||||
GovernedWriterRequest,
|
||||
OwnerAuthorizationPolicy,
|
||||
)
|
||||
|
||||
|
||||
class LiveProtocolError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolAuthError(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolConflict(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
class LiveProtocolUnavailable(LiveProtocolError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HttpResult:
|
||||
status: int
|
||||
body: Mapping[str, Any]
|
||||
|
||||
|
||||
class JsonHttpTransport:
|
||||
"""Small stdlib JSON transport. Authorization values are never logged."""
|
||||
|
||||
def request(
|
||||
self,
|
||||
method: str,
|
||||
url: str,
|
||||
*,
|
||||
body: Mapping[str, Any] | None = None,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
timeout: float = 20.0,
|
||||
) -> HttpResult:
|
||||
payload = None
|
||||
req_headers = {"Accept": "application/json"}
|
||||
if body is not None:
|
||||
payload = json.dumps(
|
||||
dict(body),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
if headers:
|
||||
req_headers.update({str(k): str(v) for k, v in headers.items()})
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=payload,
|
||||
headers=req_headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
raw = resp.read()
|
||||
status = int(resp.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read()
|
||||
status = int(exc.code)
|
||||
except (OSError, urllib.error.URLError) as exc:
|
||||
raise LiveProtocolUnavailable(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: transport unavailable"
|
||||
) from exc
|
||||
if not raw:
|
||||
obj: Any = {}
|
||||
else:
|
||||
try:
|
||||
obj = json.loads(raw.decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: non-JSON response"
|
||||
) from exc
|
||||
if not isinstance(obj, Mapping):
|
||||
raise LiveProtocolError(
|
||||
f"{method.upper()} {urllib.parse.urlsplit(url).path}: response must be object"
|
||||
)
|
||||
return HttpResult(status=status, body=dict(obj))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveProtocolConfig:
|
||||
bridge_url: str = "http://127.0.0.1:18892"
|
||||
agent_task_url: str = "http://127.0.0.1:18940"
|
||||
principal_issuer_url: str = "http://127.0.0.1:18946"
|
||||
control_token_file: str = (
|
||||
"/run/credentials/aios-agent-task-api.service/control_token"
|
||||
)
|
||||
authority_git_dir: str = "/data/aios-gitops/repo.git"
|
||||
issue_schema: str = "aios.task-principal-issue.v1"
|
||||
agent_type: str = "aios.p0-task-admission-worker"
|
||||
agent_version: str = "governed-writer-live-v1"
|
||||
lease_seconds: int = 900
|
||||
assertion_ttl_seconds: int = 60
|
||||
minimum_remaining_window_seconds: int = 240
|
||||
|
||||
def normalized(self) -> "LiveProtocolConfig":
|
||||
return LiveProtocolConfig(
|
||||
bridge_url=self.bridge_url.rstrip("/"),
|
||||
agent_task_url=self.agent_task_url.rstrip("/"),
|
||||
principal_issuer_url=self.principal_issuer_url.rstrip("/"),
|
||||
control_token_file=self.control_token_file,
|
||||
authority_git_dir=self.authority_git_dir,
|
||||
issue_schema=self.issue_schema,
|
||||
agent_type=self.agent_type,
|
||||
agent_version=self.agent_version,
|
||||
lease_seconds=int(self.lease_seconds),
|
||||
assertion_ttl_seconds=int(self.assertion_ttl_seconds),
|
||||
minimum_remaining_window_seconds=int(
|
||||
self.minimum_remaining_window_seconds
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SecretHandleStore:
|
||||
"""Process-local secret container.
|
||||
|
||||
Public receipts contain handles only; secret values are not serialized.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
|
||||
def put(self, kind: str, value: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise LiveProtocolAuthError(f"{kind} secret missing")
|
||||
handle = f"{kind}:{secrets.token_hex(12)}"
|
||||
self._values[handle] = value
|
||||
return handle
|
||||
|
||||
def get(self, handle: str) -> str:
|
||||
try:
|
||||
return self._values[handle]
|
||||
except KeyError as exc:
|
||||
raise LiveProtocolAuthError("secret handle not available") from exc
|
||||
|
||||
def drop(self, handle: str) -> None:
|
||||
self._values.pop(handle, None)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveTask:
|
||||
task_id: str
|
||||
status: str
|
||||
revision: int
|
||||
deadline_epoch: float | None
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveClaim:
|
||||
task_id: str
|
||||
claim_ref: str
|
||||
server_claim_id: str | None
|
||||
lease_handle: str
|
||||
fencing_generation: int | None
|
||||
lease_expires_at: float | None
|
||||
task_revision: int | None
|
||||
public_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterLease:
|
||||
# /v2/writer-lease is a validated binding projection. The durable branch
|
||||
# lease is created only by the continuation executor during commit.
|
||||
writer_lease_id: str | None
|
||||
task_id: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
generation: int
|
||||
expires_at: float | None
|
||||
continuation_key: str
|
||||
raw: Mapping[str, Any]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveWriterSession:
|
||||
authorization_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
claim_ref: str
|
||||
job_id: str
|
||||
branch_mode: str
|
||||
expected_branch_oid: str
|
||||
writer_lease_id: str | None
|
||||
continuation_key: str
|
||||
principal_issue_schema: str
|
||||
agent_token_handle: str = field(repr=False)
|
||||
lease_handle: str = field(repr=False)
|
||||
task_lease_receipt: Mapping[str, Any] = field(default_factory=dict)
|
||||
writer_lease_binding: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def public_dict(self) -> dict[str, Any]:
|
||||
public = {
|
||||
"authorization_id": self.authorization_id,
|
||||
"task_id": self.task_id,
|
||||
"agent_id": self.agent_id,
|
||||
"claim_ref": self.claim_ref,
|
||||
"job_id": self.job_id,
|
||||
"branch_mode": self.branch_mode,
|
||||
"expected_branch_oid": self.expected_branch_oid,
|
||||
"continuation_key": self.continuation_key,
|
||||
"principal_issue_schema": self.principal_issue_schema,
|
||||
"writer_lease_binding": dict(self.writer_lease_binding),
|
||||
}
|
||||
return public
|
||||
|
||||
|
||||
def _parse_epoch(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return None
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
pass
|
||||
# ISO-8601 minimal support.
|
||||
import datetime as dt
|
||||
|
||||
try:
|
||||
return dt.datetime.fromisoformat(text.replace("Z", "+00:00")).timestamp()
|
||||
except Exception:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def _safe_ref_from_token(token: str, prefix: str) -> str:
|
||||
return f"{prefix}:sha256:{hashlib.sha256(token.encode('utf-8')).hexdigest()[:24]}"
|
||||
|
||||
|
||||
class LiveAgentTaskClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store or SecretHandleStore()
|
||||
|
||||
def _control_token(self) -> str:
|
||||
path = Path(self.config.control_token_file)
|
||||
try:
|
||||
token = path.read_text(encoding="utf-8").strip()
|
||||
except OSError as exc:
|
||||
raise LiveProtocolAuthError(
|
||||
"agent-task control token file unavailable"
|
||||
) from exc
|
||||
if len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent-task control token invalid")
|
||||
return token
|
||||
|
||||
def _control_headers(self) -> dict[str, str]:
|
||||
return {"Authorization": "Bearer " + self._control_token()}
|
||||
|
||||
def read_task(self, task_id: str) -> LiveTask:
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
result = self.transport.request(
|
||||
"GET",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}",
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 404:
|
||||
raise LiveProtocolConflict("task not found")
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task read failed HTTP {result.status}")
|
||||
raw = result.body
|
||||
task_obj = raw.get("task") if isinstance(raw.get("task"), Mapping) else raw
|
||||
status = str(task_obj.get("status") or "")
|
||||
if not status:
|
||||
raise LiveProtocolError("task readback missing status")
|
||||
revision = int(task_obj.get("revision") or 0)
|
||||
return LiveTask(
|
||||
task_id=str(task_obj.get("task_id") or task_id),
|
||||
status=status,
|
||||
revision=revision,
|
||||
deadline_epoch=_parse_epoch(task_obj.get("deadline")),
|
||||
raw=dict(task_obj),
|
||||
)
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
capabilities: Sequence[str],
|
||||
route: str,
|
||||
agent_id: str | None = None,
|
||||
) -> tuple[str, str]:
|
||||
if agent_id is None:
|
||||
agent_id = (
|
||||
"governed-writer-"
|
||||
+ hashlib.sha256(
|
||||
(task_id + "\0" + str(time.time_ns())).encode("utf-8")
|
||||
).hexdigest()[:20]
|
||||
)
|
||||
body = {
|
||||
"agent_id": agent_id,
|
||||
"agent_type": self.config.agent_type,
|
||||
"agent_version": self.config.agent_version,
|
||||
"capabilities": list(capabilities),
|
||||
"metadata": {"task_id": task_id, "route": route},
|
||||
}
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/agents/register",
|
||||
body=body,
|
||||
headers=self._control_headers(),
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"agent register failed HTTP {result.status}")
|
||||
token = result.body.get("agent_token")
|
||||
if not isinstance(token, str) or len(token) < 8:
|
||||
raise LiveProtocolAuthError("agent register returned no usable token")
|
||||
return agent_id, self.secrets.put("agent-token", token)
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveClaim:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
token = self.secrets.get(agent_token_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/claim",
|
||||
body={"lease_seconds": lease_seconds},
|
||||
headers={"Authorization": "Bearer " + token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task not claimable")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"claim failed HTTP {result.status}")
|
||||
if result.body.get("status") != "CLAIMED":
|
||||
raise LiveProtocolError("claim response not CLAIMED")
|
||||
lease_token = result.body.get("lease_token")
|
||||
if not isinstance(lease_token, str) or len(lease_token) < 8:
|
||||
raise LiveProtocolAuthError("claim returned no lease token")
|
||||
server_claim_id = result.body.get("claim_id")
|
||||
lease_id = result.body.get("lease_id")
|
||||
public_ref = None
|
||||
for candidate in (server_claim_id, lease_id):
|
||||
if isinstance(candidate, str) and candidate:
|
||||
public_ref = candidate
|
||||
break
|
||||
if public_ref is None:
|
||||
public_ref = _safe_ref_from_token(lease_token, "claim")
|
||||
lease_handle = self.secrets.put("task-lease", lease_token)
|
||||
fencing = result.body.get("fencing_generation")
|
||||
return LiveClaim(
|
||||
task_id=task_id,
|
||||
claim_ref=public_ref,
|
||||
server_claim_id=(
|
||||
str(server_claim_id)
|
||||
if isinstance(server_claim_id, str) and server_claim_id
|
||||
else None
|
||||
),
|
||||
lease_handle=lease_handle,
|
||||
fencing_generation=int(fencing) if fencing is not None else None,
|
||||
lease_expires_at=_parse_epoch(result.body.get("lease_expires_at")),
|
||||
task_revision=(
|
||||
int(result.body["receipt_revision"])
|
||||
if result.body.get("receipt_revision") is not None
|
||||
else None
|
||||
),
|
||||
public_receipt={k: result.body[k] for k in (
|
||||
"task_id", "status", "claim_id", "lease_id", "fencing_generation",
|
||||
"receipt_revision", "lease_expires_at", "attempt", "revision",
|
||||
) if k in result.body},
|
||||
)
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
*,
|
||||
claim: LiveClaim,
|
||||
agent_token_handle: str,
|
||||
phase: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> LiveTask:
|
||||
lease_seconds = int(lease_seconds or self.config.lease_seconds)
|
||||
quoted = urllib.parse.quote(claim.task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(claim.lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/heartbeat",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"lease_seconds": lease_seconds,
|
||||
"progress": {"phase": phase, "status": "running"},
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"heartbeat failed HTTP {result.status}")
|
||||
if result.body.get("status") not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolError("heartbeat did not yield RUNNING/CLAIMED")
|
||||
task = self.read_task(claim.task_id)
|
||||
if task.status not in ("RUNNING", "CLAIMED"):
|
||||
raise LiveProtocolConflict(
|
||||
f"authoritative task state after heartbeat is {task.status}"
|
||||
)
|
||||
return task
|
||||
|
||||
def submit_result(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
agent_token_handle: str,
|
||||
lease_handle: str,
|
||||
idempotency_key: str,
|
||||
result_value: Mapping[str, Any],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(idempotency_key, str) or not 1 <= len(idempotency_key) <= 160:
|
||||
raise LiveProtocolError("task result idempotency key invalid")
|
||||
if not isinstance(result_value, Mapping):
|
||||
raise LiveProtocolError("task result mapping required")
|
||||
quoted = urllib.parse.quote(task_id, safe="")
|
||||
agent_token = self.secrets.get(agent_token_handle)
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.agent_task_url}/v1/tasks/{quoted}/result",
|
||||
body={
|
||||
"lease_token": lease_token,
|
||||
"idempotency_key": idempotency_key,
|
||||
"result": dict(result_value),
|
||||
},
|
||||
headers={"Authorization": "Bearer " + agent_token},
|
||||
timeout=15,
|
||||
)
|
||||
if result.status == 409:
|
||||
raise LiveProtocolConflict(
|
||||
str(result.body.get("error") or "task result conflict")
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"task result HTTP {result.status}")
|
||||
if (
|
||||
result.body.get("task_id") != task_id
|
||||
or result.body.get("status") != "VERIFYING"
|
||||
or result.body.get("claimed_by") != agent_id
|
||||
or result.body.get("result") != dict(result_value)
|
||||
):
|
||||
raise LiveProtocolConflict("task result readback mismatch")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveTaskPrincipalClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
secrets_store: SecretHandleStore,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secrets = secrets_store
|
||||
self.public_receipts: list[dict[str, Any]] = []
|
||||
|
||||
def issue_assertion(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
lease_handle: str,
|
||||
operation: str,
|
||||
bridge_request: Mapping[str, Any],
|
||||
request_id: str,
|
||||
) -> str:
|
||||
lease_token = self.secrets.get(lease_handle)
|
||||
body = {
|
||||
"schema": self.config.issue_schema,
|
||||
"request_id": request_id,
|
||||
"task_id": task_id,
|
||||
"operation": operation,
|
||||
"bridge_request": dict(bridge_request),
|
||||
"requested_ttl_seconds": self.config.assertion_ttl_seconds,
|
||||
}
|
||||
last: HttpResult | None = None
|
||||
for attempt in range(2):
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.principal_issuer_url}/v1/task-principal/assertions",
|
||||
body=body,
|
||||
headers={"Authorization": "TaskLease " + lease_token},
|
||||
timeout=20,
|
||||
)
|
||||
last = result
|
||||
assertion = result.body.get("assertion")
|
||||
if result.status in (200, 201) and isinstance(assertion, str) and assertion:
|
||||
self.public_receipts.append({
|
||||
"task_id": task_id, "operation": operation, "request_id": request_id,
|
||||
"assertion_sha256": hashlib.sha256(assertion.encode()).hexdigest(),
|
||||
"issuer_response": {k: result.body[k] for k in ("jti", "expires_at", "replayed") if k in result.body},
|
||||
})
|
||||
return assertion
|
||||
if not (
|
||||
attempt == 0
|
||||
and result.status == 503
|
||||
and result.body.get("error") == "TASK_PRINCIPAL_ISSUER_UNAVAILABLE"
|
||||
):
|
||||
break
|
||||
time.sleep(1)
|
||||
status = 0 if last is None else last.status
|
||||
raise LiveProtocolError(
|
||||
f"TaskPrincipal assertion issue failed operation={operation} HTTP {status}"
|
||||
)
|
||||
|
||||
|
||||
class LiveGitReader:
|
||||
def __init__(self, git_dir: str) -> None:
|
||||
self.git_dir = str(git_dir)
|
||||
|
||||
def branch_head(self, branch: str) -> str | None:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "rev-parse", f"refs/heads/{branch}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
head = proc.stdout.strip()
|
||||
if len(head) != 40 or any(c not in "0123456789abcdef" for c in head):
|
||||
raise LiveProtocolError("git branch HEAD is not a full OID")
|
||||
return head
|
||||
|
||||
def file_sha256(self, oid: str, path: str) -> str:
|
||||
proc = subprocess.run(
|
||||
["git", "--git-dir", self.git_dir, "show", f"{oid}:{path}"],
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=20,
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise LiveProtocolError(f"git readback failed for {path}")
|
||||
return hashlib.sha256(proc.stdout).hexdigest()
|
||||
|
||||
|
||||
class LiveBridgeClient:
|
||||
def __init__(
|
||||
self,
|
||||
config: LiveProtocolConfig,
|
||||
*,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
principal_client: LiveTaskPrincipalClient,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.principal_client = principal_client
|
||||
|
||||
def runtime_status(self) -> Mapping[str, Any]:
|
||||
result = self.transport.request(
|
||||
"GET", f"{self.config.bridge_url}/v2/runtime", timeout=10
|
||||
)
|
||||
if result.status != 200:
|
||||
raise LiveProtocolUnavailable(f"bridge runtime HTTP {result.status}")
|
||||
return result.body
|
||||
|
||||
def acquire_writer_lease(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
job_id: str,
|
||||
lease_handle: str,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
base_oid: str,
|
||||
expected_branch_oid: str,
|
||||
branch_mode: str,
|
||||
continuation_key: str,
|
||||
) -> LiveWriterLease:
|
||||
req = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"job_id": job_id,
|
||||
"idempotency_key": continuation_key,
|
||||
"repository": repository,
|
||||
"branch_mode": branch_mode,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=task_id,
|
||||
lease_handle=lease_handle,
|
||||
operation="acquireWriterLease",
|
||||
bridge_request=req,
|
||||
request_id="writer-lease-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/writer-lease",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=30,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"writer lease HTTP {result.status}")
|
||||
body = result.body
|
||||
expected_binding = {
|
||||
"schema": "aios.task-writer-lease.v2",
|
||||
"task_id": task_id,
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"expected_branch_oid": expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"lease_seconds": int(self.config.lease_seconds),
|
||||
}
|
||||
for field, expected in expected_binding.items():
|
||||
if body.get(field) != expected:
|
||||
raise LiveProtocolConflict(f"writer lease {field} mismatch")
|
||||
projected_branch_mode = body.get("branch_mode")
|
||||
allowed_branch_modes = {branch_mode}
|
||||
# CREATE_NEW is materialized by the Bridge before it returns the
|
||||
# descriptor, so the returned binding is CONTINUE_EXISTING.
|
||||
if branch_mode == "CREATE_NEW":
|
||||
allowed_branch_modes.add("CONTINUE_EXISTING")
|
||||
if projected_branch_mode not in allowed_branch_modes:
|
||||
raise LiveProtocolConflict("writer lease branch_mode mismatch")
|
||||
return LiveWriterLease(
|
||||
writer_lease_id=None,
|
||||
task_id=task_id,
|
||||
job_id=job_id,
|
||||
branch_mode=str(projected_branch_mode),
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
generation=int(generation),
|
||||
expires_at=None,
|
||||
continuation_key=continuation_key,
|
||||
raw=dict(body),
|
||||
)
|
||||
|
||||
def commit_files(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
repository: str,
|
||||
target_branch: str,
|
||||
generation: int,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
entries = [
|
||||
{
|
||||
"path": path,
|
||||
"content_base64": base64.b64encode(data).decode("ascii"),
|
||||
}
|
||||
for path, data in files.items()
|
||||
]
|
||||
req = {
|
||||
"task_id": session.task_id,
|
||||
"job_id": session.job_id,
|
||||
"repository": repository,
|
||||
"expected_branch_oid": session.expected_branch_oid,
|
||||
"target_branch": target_branch,
|
||||
"generation": int(generation),
|
||||
"idempotency_key": session.continuation_key,
|
||||
"files": entries,
|
||||
"message": message,
|
||||
}
|
||||
assertion = self.principal_client.issue_assertion(
|
||||
task_id=session.task_id,
|
||||
lease_handle=session.lease_handle,
|
||||
operation="commitFilesGoverned",
|
||||
bridge_request=req,
|
||||
request_id="commit-files-" + secrets.token_hex(8),
|
||||
)
|
||||
result = self.transport.request(
|
||||
"POST",
|
||||
f"{self.config.bridge_url}/v2/commit-files",
|
||||
body=req,
|
||||
headers={"Authorization": "TaskPrincipal " + assertion},
|
||||
timeout=45,
|
||||
)
|
||||
if result.status not in (200, 201):
|
||||
raise LiveProtocolError(f"commit-files HTTP {result.status}")
|
||||
return dict(result.body)
|
||||
|
||||
|
||||
class LiveGovernedWriterExecutor:
|
||||
"""Execute the real post-admission writer chain.
|
||||
|
||||
Prerequisites:
|
||||
- Owner authorization was already verified by the caller/authority.
|
||||
- A fresh admission task exists and is QUEUED.
|
||||
- The task's execution plan already binds the supplied job_id.
|
||||
- A valid delegation epoch/admission window is already active.
|
||||
|
||||
This executor deliberately does not create/rewrite admission plans or sign
|
||||
Owner epochs. Those remain in the existing admission/signer authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
config: LiveProtocolConfig,
|
||||
owner_policy: OwnerAuthorizationPolicy,
|
||||
authorization_ref: str,
|
||||
transport: JsonHttpTransport | None = None,
|
||||
agent_id: str | None = None,
|
||||
scope_enrollment: Callable[..., Mapping[str, Any]] | None = None,
|
||||
) -> None:
|
||||
self.config = config.normalized()
|
||||
self.owner_policy = owner_policy
|
||||
self.authorization_ref = authorization_ref
|
||||
if scope_enrollment is not None and (not callable(scope_enrollment) or not agent_id):
|
||||
raise ValueError("scope enrollment requires a configured agent identity")
|
||||
self.agent_id = agent_id
|
||||
self.scope_enrollment = scope_enrollment
|
||||
self.transport = transport or JsonHttpTransport()
|
||||
self.secret_store = SecretHandleStore()
|
||||
self.agent = LiveAgentTaskClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.principals = LiveTaskPrincipalClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
secrets_store=self.secret_store,
|
||||
)
|
||||
self.bridge = LiveBridgeClient(
|
||||
self.config,
|
||||
transport=self.transport,
|
||||
principal_client=self.principals,
|
||||
)
|
||||
self.git = LiveGitReader(self.config.authority_git_dir)
|
||||
|
||||
def _assert_owner_scope(self, request: GovernedWriterRequest) -> None:
|
||||
if self.authorization_ref != self.owner_policy.authorization_id:
|
||||
raise AuthorizationDenied("authorization_ref does not match verified policy")
|
||||
self.owner_policy.validate(request, now=time.time())
|
||||
|
||||
def acquire(
|
||||
self,
|
||||
*,
|
||||
request: GovernedWriterRequest,
|
||||
job_id: str,
|
||||
route: str = "owner-authorized-governed-writer",
|
||||
) -> LiveWriterSession:
|
||||
self._assert_owner_scope(request)
|
||||
|
||||
runtime = self.bridge.runtime_status()
|
||||
readiness = GovernedWriteReadiness.from_runtime_status(runtime)
|
||||
if not readiness.governed_write_path_open:
|
||||
raise LiveProtocolConflict(
|
||||
"governed write path blocked: " + ",".join(readiness.blockers)
|
||||
)
|
||||
if readiness.legacy_global_writes_enabled:
|
||||
raise LiveProtocolConflict("legacy/global writes must remain disabled")
|
||||
|
||||
task = self.agent.read_task(request.task_id)
|
||||
if task.status != "QUEUED":
|
||||
raise LiveProtocolConflict(
|
||||
f"task must be QUEUED before claim; live={task.status}"
|
||||
)
|
||||
now = time.time()
|
||||
if (
|
||||
task.deadline_epoch is not None
|
||||
and task.deadline_epoch - now
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict("task deadline too close to safely claim")
|
||||
|
||||
agent_id, agent_token_handle = self.agent.register_agent(
|
||||
task_id=request.task_id,
|
||||
capabilities=sorted(request.required_capabilities),
|
||||
route=route,
|
||||
agent_id=self.agent_id,
|
||||
)
|
||||
claim = self.agent.claim(
|
||||
task_id=request.task_id,
|
||||
agent_token_handle=agent_token_handle,
|
||||
)
|
||||
running = self.agent.heartbeat(
|
||||
claim=claim,
|
||||
agent_token_handle=agent_token_handle,
|
||||
phase=route,
|
||||
)
|
||||
if (
|
||||
running.deadline_epoch is not None
|
||||
and running.deadline_epoch - time.time()
|
||||
< self.config.minimum_remaining_window_seconds
|
||||
):
|
||||
raise LiveProtocolConflict(
|
||||
"remaining governed task window too short after heartbeat"
|
||||
)
|
||||
|
||||
if self.scope_enrollment is not None:
|
||||
# The existing issuer and Bridge still verify every assertion.
|
||||
# Enrollment only happens after a real claim has established the
|
||||
# agent/attempt/lease binding; failures stop before any assertion.
|
||||
enrollment = self.scope_enrollment(
|
||||
request=request, job_id=job_id, agent_id=agent_id,
|
||||
claim=claim, task=running,
|
||||
)
|
||||
if (not isinstance(enrollment, Mapping)
|
||||
or enrollment.get("status") != "ENROLLED"
|
||||
or enrollment.get("task_id") != request.task_id
|
||||
or enrollment.get("agent_id") != agent_id
|
||||
or enrollment.get("job_id") != job_id):
|
||||
raise LiveProtocolConflict("verified principal scope enrollment required")
|
||||
|
||||
live_head = self.git.branch_head(request.target_branch)
|
||||
if live_head is None:
|
||||
branch_mode = "CREATE_NEW"
|
||||
expected_branch_oid = request.base_oid
|
||||
else:
|
||||
branch_mode = "CONTINUE_EXISTING"
|
||||
expected_branch_oid = live_head
|
||||
|
||||
continuation_key = (
|
||||
"gw-cont-"
|
||||
+ hashlib.sha256(
|
||||
(request.task_id + "\0" + job_id).encode("utf-8")
|
||||
).hexdigest()[:32]
|
||||
)
|
||||
writer = self.bridge.acquire_writer_lease(
|
||||
task_id=request.task_id,
|
||||
job_id=job_id,
|
||||
lease_handle=claim.lease_handle,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
base_oid=request.base_oid,
|
||||
expected_branch_oid=expected_branch_oid,
|
||||
branch_mode=branch_mode,
|
||||
continuation_key=continuation_key,
|
||||
)
|
||||
return LiveWriterSession(
|
||||
authorization_id=self.authorization_ref,
|
||||
task_id=request.task_id,
|
||||
agent_id=agent_id,
|
||||
claim_ref=claim.claim_ref,
|
||||
job_id=job_id,
|
||||
branch_mode=writer.branch_mode,
|
||||
expected_branch_oid=writer.expected_branch_oid,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
continuation_key=writer.continuation_key,
|
||||
principal_issue_schema=self.config.issue_schema,
|
||||
agent_token_handle=agent_token_handle,
|
||||
lease_handle=claim.lease_handle,
|
||||
task_lease_receipt=dict(claim.public_receipt),
|
||||
writer_lease_binding=dict(writer.raw),
|
||||
)
|
||||
|
||||
def commit_and_readback(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
request: GovernedWriterRequest,
|
||||
files: Mapping[str, bytes],
|
||||
expected_sha256: Mapping[str, str],
|
||||
message: str,
|
||||
) -> Mapping[str, Any]:
|
||||
self._assert_owner_scope(request)
|
||||
if set(files) != set(expected_sha256):
|
||||
raise LiveProtocolConflict("commit file set differs from expected SHA set")
|
||||
for path, data in files.items():
|
||||
got = hashlib.sha256(data).hexdigest()
|
||||
if got != expected_sha256[path]:
|
||||
raise LiveProtocolConflict(f"pre-commit SHA mismatch: {path}")
|
||||
|
||||
response = self.bridge.commit_files(
|
||||
session=session,
|
||||
repository=request.repository,
|
||||
target_branch=request.target_branch,
|
||||
generation=request.generation,
|
||||
files=files,
|
||||
message=message,
|
||||
)
|
||||
|
||||
new_head = self.git.branch_head(request.target_branch)
|
||||
if new_head is None or new_head == session.expected_branch_oid:
|
||||
raise LiveProtocolConflict("branch did not advance after governed commit")
|
||||
readback = {
|
||||
path: self.git.file_sha256(new_head, path) for path in expected_sha256
|
||||
}
|
||||
if readback != dict(expected_sha256):
|
||||
raise LiveProtocolConflict("governed commit readback SHA mismatch")
|
||||
return {
|
||||
"commit_response": response,
|
||||
"branch_head": new_head,
|
||||
"readback_sha256": readback,
|
||||
}
|
||||
|
||||
def submit_task_result(
|
||||
self,
|
||||
*,
|
||||
session: LiveWriterSession,
|
||||
result: Mapping[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> Mapping[str, Any]:
|
||||
return self.agent.submit_result(
|
||||
task_id=session.task_id,
|
||||
agent_id=session.agent_id,
|
||||
agent_token_handle=session.agent_token_handle,
|
||||
lease_handle=session.lease_handle,
|
||||
idempotency_key=idempotency_key,
|
||||
result_value=result,
|
||||
)
|
||||
@@ -600,9 +600,17 @@ class EvidenceEnvelope:
|
||||
or self.evidence_digest != compute_sha256_hex(self.digest_payload())
|
||||
):
|
||||
return False
|
||||
if self.signature or self.attestation_ref:
|
||||
return True if attestation_verifier is None else bool(attestation_verifier(self))
|
||||
return True
|
||||
# An unsigned envelope has no proof of origin and must never verify.
|
||||
if not (self.signature or self.attestation_ref):
|
||||
return False
|
||||
# Every proof form requires an injected verifier. A reference or
|
||||
# signature must never become trusted merely because it is present.
|
||||
if attestation_verifier is None:
|
||||
return False
|
||||
try:
|
||||
return bool(attestation_verifier(self))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
|
||||
@@ -92,6 +92,7 @@ class PolicyEngine:
|
||||
max_risk: str = "medium",
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
evidence_attestation_verifier: Callable[[object], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
@@ -105,6 +106,7 @@ class PolicyEngine:
|
||||
self.max_risk = max_risk
|
||||
self.result_signature_verifier = result_signature_verifier
|
||||
self.probe_attestation_verifier = probe_attestation_verifier
|
||||
self.evidence_attestation_verifier = evidence_attestation_verifier
|
||||
self.identity_capabilities = {
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
@@ -195,6 +197,8 @@ class PolicyEngine:
|
||||
"worker.register": "worker.self_register",
|
||||
"worker.admin": "worker.admin",
|
||||
}
|
||||
if action not in action_capabilities:
|
||||
return PolicyDecision(False, f"unknown action {action}")
|
||||
required_capability = action_capabilities.get(action)
|
||||
if required_capability:
|
||||
granted = set(
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
"""S5.1 live authority adapters with exact caller/candidate binding.
|
||||
|
||||
These adapters are projections over existing authorities. They do not create
|
||||
tasks, grants, leases, branches, or recovery authority.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .governance_admission import AdmissionOperation, GovernanceFacts
|
||||
from .governance_recovery_controller import GovernanceEvent
|
||||
from .governance_resolver import ExactTaskResolver, ResolutionQuery, ResolutionResult
|
||||
|
||||
|
||||
class LiveSurfaceUnavailable(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_SURFACE_UNAVAILABLE[{role}]: {reason}")
|
||||
|
||||
|
||||
class LiveBindingViolation(RuntimeError):
|
||||
def __init__(self, role: str, reason: str) -> None:
|
||||
self.role = role
|
||||
self.reason = reason
|
||||
super().__init__(f"LIVE_BINDING_VIOLATION[{role}]: {reason}")
|
||||
|
||||
|
||||
class TaskPlaneReadClient(Protocol):
|
||||
def get_task(
|
||||
self, task_id: str, *, actor_identity: str
|
||||
) -> Mapping[str, Any] | None: ...
|
||||
|
||||
def list_tasks(
|
||||
self, *, actor_identity: str
|
||||
) -> list[Mapping[str, Any]]: ...
|
||||
|
||||
|
||||
class LiveTaskResolverAdapter:
|
||||
"""Bind ExactTaskResolver to one caller-authorized TaskPlane projection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: TaskPlaneReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
bound_repository: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
self._bound_repository = bound_repository
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def resolve(
|
||||
self, query: ResolutionQuery, *, actor_identity: str
|
||||
) -> ResolutionResult:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A1",
|
||||
"no caller-authorized TaskPlaneReadClient and bound identity",
|
||||
)
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"controller actor_identity does not match bound read principal",
|
||||
)
|
||||
if (
|
||||
self._bound_repository
|
||||
and query.repository != self._bound_repository
|
||||
):
|
||||
raise LiveBindingViolation(
|
||||
"A1",
|
||||
"query repository is outside the bound authority projection",
|
||||
)
|
||||
return ExactTaskResolver(self._client).resolve(
|
||||
query, actor_identity=self._actor_identity
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernedAuthoritySnapshot:
|
||||
"""One revision-consistent, candidate-specific governed read."""
|
||||
|
||||
task_id: str
|
||||
actor_identity: str
|
||||
operation: AdmissionOperation
|
||||
snapshot_revision: str
|
||||
facts: GovernanceFacts
|
||||
|
||||
|
||||
class GovernedAuthorityReadClient(Protocol):
|
||||
def read_snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernedAuthoritySnapshot: ...
|
||||
|
||||
|
||||
class LiveGovernanceFactsAdapter:
|
||||
"""Materialize GovernanceFacts from one atomic/revision-consistent read."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
client: GovernedAuthorityReadClient | None = None,
|
||||
actor_identity: str | None = None,
|
||||
) -> None:
|
||||
self._client = client
|
||||
self._actor_identity = actor_identity
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._client is not None and bool(self._actor_identity)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
*,
|
||||
task_id: str | None,
|
||||
actor_identity: str,
|
||||
operation: AdmissionOperation,
|
||||
) -> GovernanceFacts:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A2",
|
||||
"no candidate-specific governed authority reader and identity",
|
||||
)
|
||||
if task_id is None:
|
||||
raise LiveBindingViolation("A2", "exact task_id required")
|
||||
if actor_identity != self._actor_identity:
|
||||
raise LiveBindingViolation(
|
||||
"A2",
|
||||
"controller actor_identity does not match bound governed principal",
|
||||
)
|
||||
snapshot = self._client.read_snapshot(
|
||||
task_id=task_id,
|
||||
actor_identity=actor_identity,
|
||||
operation=operation,
|
||||
)
|
||||
if not snapshot.snapshot_revision:
|
||||
raise LiveBindingViolation(
|
||||
"A2", "authority snapshot revision is missing"
|
||||
)
|
||||
if snapshot.task_id != task_id:
|
||||
raise LiveBindingViolation("A2", "authority snapshot task mismatch")
|
||||
if snapshot.actor_identity != actor_identity:
|
||||
raise LiveBindingViolation("A2", "authority snapshot actor mismatch")
|
||||
if snapshot.operation is not operation:
|
||||
raise LiveBindingViolation("A2", "authority snapshot operation mismatch")
|
||||
return snapshot.facts
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LiveGovernanceEvent:
|
||||
kind: str
|
||||
revision: str
|
||||
occurred_at: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class GovernanceEventSubscriber(Protocol):
|
||||
def subscribe(
|
||||
self, on_event: Callable[[LiveGovernanceEvent], object]
|
||||
) -> None: ...
|
||||
def close(self) -> None: ...
|
||||
|
||||
|
||||
class LiveEventSubscriptionAdapter:
|
||||
"""Translate site events into the core GovernanceEvent contract."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sink: Callable[[GovernanceEvent], object],
|
||||
subscriber: GovernanceEventSubscriber | None = None,
|
||||
) -> None:
|
||||
self._sink = sink
|
||||
self._subscriber = subscriber
|
||||
self._active = False
|
||||
|
||||
@property
|
||||
def live(self) -> bool:
|
||||
return self._subscriber is not None
|
||||
|
||||
def _on_live_event(self, event: LiveGovernanceEvent):
|
||||
if not isinstance(event, LiveGovernanceEvent):
|
||||
raise LiveBindingViolation(
|
||||
"A3", "subscriber emitted an unsupported event type"
|
||||
)
|
||||
return self._sink(GovernanceEvent(
|
||||
kind=event.kind,
|
||||
revision=event.revision,
|
||||
occurred_at=event.occurred_at,
|
||||
))
|
||||
|
||||
def start(self) -> None:
|
||||
if not self.live:
|
||||
raise LiveSurfaceUnavailable(
|
||||
"A3",
|
||||
"no governance event subscriber injected; polling remains fallback",
|
||||
)
|
||||
self._subscriber.subscribe(self._on_live_event)
|
||||
self._active = True
|
||||
|
||||
def close(self) -> None:
|
||||
if self._subscriber is not None:
|
||||
self._subscriber.close()
|
||||
self._active = False
|
||||
@@ -65,6 +65,7 @@ class TaskPlaneStore:
|
||||
self.high_assurance = high_assurance
|
||||
self.fault_injector = None
|
||||
self.probe_attestation_verifier = None
|
||||
self.evidence_attestation_verifier = None
|
||||
self._lock = threading.RLock()
|
||||
self._tx_state = threading.local()
|
||||
self._closed = False
|
||||
@@ -1408,6 +1409,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.add_evidence_envelope(envelope)
|
||||
@@ -1731,6 +1733,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=envelope.task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=expected_previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
):
|
||||
raise ConflictError("evidence envelope invalid")
|
||||
self.conn.execute(
|
||||
@@ -1798,6 +1801,7 @@ class TaskPlaneStore:
|
||||
expected_task_id=task_id,
|
||||
expected_claim_id=envelope.claim_id,
|
||||
expected_previous_hash=previous,
|
||||
attestation_verifier=self.evidence_attestation_verifier,
|
||||
)
|
||||
):
|
||||
return False
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# C4 capability contract provenance
|
||||
|
||||
The C4 checkout has no `aios_capability_contract.py`, although
|
||||
`active-runtime/app/aios_agent_task_protocol.py` imports it at module scope.
|
||||
The read-only QA capture used for the missing source closure is:
|
||||
|
||||
- source: `C:\\Users\\ImRuY\\.codex-restored\\work\\v2r2c5-release-20260926\\qa\\capability-contract.txt`
|
||||
- SHA256: `c97c9f8a3cf199a9c0348da1212aa26d161aad8ddaed84db534e46f490f2422f`
|
||||
- 140 lines
|
||||
|
||||
The contract preserves separate task and worker alias maps, maps unknown task
|
||||
names to `exact:<token>`, ignores unknown worker names, and returns false for
|
||||
unknown or mismatched eligibility. It is used by formal AgentTask poll and
|
||||
claim paths only. Generic `task_plane` matching remains outside this lane.
|
||||
|
||||
The authoritative runtime overlay is captured separately by QA:
|
||||
|
||||
- base SHA256: `953de1d491b4f1bc47c71f502027fd488e6c48ecc49da8a91dc9d8754c320d17`
|
||||
- overlay SHA256: `7555aa067fc01c6a25276a834dda4924b404f3b0c529ed3f99e9f1d83d18798e`
|
||||
|
||||
The consolidated module preserves the base mappings and adds only the seven
|
||||
explicit lifecycle labels: `agent-task:control`, `governed.task.create`,
|
||||
`governed.write.files`, `task.principal.issue`, `testing`, `verification`, and
|
||||
`writer.lease`. It does not depend on an absolute deployed path.
|
||||
|
||||
Validation logs:
|
||||
|
||||
- `PYTHONPATH=active-runtime/app python -m pytest -q tests/test_v2r2c5_capability_contract.py` -> 6 passed.
|
||||
- `PYTHONPATH=active-runtime/app;active-runtime python -m pytest -q tests/test_task_admission_bootstrap.py tests/test_task_admission_bootstrap_chain_contract.py` -> 45 passed.
|
||||
- `tests/test_task_admission_bootstrap_http.py` could not collect on Windows because the existing `gitea_tag_publication.py` imports unavailable `fcntl`; no capability assertion ran in that file.
|
||||
@@ -0,0 +1,73 @@
|
||||
# C5 控制面闭合报告
|
||||
|
||||
状态:本地工程闭合候选已形成;live control plane 尚未闭合。本文是交付准备记录,不是生产发布、activation、Task admission 或 RC 成功证明。
|
||||
|
||||
## 已闭合的工程边界
|
||||
|
||||
架构决定和实施计划将 unattended runner 定义为编排客户端。它不拥有 Owner authority、epoch issuer、Task DB、Git writer、approval verifier 或 production executor。现有组件仍是唯一权威:`TaskAdmissionBootstrapService`/`AgentTaskAdmissionPort` 负责 envelope、grant 和 Task admission;既有 lease/principal/writer chain 负责写入;trusted builder 和 `SilentReleaseActionService` 负责 candidate/provider evidence;`ReleaseSagaStore`、`ReleaseOrchestrator` 和 `ReleaseRecoveryWorker` 负责部署、回滚与未知副作用恢复。旧的 `release_state_machine.py` 不应再作为第二状态权威。
|
||||
|
||||
本地 runner `active-runtime/app/governed_unattended_release.py` 已提供以下受限组合接口:
|
||||
|
||||
```text
|
||||
GovernedUnattendedReleaseRunner.intake(intent)
|
||||
GovernedUnattendedReleaseRunner.admit(intent, authorization)
|
||||
GovernedUnattendedReleaseRunner.publish(intent, admission_receipt)
|
||||
GovernedUnattendedReleaseRunner.postpublication_rc(event_id, source_commit, version, source_readback)
|
||||
GovernedUnattendedReleaseRunner.deploy(candidate_id)
|
||||
```
|
||||
|
||||
`SourcePublicationIntent` 对 repository、source/tree/base OID、精确 target branch、generation、execution digest、file allowlist、capability set 和 risk 做不可变规范化,并派生稳定 `task_id` 与 idempotency key。`admit` 在 envelope 已创建后遇到超时、连接断开或 5xx 时先 reconcile,不把未知结果当作未应用,也不重复提交;禁止错误不会重试。`publish` 要求同一 Task 的 `task_lease`、`task_principal`、`writer_lease`、authenticated commit receipt、base/branch/generation/execution digest/source/tree 全部绑定。缺一即阻断。
|
||||
|
||||
本地模拟器 `scripts/simulate_c5_unattended_release.py` 从 Git object 读取 source/base/tree/diff/archive,运行候选 C5 contract tests,使用临时 Owner key 和真实 admission/bootstrap/TaskStore 实现完成隔离签名与 admission,再写入现有模型的 candidate/manifest/RC JSON。输出明确标记 `SIMULATION`、`official_release=false`、`authority_source_commit=null`、`acceptance=PENDING_AUTHENTICATED_PRODUCTION_PROVIDER_EVIDENCE`、`approval=NOT_REQUESTED`、`deployment=NOT_ATTEMPTED`。这条链路可证明结构、签名验证、隔离 TaskStore、幂等和非权威 artifact 写出;不能证明 live authority、live publication、trusted production provider、release approval、runtime health 或 deployment。
|
||||
|
||||
`governed_release_evidence_producer.py` 的 `produce_evidence_closure(...)` 可生成现有 RC evidence closure,但要求 tests、canary、Gitea、rollback、secret-scan 每一类均有 trusted-provider receipt,并校验 artifact/manifest/package closure/source/tree。相同 closure 幂等,内容变化为冲突。该闭合器不得被调用来伪造 provider pass。
|
||||
|
||||
现有 release service 的正式接口仍应按以下顺序复用:
|
||||
|
||||
```text
|
||||
SilentReleaseActionService.create_release_intent(payload)
|
||||
-> get_release_candidate(candidate_id)
|
||||
-> get_release_plan(candidate_id)
|
||||
-> get_acceptance(candidate_id)
|
||||
-> verify_release_acceptance(candidate_id)
|
||||
-> get_approval_envelope(candidate_id)
|
||||
-> submit_owner_approval(signed_payload)
|
||||
-> start_deployment(candidate_id)
|
||||
```
|
||||
|
||||
`create_release_intent` 只能接收 event/source/version,并从 authenticated source admission resolver 得到版本与 admission ID;不能接收 caller evidence。`submit_owner_approval` 必须经既有 signed release-plan verifier 和 durable approval store。`start_deployment` 会重新加载 durable approval,调用既有 saga/orchestrator;caller 传入的 approval 不作为授权来源。
|
||||
|
||||
## 当前 live 未闭合
|
||||
|
||||
以下条件仍未证实,不能写成 PASS 或 COMPLETE:
|
||||
|
||||
- `authority_source_commit` 仍为 `null`;没有正式 live source publication receipt、authority Git readback、TaskLease/TaskPrincipal/WriterLease/commit closure。
|
||||
- 没有 live C5 exact source-only policy 的 Owner-signed activation、policy digest、issuer、revocation source 和 fresh epoch。不能用本地 policy/request hash、旧 activation binding、simulation epoch 或借用 credential 替代。
|
||||
- 没有 live registered machine caller 的可用 admission route;当前 runner 不能自行成为 authority issuer 或把 `engineering` 风险提升为全局允许值。
|
||||
- 没有真实 trusted builder/provider resolver 的完整绑定,尤其是 authenticated tests、canary、Gitea、rollback、secret-scan receipts;本地 candidate artifact 不是生产 evidence closure。
|
||||
- 没有 pre-authorized Owner approval broker 为精确 candidate/release plan 签名。source-only authorization 不等于 release approval。
|
||||
- 没有完成 live `SilentReleaseActionService` 的 candidate、acceptance、release plan、saga readback,也没有 deployment composition、runtime health、rollback 和 post-tag/finalization readback。
|
||||
- C6 已冻结;C5 自动化支持若进入后续发布,必须重新计算 source/tree/file binding、execution plan 和 signed authority,不能把支持代码自动并入既有 candidate。
|
||||
|
||||
## 最终本地验证
|
||||
|
||||
2026-09-26,在 Linux/Python 3.12.3 的隔离目录中完成 **184 passed** 定向回归(5.53 秒)。范围包含 epoch v1/v2、source policy、runner、真实 release service/Owner signature 集成、既有 admission/bootstrap/recovery、production contract 和 provider evidence producer。没有重跑历史全量业务测试,也没有把旧的 598 passed 计为本次新增代码的结果。
|
||||
|
||||
新增集成覆盖实际 signed builder、signed Owner approval、source substitution 拒绝、无 verifier 拒绝及 durable attestation 篡改拒绝。公开 acceptance 接口包含脱敏字段;`verify_release_acceptance` 在现有 service 内加载原始 durable 数据重新验签,保持公开脱敏规则,向 runner 返回验证摘要。
|
||||
|
||||
最终 `demo-run-4` 从不可变候选 `536a2af0f07962495c88526061ce43fb27994c15` 的 archive 执行 C5 capability N01/N02/N03,经过真实 SSHSIG 验签、真实隔离 Task admission、幂等重放和 scope/revocation 负例,成功写出 `V2R2C5_RC.simulation.json`。该 candidate 保持 `BUILDING`;正式 source OID 为空,provider acceptance 待补、approval 未请求、deployment 未执行。
|
||||
|
||||
可复核证据位于集成工作树相邻的 `../evidence/final-targeted-tests.xml`、`../evidence/demo-run-4/V2R2C5_RC.simulation.json` 和 `../evidence/demo-run-4/SHA256_MANIFEST.json`。这些结果证明隔离工程闭环,不证明 live 发布闭环。
|
||||
|
||||
## 可恢复下一步
|
||||
|
||||
1. 在现有 governed Task → TaskLease → TaskPrincipal → WriterLease → Governed Commit 链上发布 runner/epoch compatibility/exact policy guard 支持,并 read back commit/tree/receipt。
|
||||
2. 由已有 Owner authority 通过控制面激活精确 C5 source-only policy,记录 signed activation、policy digest、issuer、expiry、revocation source;随后请求 fresh epoch。禁止直接写受信任目录或复用旧 epoch。
|
||||
3. 用真实 registered caller 执行 admission;对 envelope、admission receipt、Task 状态和 authority OID 做 readback。任何 unknown effect 先 reconcile。
|
||||
4. 仅在 publication receipt、lease chain、commit receipt 和 authority readback 完整后,调用 `create_release_intent`,再由真实 provider 生成并验证 candidate、acceptance、plan、saga 和 RC evidence。
|
||||
5. 由预授权 Owner approval broker 对精确 release plan 签名,经 `submit_owner_approval` 验证并 durable 存储。缺 broker 时保持阻断,不降低 risk、不 mint client signature。
|
||||
6. 若另有明确生产授权,再调用 `start_deployment`;依赖既有 pre-switch/canary、health、rollback、tag/finalization reconciliation。否则将结果保留在已签名、可重放的 RC/approval-ready evidence,而不触碰生产。
|
||||
|
||||
## 运行时假设
|
||||
|
||||
真实组合依赖 Linux/POSIX `fcntl` locks、`fsync`/atomic rename、受限 symlink/open、Git authoritative bare repo、Gitea/ref readback、systemd service identity/restart、health endpoint、Ed25519 verification,以及 KMS/HSM/secret-manager reference。`execution_enabled=false`/shadow mode 是默认安全状态;生产执行缺少 approval verifier、trusted builder、builder attestation verifier、orchestrator、pre-switch validator 或 trusted policy 时必须 fail closed。
|
||||
@@ -0,0 +1,52 @@
|
||||
# C5 release automation implementation and rollout
|
||||
|
||||
The engineering task implements a repeatable orchestration path and isolated executable proof. It does not treat local artifacts as an activated production capability. V2R2C6 is frozen.
|
||||
|
||||
## Parallel implementation ownership
|
||||
|
||||
1. Epoch compatibility: admission bootstrap and signed-epoch provider; v1 regression, original-byte v2 verification, tamper/schema/revocation tests.
|
||||
2. Exact policy lifecycle: existing structured Owner issuer plus an exact source-publication guard; TTL, current trust, revocation, scope and configured-risk checks.
|
||||
3. Thin runner: generated immutable intake, admission/reconciliation, publication proof gate, existing trusted candidate service and signed approval/deployment entry points.
|
||||
4. Integration: isolated executable demo, targeted contract tests, architecture/gap reports and immutable local commit/archive evidence.
|
||||
|
||||
Each implementation lane uses a separate Git branch/worktree. Integration uses ordinary local commits without force-push, main mutation, TaskDB mutation or production writes.
|
||||
|
||||
## Minimum executable closure
|
||||
|
||||
Run the simulation CLI with a Git repository, committed source/base and a new output directory. It must derive the file list, tree and source archive from Git objects; run the selected checks; issue a fresh test-only Owner policy/epoch using ephemeral keys; verify them with the actual registry verifier and dual-schema provider; submit an envelope through the actual admission service against an isolated Task transport; and write an existing-model RC artifact with all evidence hashes.
|
||||
|
||||
The output must say `SIMULATION`, `official_release=false`, and retain `authority_source_commit=null`. It must not invent live provider receipts or set `READY_FOR_APPROVAL` without the existing acceptance path. Ephemeral private keys are confined to a temporary directory and excluded from exported evidence. A signed test receipt authenticates only its stated isolated environment.
|
||||
|
||||
From this implementation checkout on Linux/POSIX, with Git, OpenSSH, pytest and cryptography available:
|
||||
|
||||
```bash
|
||||
python3 scripts/simulate_c5_unattended_release.py \
|
||||
--repository /path/to/local/candidate/repository \
|
||||
--source 536a2af0f07962495c88526061ce43fb27994c15 \
|
||||
--base e5f8209f57b43fc39e55c4555776e6362ce326eb \
|
||||
--output /path/to/new/isolated/evidence-directory
|
||||
```
|
||||
|
||||
The command creates its own isolated Task store and ephemeral signing domain. It has no live transport or deployment adapter. No manually assembled JSON, existing Owner credential, service restart or production endpoint is needed.
|
||||
|
||||
## Required checks
|
||||
|
||||
- v1/v2 consumer compatibility, original bytes, issuer/key/namespace/digest, unknown schema, invalid fields, expiry and revocation.
|
||||
- Existing medium policy still denies engineering; exact source policy denies any changed branch/base/source/tree/files/caps/execution binding.
|
||||
- Missing verifier and missing/failing revocation lookup deny; admission replay rechecks authorization.
|
||||
- Stable intake identity, bounded retry, no retry for policy rejection, unknown side-effect reconciliation and conflicting receipt rejection.
|
||||
- Missing TaskLease/principal/WriterLease/commit/readback cannot produce a live RC.
|
||||
- Existing signed approval, provider evidence, saga integrity, deployment verification, rollback and post-tag reconciliation contracts.
|
||||
- C5 capability negatives N01/N02/N03 and affected regression tests only; historical full-suite counts are not reused as evidence for new changes.
|
||||
|
||||
## Governed rollout after local verification
|
||||
|
||||
1. Publish the support commit through the existing Task -> TaskLease -> TaskPrincipal -> WriterLease -> Governed Commit chain. Read back its OID/tree/receipt.
|
||||
2. Governed-install the dual-schema consumer and exact source-only policy guard on the existing control-plane service, preserving the old artifact for rollback.
|
||||
3. Activate the exact policy and dedicated machine caller using the existing Owner authority. Record signed activation, policy digest, issuer and revocation source. This is a provisioning step, not an implicit authority granted by the runner.
|
||||
4. Request a fresh epoch and execute C5 admission/publication. Rebind evidence to the authority-readback OID; it may differ from the local candidate.
|
||||
5. Run actual build/test/security/canary/rollback providers and their authenticated resolver. Invoke `create_release_intent` against the authority source; read back candidate, acceptance, plan and saga.
|
||||
6. Have the pre-authorized Owner broker sign the exact release plan, and submit it through `submit_owner_approval`. Source-only authorization is insufficient here.
|
||||
7. Call the existing deployment controller with approved durable state. Canary/pre-switch, health, promotion, registry/baseline finalization and rollback follow the existing saga. Verify runtime identity and final consistency.
|
||||
|
||||
Missing live authority or provider composition is a durable blocker, never a reason to lower risk, bypass G0, mint signatures in the client or call production restart directly.
|
||||
@@ -0,0 +1,60 @@
|
||||
# C5 governed unattended release: architecture decision
|
||||
|
||||
Status: selected for isolated implementation; live activation is not implied.
|
||||
Task: TASK-AIOS-C5-GOVERNED-UNATTENDED-RELEASE-PLATFORM-DESIGN-AND-IMPLEMENTATION-20260926-R1
|
||||
|
||||
## Decision
|
||||
|
||||
Select **B, additive v1/v2 epoch consumption**. Verify the producer's original canonical signed bytes and retain the actual schema, issuer, key, policy digest and revocation semantics. Project common typed fields only after verification. A v2 payload is never relabelled v1 and its signature is never copied onto another payload.
|
||||
|
||||
The producer's `policy_digest` hashes the Owner policy. It is not interchangeable with the default Task Admission policy digest. The existing policy factory propagates this digest; the compatibility change preserves that binding. A v2 `canonical_payload_digest` covers the payload before that field is added, while the signature authenticates the complete original canonical payload.
|
||||
|
||||
Option A would either be the same dual-schema verification hidden behind a misleading v1 label, or require a new signature. Option C would change more producers and consumers without resolving the immediate authority/composition gap. B preserves v1 operation and has the smallest rollback surface.
|
||||
|
||||
## One control plane
|
||||
|
||||
The unattended runner is an orchestration client. It does not become an authority issuer, Task database writer, Git writer, approval verifier, release state machine, or production executor.
|
||||
|
||||
| Responsibility | Existing authority/component |
|
||||
|---|---|
|
||||
| Owner signatures and narrowing | `LocalOwnerAuthority`, registered Owner key and configured Owner policy |
|
||||
| Epoch verification | `SignedEpochDirectoryProvider`, `RegistryOwnerApprovalVerifier` |
|
||||
| Exact source-only policy | Structured Owner-signed policy, verified request guard |
|
||||
| Envelope, grant and Task admission | `TaskAdmissionBootstrapService`, `AgentTaskAdmissionPort` |
|
||||
| Claim, principal and WriterLease | Existing Task/lease/governed writer chain |
|
||||
| Source commit proof | Governed commit receipt plus authority Git readback |
|
||||
| Build, provider evidence and candidate | Trusted builder, provider resolver, `SilentReleaseActionService` |
|
||||
| Approval | Existing signed release-plan approval verifier and durable approval store |
|
||||
| Deployment and recovery | `ReleaseSagaStore`, `ReleaseOrchestrator`, `ReleaseRecoveryWorker` |
|
||||
|
||||
The older linear `release_state_machine.py` is not added as another release state authority. The runner consumes durable state from the existing stores. Recovery of an unknown mutation reconciles that state before another write.
|
||||
|
||||
## Least privilege and unattended authority
|
||||
|
||||
The source-only policy binds repository, source commit/tree, base commit, exact target branch, generation, execution digest, file set, capability set, engineering risk, expiry and fresh identity. It cannot authorize main writes, tags, deployment, baseline mutation or historical Task mutation. The global admission ceiling stays `medium`; an exact verified request may use its separately signed engineering policy. A different request must be denied before envelope creation.
|
||||
|
||||
Recognizing the `engineering` rank in the issuer does not raise any configured ceiling. An existing `medium` Owner policy still rejects engineering issuance. The structured policy issuer must narrow its configured Owner policy; it cannot install or activate a policy for itself.
|
||||
|
||||
Each authorization rechecks signature, current key trust, expiry, revocation and scope. No automatic extension of expired epochs is allowed. A controller requests a fresh epoch under an already activated Owner policy. Source-only authority cannot be exchanged for release approval. Automatic release approval requires a separately delegated Owner broker to sign the exact candidate and release plan; missing or revoked delegation stops the workflow.
|
||||
|
||||
“No interaction” is an execution property after governed provisioning. It is not permission to create a trust root or grant the controller new powers. Current production has not been provisioned for this new composition.
|
||||
|
||||
## Retry and recovery
|
||||
|
||||
Task and event identities derive from immutable input. Retries retain them. Policy rejection is terminal until policy/input changes. A timeout is not proof that a write failed: use the admission receipt/official Task readback, publication receipt and authority OID, or existing release saga/operation journal to reconcile. Unknown or conflicting effects remain blocked. Transport calls require bounded timeouts; a controller deadline alone cannot cancel a remote mutation.
|
||||
|
||||
Build and acceptance evidence are obtained through trusted providers, not caller-supplied pass flags. A local simulation artifact is explicitly marked non-authoritative. Its source OID, archive hash and cryptographic checks can be real without proving live publication, runtime acceptance or deployment.
|
||||
|
||||
## Risks and rollout
|
||||
|
||||
1. Schema acceptance alone does not activate a usable policy. Roll out the consumer and exact policy guard together through governed control-plane publication.
|
||||
2. The existing deployed Owner policy does not authorize the C5 scope, and the current caller has no usable authenticated activation/epoch route. Provision the narrowly scoped service identity and policy through the existing governance plane. Do not use direct root file writes as a substitute.
|
||||
3. Signed source policy and epoch must agree on the Owner policy digest. Mixing epochs, digests or request identities fails closed.
|
||||
4. The local source candidate `536a2af0f07962495c88526061ce43fb27994c15` is preserved. Automation support changes are separate commits. Any decision to include them in the release must regenerate the governed execution plan and exact file/source bindings.
|
||||
5. The existing deployment engine contains canary/pre-switch, runtime verification, rollback and post-tag reconciliation logic. Tests establish adapter contracts; they do not prove current production composition or health.
|
||||
|
||||
## Rollback
|
||||
|
||||
Disable the new runner, revoke its source-only policy/epoch and stop new admissions. Preserve journals, receipts, immutable Tasks and candidate records. Revert the consumer/support module deployment to the prior governed control-plane artifact; v1 remains supported throughout migration. Never rewrite already issued signed payloads or historical Task state.
|
||||
|
||||
Production deployment rollback remains the existing signed plan's rollback operation and release saga. A post-tag/finalization uncertainty requires reconciliation; it must not be hidden by an automatic tag rewrite. C4 stays the production baseline until a separately approved C5 deployment completes runtime verification.
|
||||
@@ -1,9 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build a deterministic plan-bound production payload from one clean source tree."""
|
||||
from __future__ import annotations
|
||||
import argparse, hashlib, json, shutil, tarfile, tempfile
|
||||
import argparse, hashlib, json, re, shutil, tarfile, tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
# Adapter service must be a well-formed systemd unit base name (no path, no
|
||||
# leading dash) so the produced payload binds to an explicit, governed adapter
|
||||
# without pinning this producer to a single V2R2C2-era service/port.
|
||||
_SERVICE_UNIT_NAME_RE = re.compile(r"^[A-Za-z0-9_.:@-]{1,255}$")
|
||||
|
||||
SOURCE_FILES = (
|
||||
"active-runtime/app/deployment_state.py",
|
||||
"active-runtime/app/v2r1c6_privacy_tools.py",
|
||||
@@ -34,8 +41,22 @@ def build(source: Path, output: Path, *, version: str, source_oid: str, tree_oid
|
||||
if not source.is_dir() or source.is_symlink(): raise SystemExit("source_directory_invalid")
|
||||
require_oid(source_oid, "source_oid"); require_oid(tree_oid, "tree_oid")
|
||||
require_oid(rollback_target_oid, "rollback_target_oid"); require_sha(rollback_artifact_sha256, "rollback_artifact_sha256")
|
||||
if version != "V2R2C2" or rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
||||
if service != "aios-v2r2c2-production.service" or port != 18928: raise SystemExit("adapter_policy_binding_invalid")
|
||||
# Successor-aware binding: version is validated against the canonical
|
||||
# release-version contract (VnRnCn[-suffix], shared with the evidence
|
||||
# producer). Rollback must never target the same version as the payload
|
||||
# (fail-closed safety invariant preserved).
|
||||
try:
|
||||
validate_release_version(version)
|
||||
validate_release_version(rollback_version)
|
||||
except ValueError:
|
||||
raise SystemExit("release_version_binding_invalid")
|
||||
if rollback_version == version: raise SystemExit("release_version_binding_invalid")
|
||||
# Adapter binding: service must be a well-formed unit name and port within
|
||||
# the valid range. No single V2R2C2-era service/port is pinned.
|
||||
if not (isinstance(service, str) and _SERVICE_UNIT_NAME_RE.fullmatch(service)):
|
||||
raise SystemExit("adapter_policy_binding_invalid")
|
||||
if not isinstance(port, int) or not (1 <= port <= 65535):
|
||||
raise SystemExit("adapter_policy_binding_invalid")
|
||||
if output.exists(): raise SystemExit("output_exists")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.TemporaryDirectory(prefix="trusted-release-build-") as td:
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Build an additive overlay from the captured live Bridge; never install it."""
|
||||
import argparse
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
BASE_SHA256 = "38fc3e54e920ac3f6c992a651b7ef1ec0d2bb994848c0cb9e113bea3383403ca"
|
||||
|
||||
START = " if task_admission_handlers is None:\n"
|
||||
END = " from recovery_bridge import install_recovery\n"
|
||||
REPLACEMENT = ''' if task_admission_handlers is None:
|
||||
source_activation = os.environ.get("AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE", "").strip()
|
||||
try:
|
||||
if os.environ.get("AIOS_TASK_ADMISSION_EPOCH_ROOT", "").strip():
|
||||
from aios_task_admission_live_adapters import build_task_admission_handlers_from_environment
|
||||
task_admission_handlers = build_task_admission_handlers_from_environment(
|
||||
post_admission=runtime.project_admitted_task)
|
||||
elif activation_raw and Path(activation_raw).is_file():
|
||||
from aios_task_admission_live_adapters import build_task_admission_handlers_from_activation_file
|
||||
task_admission_handlers = build_task_admission_handlers_from_activation_file(
|
||||
Path(activation_raw), post_admission=runtime.project_admitted_task)
|
||||
except TaskAdmissionError as exc:
|
||||
if not source_activation or str(exc) != "TASK_ADMISSION_EPOCH_EXPIRED":
|
||||
raise
|
||||
from source_publication_runtime import expired_admission_runtime
|
||||
task_admission_handlers = expired_admission_runtime().handlers
|
||||
if source_activation:
|
||||
from source_publication_runtime import (
|
||||
AdmissionRuntimeRouter, ExistingPrincipalPolicyFence,
|
||||
build_source_runtime_from_activation_file,
|
||||
)
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
if task_admission_handlers is None:
|
||||
raise RuntimeError("SOURCE_ACTIVATION_ORDINARY_RUNTIME_REQUIRED")
|
||||
source_runtime = build_source_runtime_from_activation_file(
|
||||
Path(source_activation), post_admission=runtime.project_admitted_task,
|
||||
ordinary_activation_file=Path(activation_raw))
|
||||
ordinary = TaskAdmissionRuntimeBundle(task_admission_handlers, verifier)
|
||||
task_admission_handlers = AdmissionRuntimeRouter(ordinary, source_runtime).handlers()
|
||||
BridgeHandler.principal_verifier = ExistingPrincipalPolicyFence(verifier, source_runtime)
|
||||
'''
|
||||
|
||||
|
||||
def build(base: bytes) -> bytes:
|
||||
if hashlib.sha256(base).hexdigest() != BASE_SHA256:
|
||||
raise ValueError("LIVE_BRIDGE_BASE_HASH_MISMATCH")
|
||||
source = base.decode("utf-8")
|
||||
if source.count(START) != 1 or source.count(END) != 1:
|
||||
raise ValueError("LIVE_BRIDGE_ANCHOR_MISMATCH")
|
||||
start, end = source.index(START), source.index(END)
|
||||
result = source[:start] + REPLACEMENT + source[end:]
|
||||
runtime_line = ' if parsed.path == "/v2/runtime": self._json(self.adapter.getRuntimeStatus()); return\n'
|
||||
if result.count(runtime_line) != 1:
|
||||
raise ValueError("LIVE_RUNTIME_ROUTE_ANCHOR_MISMATCH")
|
||||
result = result.replace(runtime_line, ''' if parsed.path == "/v2/runtime":
|
||||
import hashlib as _source_hashlib
|
||||
source_path = Path(__file__).resolve(strict=True)
|
||||
self._json({**self.adapter.getRuntimeStatus(),
|
||||
"source_module_path": str(source_path),
|
||||
"source_module_sha256": _source_hashlib.sha256(source_path.read_bytes()).hexdigest()})
|
||||
return
|
||||
''', 1)
|
||||
compile(result, "aios_chatgpt_bridge_v2_server.py", "exec")
|
||||
return result.encode("utf-8")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--base", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
output = build(args.base.read_bytes())
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with args.output.open("xb") as handle:
|
||||
handle.write(output)
|
||||
print(hashlib.sha256(output).hexdigest())
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import difflib
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
FILES = ("aios_task_admission_bootstrap.py", "aios_task_admission_live_adapters.py")
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
return hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def replace_once(text: str, old: str, new: str, label: str) -> str:
|
||||
count = text.count(old)
|
||||
if count != 1:
|
||||
raise RuntimeError(f"{label}: expected one match, got {count}")
|
||||
return text.replace(old, new, 1)
|
||||
|
||||
|
||||
def patch_bootstrap(text: str) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
'SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"\n',
|
||||
'SCHEMA_EPOCH = "aios.owner-delegation-epoch.v1"\n'
|
||||
'SCHEMA_EPOCH_V2 = "aios.owner-delegation-epoch.v2"\n'
|
||||
'SUPPORTED_EPOCH_SCHEMAS = frozenset({SCHEMA_EPOCH, SCHEMA_EPOCH_V2})\n',
|
||||
"bootstrap schema constants",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' policy_digest: str\n schema: str = SCHEMA_EPOCH\n\n def as_public_dict',
|
||||
' policy_digest: str\n'
|
||||
' schema: str = SCHEMA_EPOCH\n'
|
||||
' production_scope: str | None = None\n'
|
||||
' destructive_policy: str | None = None\n'
|
||||
' status: str = ""\n'
|
||||
' signature_namespace: str = ""\n'
|
||||
' signature_key_id: str = ""\n'
|
||||
' canonical_payload_digest: str = ""\n'
|
||||
' canonical_payload: bytes = b""\n\n'
|
||||
' def as_public_dict',
|
||||
"epoch provenance fields",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' value = asdict(self)\n value["allowed_capabilities"]',
|
||||
' value = asdict(self)\n'
|
||||
' value.pop("canonical_payload", None)\n'
|
||||
' value["allowed_capabilities"]',
|
||||
"epoch public serialization",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' if epoch.schema != SCHEMA_EPOCH:\n',
|
||||
' if epoch.schema not in SUPPORTED_EPOCH_SCHEMAS:\n',
|
||||
"bootstrap epoch schema check",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}\n',
|
||||
' ranks = {"low": 0, "medium": 1, "engineering": 2, "high": 3}\n',
|
||||
"risk compatibility assertion",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_adapters(text: str) -> str:
|
||||
text = replace_once(
|
||||
text,
|
||||
' OwnerDelegationEpoch,\n TaskAdmissionError,',
|
||||
' OwnerDelegationEpoch,\n SCHEMA_EPOCH,\n SCHEMA_EPOCH_V2,\n TaskAdmissionError,',
|
||||
"adapter schema imports",
|
||||
)
|
||||
marker = 'OWNER_SIGNER_IDENTITY = "aios-task-grant-signer"\n'
|
||||
fields = marker + (
|
||||
'_EPOCH_V1_FIELDS = frozenset({\n'
|
||||
' "epoch_id", "owner_identity", "trust_domain", "repository",\n'
|
||||
' "allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes",\n'
|
||||
' "risk_ceiling", "issued_at", "not_before", "expires_at",\n'
|
||||
' "revocation_epoch", "policy_digest", "schema",\n'
|
||||
'})\n'
|
||||
'_EPOCH_V2_FIELDS = frozenset({\n'
|
||||
' *_EPOCH_V1_FIELDS, "business_goal", "production_scope",\n'
|
||||
' "destructive_policy", "signature_namespace", "signature_key_id",\n'
|
||||
' "canonical_payload_digest", "status",\n'
|
||||
'})\n'
|
||||
'_SSH_FINGERPRINT_RE = re.compile(r"^SHA256:[A-Za-z0-9+/]{43}$")\n'
|
||||
)
|
||||
text = replace_once(text, marker, fields, "adapter epoch field sets")
|
||||
text = replace_once(
|
||||
text,
|
||||
' if value.get("schema") != "aios.owner-delegation-epoch.v1":\n'
|
||||
' raise TaskAdmissionError(\n'
|
||||
' "TASK_ADMISSION_EPOCH_SCHEMA_INVALID",\n'
|
||||
' 403,\n'
|
||||
' )\n',
|
||||
' schema = value.get("schema")\n'
|
||||
' if schema not in {SCHEMA_EPOCH, SCHEMA_EPOCH_V2}:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_SCHEMA_INVALID", 403)\n'
|
||||
' expected_fields = _EPOCH_V1_FIELDS if schema == SCHEMA_EPOCH else _EPOCH_V2_FIELDS\n'
|
||||
' if set(value) != expected_fields:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_FIELDS_INVALID", 403)\n',
|
||||
"adapter schema and fields",
|
||||
)
|
||||
anchor = ' if epoch_id in self._revoked_ids():\n raise TaskAdmissionError("TASK_ADMISSION_EPOCH_REVOKED", 403)\n\n'
|
||||
block = anchor + (
|
||||
' if schema == SCHEMA_EPOCH_V2:\n'
|
||||
' if (\n'
|
||||
' value.get("status") != "ACTIVE"\n'
|
||||
' or not isinstance(value.get("business_goal"), str)\n'
|
||||
' or not value["business_goal"]\n'
|
||||
' or not isinstance(value.get("production_scope"), str)\n'
|
||||
' or not value["production_scope"]\n'
|
||||
' or not isinstance(value.get("destructive_policy"), str)\n'
|
||||
' or value["destructive_policy"] not in {"forbidden", "rollback-only"}\n'
|
||||
' or value.get("signature_namespace") != OWNER_NAMESPACE\n'
|
||||
' or not isinstance(value.get("signature_key_id"), str)\n'
|
||||
' or not _SSH_FINGERPRINT_RE.fullmatch(value["signature_key_id"])\n'
|
||||
' or not isinstance(value.get("canonical_payload_digest"), str)\n'
|
||||
' or not re.fullmatch(r"[0-9a-f]{64}", value["canonical_payload_digest"])\n'
|
||||
' ):\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_V2_BINDING_INVALID", 403)\n'
|
||||
' digest_value = dict(value)\n'
|
||||
' supplied_digest = digest_value.pop("canonical_payload_digest", None)\n'
|
||||
' if sha256_json(digest_value) != supplied_digest:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID", 403)\n\n'
|
||||
)
|
||||
text = replace_once(text, anchor, block, "v2 payload binding")
|
||||
text = replace_once(
|
||||
text,
|
||||
' key_id = key_id_file.read_text(encoding="utf-8").strip()\n',
|
||||
' key_id = key_id_file.read_text(encoding="utf-8").strip()\n'
|
||||
' if schema == SCHEMA_EPOCH_V2 and key_id != value["signature_key_id"]:\n'
|
||||
' raise TaskAdmissionError("TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID", 403)\n',
|
||||
"v2 key binding",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' schema=str(value["schema"]),\n )\n',
|
||||
' schema=str(value["schema"]),\n'
|
||||
' production_scope=str(value["production_scope"]) if schema == SCHEMA_EPOCH_V2 else None,\n'
|
||||
' destructive_policy=str(value["destructive_policy"]) if schema == SCHEMA_EPOCH_V2 else None,\n'
|
||||
' status=str(value.get("status", "")),\n'
|
||||
' signature_namespace=str(value.get("signature_namespace", "")),\n'
|
||||
' signature_key_id=str(value.get("signature_key_id", "")),\n'
|
||||
' canonical_payload_digest=str(value.get("canonical_payload_digest", "")),\n'
|
||||
' canonical_payload=payload,\n'
|
||||
' )\n',
|
||||
"epoch provenance construction",
|
||||
)
|
||||
return text
|
||||
|
||||
|
||||
def patch_runtime_bundle(text: str) -> str:
|
||||
marker = 'def _build_task_admission_handlers(\n'
|
||||
text = replace_once(text, marker, '@dataclass(frozen=True, slots=True)\n'
|
||||
'class TaskAdmissionRuntimeBundle:\n'
|
||||
' handlers: Mapping[str, Any]\n'
|
||||
' principal_verifier: Any = None\n'
|
||||
' service: Any = None\n'
|
||||
' authorization: Any = None\n'
|
||||
' store: Any = None\n'
|
||||
' task_port: Any = None\n'
|
||||
' epoch_id: str = ""\n\n\n'
|
||||
'def _build_task_admission_runtime_bundle(\n', "runtime bundle declaration")
|
||||
text = replace_once(
|
||||
text,
|
||||
') -> Mapping[str, Any]:\n """Compose handlers from already-trusted server-side coordinates."""',
|
||||
') -> TaskAdmissionRuntimeBundle:\n """Compose handlers from already-trusted server-side coordinates."""',
|
||||
"runtime bundle return type",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' store=TaskAdmissionStore(state_db),\n',
|
||||
' store=(store := TaskAdmissionStore(state_db)),\n',
|
||||
"runtime bundle store capture",
|
||||
)
|
||||
text = replace_once(
|
||||
text,
|
||||
' return TaskAdmissionBridgeContract(service).handlers()\n\n\ndef build_task_admission_handlers_from_activation_file(',
|
||||
' return TaskAdmissionRuntimeBundle(\n'
|
||||
' handlers=TaskAdmissionBridgeContract(service).handlers(),\n'
|
||||
' service=service, authorization=authorization, store=store,\n'
|
||||
' task_port=task_port, epoch_id=epoch_id,\n'
|
||||
' )\n\n\n'
|
||||
'def _build_task_admission_handlers(**kwargs: Any) -> Mapping[str, Any]:\n'
|
||||
' return _build_task_admission_runtime_bundle(**kwargs).handlers\n\n\n'
|
||||
'def build_task_admission_handlers_from_activation_file(',
|
||||
"runtime bundle legacy wrapper",
|
||||
)
|
||||
# Clone the original strict activation parser, retaining all path/token/API
|
||||
# checks. The new bundle entry point still passes through the live V3
|
||||
# producer hook. It never replaces the registered signed principal verifier.
|
||||
tree = ast.parse(text)
|
||||
node = next(n for n in tree.body if isinstance(n, ast.FunctionDef)
|
||||
and n.name == "build_task_admission_handlers_from_activation_file")
|
||||
body = ast.get_source_segment(text, node)
|
||||
body = body.replace("def build_task_admission_handlers_from_activation_file(",
|
||||
"def _c5_build_runtime_from_activation_file(", 1)
|
||||
body = body.replace("return _build_task_admission_handlers(",
|
||||
"return _build_task_admission_runtime_bundle(", 1)
|
||||
text += "\n\n" + body + '''
|
||||
|
||||
|
||||
def build_task_admission_runtime_from_activation_file(activation_file, *, post_admission=None,
|
||||
caller_service="aios-chatgpt-bridge-v2-staging.service"):
|
||||
# Dedicated source-only composition. Ordinary handler construction continues
|
||||
# to use the original medium policy and does not enter this function.
|
||||
from dataclasses import replace
|
||||
bundle = _fl_call(_c5_build_runtime_from_activation_file, activation_file,
|
||||
caller_service=caller_service, post_admission=post_admission)
|
||||
epoch = bundle.authorization.verify_delegation_epoch(bundle.epoch_id)
|
||||
if (epoch.schema != SCHEMA_EPOCH_V2 or epoch.production_scope != "none"
|
||||
or epoch.destructive_policy != "forbidden"):
|
||||
raise TaskAdmissionError("SOURCE_ACTIVATION_EPOCH_SCOPE_DENIED", 403)
|
||||
bundle.service.policy = replace(bundle.service.policy,
|
||||
repository=epoch.repository, trust_domain=epoch.trust_domain,
|
||||
allowed_capabilities=frozenset(epoch.allowed_capabilities),
|
||||
max_risk=epoch.risk_ceiling, policy_digest=epoch.policy_digest)
|
||||
return bundle
|
||||
'''
|
||||
return text
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--release-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
root = args.release_root.resolve()
|
||||
snapshot = root / "live-release" / "staging-source-snapshot"
|
||||
identity_path = root / "live-release" / "STAGING_MODULE_IDENTITIES.json"
|
||||
overlay = root / "live-release" / "staging-overlay" / "app"
|
||||
identity = json.loads(identity_path.read_text(encoding="utf-8"))
|
||||
outputs: dict[str, dict[str, str | int]] = {}
|
||||
diffs: list[str] = []
|
||||
for name in FILES:
|
||||
source = snapshot / name
|
||||
expected = identity["modules"][name.removesuffix(".py")]["sha256"]
|
||||
if sha256(source) != expected:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}")
|
||||
before = source.read_text(encoding="utf-8")
|
||||
after = patch_bootstrap(before) if name.endswith("bootstrap.py") else patch_adapters(before)
|
||||
after = patch_runtime_bundle(after) if name.endswith("live_adapters.py") else after
|
||||
target = overlay / name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(after, encoding="utf-8", newline="")
|
||||
diffs.extend(difflib.unified_diff(
|
||||
before.splitlines(keepends=True), after.splitlines(keepends=True),
|
||||
fromfile=f"staging-source-snapshot/{name}", tofile=f"staging-overlay/app/{name}",
|
||||
))
|
||||
outputs[name] = {
|
||||
"base_sha256": expected, "base_bytes": len(before.encode()),
|
||||
"result_sha256": sha256(target), "result_bytes": len(after.encode()),
|
||||
}
|
||||
diff_path = root / "live-release" / "staging-overlay" / "c5-epoch-v2-overlay.diff"
|
||||
diff_path.write_text("".join(diffs), encoding="utf-8", newline="")
|
||||
result_path = root / "live-release" / "staging-overlay" / "c5-epoch-v2-overlay.result.json"
|
||||
result_path.write_text(json.dumps({"files": outputs, "diff_sha256": sha256(diff_path)}, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,357 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Build the hash-pinned additive C5 TaskPrincipal enrollment overlay."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
PINNED = {
|
||||
# The first admission module on both live unit PYTHONPATH values. It
|
||||
# preserves the exact9/R10/generic chain and the governed TTL clamp.
|
||||
"aios_task_principal_admission.py": "d56fb0078cfcb907f2afb35f0163051a962a89520b63f6c53149d478fc7777c8",
|
||||
# Effective staging entrypoint. This preserves the 4 MiB body-limit
|
||||
# hardening wrapper, which in turn pins and loads the original a95b core.
|
||||
"aios_task_principal_issuer_server.py": "53b30a4a094f4abda176dbbf931ed2d9baec0a6ef3590c9ae498e2c22af1057c",
|
||||
"aios_task_principal_governed_adapter.py": "9bdfa7bd20bd1f4d5c2f5a58f458d0999f593a3f9326920971e357ba52939914",
|
||||
"governed_runtime_scope.py": "c56bd709c1e0c8956a5be7a4a8dc2a12591bdec2b84dcfba5c53f6bced746bcb",
|
||||
}
|
||||
|
||||
EFFECTIVE_ADMISSION_SOURCE = (
|
||||
"/var/lib/aios-gitops/bridge-v2-staging/releases/"
|
||||
"p0-task-principal-governed-runtime-20260910-02/app/"
|
||||
"aios_task_principal_admission.py"
|
||||
)
|
||||
|
||||
ADMISSION_OVERLAY = '''"""C5-only overlay over the hash-pinned effective admission chain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
BASE_SOURCE = Path(
|
||||
"/var/lib/aios-gitops/bridge-v2-staging/releases/"
|
||||
"p0-task-principal-governed-runtime-20260910-02/app/"
|
||||
"aios_task_principal_admission.py"
|
||||
)
|
||||
BASE_SOURCE_SHA256 = "d56fb0078cfcb907f2afb35f0163051a962a89520b63f6c53149d478fc7777c8"
|
||||
C5_TASK_ID = "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1"
|
||||
|
||||
|
||||
def _load_base():
|
||||
metadata = BASE_SOURCE.lstat()
|
||||
if not BASE_SOURCE.is_file() or BASE_SOURCE.is_symlink() or metadata.st_mode & 0o022:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_INVALID")
|
||||
digest = hashlib.sha256(BASE_SOURCE.read_bytes()).hexdigest()
|
||||
if digest != BASE_SOURCE_SHA256:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_DRIFT")
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"_aios_task_principal_admission_governed_base", BASE_SOURCE,
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("BASE_ADMISSION_SOURCE_UNLOADABLE")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
_effective_base = _load_base()
|
||||
for _name, _value in _effective_base.__dict__.items():
|
||||
if not _name.startswith("__"):
|
||||
globals()[_name] = _value
|
||||
|
||||
_legacy = _effective_base._legacy
|
||||
_ORIGINAL_SCOPE_AND_TRUST = _legacy._scope_and_trust
|
||||
|
||||
|
||||
def _c5_scope_and_trust(
|
||||
con, trust, task, now, *, lease_token=None, pepper=None,
|
||||
):
|
||||
# Every non-C5 task follows the effective governed/exact9/R10 chain
|
||||
# unchanged. The exact C5 identity has one exclusive enrollment route.
|
||||
if str(task["task_id"]) != C5_TASK_ID:
|
||||
return _ORIGINAL_SCOPE_AND_TRUST(
|
||||
con, trust, task, now, lease_token=lease_token, pepper=pepper,
|
||||
)
|
||||
|
||||
active_digest, active_scope = _legacy._active_scope_chain(con)
|
||||
digest = _legacy.sha256_canonical(trust)
|
||||
if active_digest != digest:
|
||||
_legacy._fail("TRUST_NOT_ACTIVE")
|
||||
event = con.execute(
|
||||
"SELECT * FROM task_principal_trust_events WHERE event_type='REGISTER' "
|
||||
"AND issuer_id=? AND key_id=? AND public_key=? AND trust_manifest_sha256=?",
|
||||
(trust["issuer_id"], trust["key_id"], trust["public_key"], digest),
|
||||
).fetchone()
|
||||
if event is None or event["scope_id"] != active_scope["scope_id"]:
|
||||
_legacy._fail("TRUST_REGISTER_MISSING_OR_CORRUPT")
|
||||
if (
|
||||
active_scope["issuer_id"] != trust["issuer_id"]
|
||||
or active_scope["key_id"] != trust["key_id"]
|
||||
):
|
||||
_legacy._fail("SCOPE_MISSING_OR_CORRUPT")
|
||||
_legacy._check_revocations(con, task, active_scope, trust, None)
|
||||
|
||||
from c5_governed_principal_scope import (
|
||||
C5PrincipalScopeError,
|
||||
resolve_enrolled_scope_from_environment,
|
||||
)
|
||||
try:
|
||||
scope = resolve_enrolled_scope_from_environment(
|
||||
task=task,
|
||||
execution=_legacy._execution(task),
|
||||
trust=trust,
|
||||
now=now,
|
||||
active_static_task_id=str(active_scope["task_id"]),
|
||||
)
|
||||
except C5PrincipalScopeError as exc:
|
||||
_legacy._fail(exc.code)
|
||||
if scope is None:
|
||||
_legacy._fail("C5_SCOPE_ROUTING_INVALID")
|
||||
claim_digest = _legacy._claim(task, lease_token, pepper, now)
|
||||
_legacy._check_revocations(con, task, scope, trust, None)
|
||||
return scope, claim_digest
|
||||
|
||||
|
||||
_legacy._scope_and_trust = _c5_scope_and_trust
|
||||
globals()["_scope_and_trust"] = _c5_scope_and_trust
|
||||
|
||||
|
||||
def build_task_principal_issuer(*args: Any, **kwargs: Any) -> Any:
|
||||
from c5_governed_principal_scope import wrap_principal_runtime_from_environment
|
||||
return wrap_principal_runtime_from_environment(
|
||||
TaskPrincipalIssuer(*args, **kwargs), role="issuer",
|
||||
)
|
||||
|
||||
|
||||
def build_task_principal_verifier(*args: Any, **kwargs: Any) -> Any:
|
||||
from c5_governed_principal_scope import wrap_principal_runtime_from_environment
|
||||
return wrap_principal_runtime_from_environment(
|
||||
TaskPrincipalVerifier(*args, **kwargs), role="verifier",
|
||||
)
|
||||
|
||||
|
||||
C5_GOVERNED_PRINCIPAL_SCOPE_OVERLAY = {
|
||||
"schema": "aios.c5-governed-principal-scope-overlay.v1",
|
||||
"base_admission": str(BASE_SOURCE),
|
||||
"base_admission_sha256": BASE_SOURCE_SHA256,
|
||||
"task_id": C5_TASK_ID,
|
||||
}
|
||||
'''
|
||||
|
||||
ISSUER_MAIN_ANCHOR = '''
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
ISSUER_MAIN_WITH_BRIDGE_ABI = '''
|
||||
# The effective Bridge imports this existing private helper. Delegate it to
|
||||
# the body-limit wrapper's already hash-pinned base module so its validation
|
||||
# semantics and the wrapper's 4 MiB MAX_BODY setting remain authoritative.
|
||||
_read_public_manifest = _BASE._read_public_manifest
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
'''
|
||||
|
||||
ENTRYPOINT = '''#!/usr/bin/env python3
|
||||
import sys
|
||||
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from c5_governed_principal_scope import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
'''
|
||||
|
||||
ENV_EXAMPLE = '''# Required identically for the issuer and Bridge units. Prefix overlay/app
|
||||
# on both PYTHONPATH values; their existing python -m entry points are unchanged.
|
||||
AIOS_C5_PRINCIPAL_SCOPE_CONFIG=/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/principal-activation.json
|
||||
'''
|
||||
|
||||
ACTIVATION_EXAMPLE = {
|
||||
"schema": "aios.c5-task-principal-activation.v1",
|
||||
"task_id": "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
"agent_id": "agent/codex-c5-release",
|
||||
"execution_digest": "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e",
|
||||
"store_path": "/var/lib/aios-gitops/c5-source-principal/enrollment.sqlite3",
|
||||
"grant_db": "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3",
|
||||
"plan_dir": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/plans",
|
||||
"producer_state_root": "/var/lib/aios-gitops/bridge-v2-staging",
|
||||
"policy_artifact": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/policy.json",
|
||||
"policy_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/policy-revocations.json",
|
||||
"epoch_root": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/epoch",
|
||||
"epoch_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/REPLACE/epoch-revocations.json",
|
||||
"owner_registry": "/etc/aios/single-host-signer-registry.json",
|
||||
"owner_signer_id": "local-owner-authority",
|
||||
"registered_signer_command": "/usr/local/libexec/aios-grant-signer-adapter",
|
||||
"require_root_plan_owner": True,
|
||||
"grant_db_allowed_group_gid": None,
|
||||
}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def copy_pinned(source: Path, target: Path, name: str) -> dict[str, Any]:
|
||||
observed = sha256(source)
|
||||
if observed != PINNED[name]:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}:{observed}")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source, target)
|
||||
return {
|
||||
"source": str(source.resolve()),
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(target),
|
||||
"bytes": target.stat().st_size,
|
||||
}
|
||||
|
||||
|
||||
def build_issuer_overlay(source: Path, target: Path) -> dict[str, Any]:
|
||||
name = "aios_task_principal_issuer_server.py"
|
||||
observed = sha256(source)
|
||||
if observed != PINNED[name]:
|
||||
raise SystemExit(f"BASE_HASH_MISMATCH:{name}:{observed}")
|
||||
text = source.read_text(encoding="utf-8")
|
||||
if text.count(ISSUER_MAIN_ANCHOR) != 1:
|
||||
raise SystemExit(
|
||||
"PATCH_ANCHOR_INVALID:issuer_bridge_private_abi:"
|
||||
+ str(text.count(ISSUER_MAIN_ANCHOR))
|
||||
)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(
|
||||
text.replace(ISSUER_MAIN_ANCHOR, ISSUER_MAIN_WITH_BRIDGE_ABI, 1),
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
return {
|
||||
"source": str(source.resolve()),
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(target),
|
||||
"bytes": target.stat().st_size,
|
||||
"preserved_max_body_bytes": 4 * 1024 * 1024,
|
||||
"bridge_private_helper": "_read_public_manifest",
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--principal-source-root", type=Path, required=True)
|
||||
parser.add_argument(
|
||||
"--issuer-source", type=Path,
|
||||
help="Exact issuer server source; defaults to principal-source-root/aios_task_principal_issuer_server.py",
|
||||
)
|
||||
parser.add_argument("--adapter-source-root", type=Path, required=True)
|
||||
parser.add_argument("--output-root", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
principal = args.principal_source_root.resolve()
|
||||
adapter = args.adapter_source_root.resolve()
|
||||
output = args.output_root.resolve()
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise SystemExit("OUTPUT_ROOT_NOT_EMPTY")
|
||||
app = output / "app"
|
||||
app.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
admission_source = principal / "aios_task_principal_admission.py"
|
||||
observed = sha256(admission_source)
|
||||
if observed != PINNED["aios_task_principal_admission.py"]:
|
||||
raise SystemExit(
|
||||
"BASE_HASH_MISMATCH:aios_task_principal_admission.py:" + observed
|
||||
)
|
||||
admission_target = app / "aios_task_principal_admission.py"
|
||||
admission_target.write_text(ADMISSION_OVERLAY, encoding="utf-8", newline="")
|
||||
|
||||
outputs: dict[str, Any] = {
|
||||
"aios_task_principal_admission.py": {
|
||||
"source": str(admission_source),
|
||||
"runtime_base_source": EFFECTIVE_ADMISSION_SOURCE,
|
||||
"base_sha256": observed,
|
||||
"result_sha256": sha256(admission_target),
|
||||
"bytes": admission_target.stat().st_size,
|
||||
}
|
||||
}
|
||||
issuer_source = (
|
||||
args.issuer_source.resolve()
|
||||
if args.issuer_source is not None
|
||||
else principal / "aios_task_principal_issuer_server.py"
|
||||
)
|
||||
outputs["aios_task_principal_issuer_server.py"] = build_issuer_overlay(
|
||||
issuer_source,
|
||||
app / "aios_task_principal_issuer_server.py",
|
||||
)
|
||||
for name in (
|
||||
"aios_task_principal_governed_adapter.py", "governed_runtime_scope.py",
|
||||
):
|
||||
outputs[name] = copy_pinned(adapter / name, app / name, name)
|
||||
module_source = Path(__file__).resolve().parents[1] / "active-runtime" / "app" / "c5_governed_principal_scope.py"
|
||||
module_target = app / module_source.name
|
||||
shutil.copyfile(module_source, module_target)
|
||||
outputs[module_source.name] = {
|
||||
"source": str(module_source),
|
||||
"result_sha256": sha256(module_target),
|
||||
"bytes": module_target.stat().st_size,
|
||||
}
|
||||
|
||||
bin_dir = output / "bin"
|
||||
bin_dir.mkdir(parents=True, exist_ok=True)
|
||||
entry = bin_dir / "aios-c5-principal-scope"
|
||||
entry.write_text(ENTRYPOINT, encoding="utf-8", newline="")
|
||||
entry.chmod(entry.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP)
|
||||
config_dir = output / "config"
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_path = config_dir / "c5-principal-scope.env.example"
|
||||
env_path.write_text(ENV_EXAMPLE, encoding="utf-8", newline="")
|
||||
activation_path = config_dir / "principal-activation.example.json"
|
||||
activation_path.write_text(
|
||||
json.dumps(ACTIVATION_EXAMPLE, sort_keys=True, separators=(",", ":")),
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
|
||||
manifest = {
|
||||
"schema": "aios.c5-task-principal-overlay.v1",
|
||||
"task_id": "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
"execution_digest": "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e",
|
||||
"files": outputs,
|
||||
"entrypoints": {
|
||||
"enrollment": "bin/aios-c5-principal-scope",
|
||||
"issuer": "python3 -B -m aios_task_principal_issuer_server",
|
||||
"bridge": "python3 -B -m aios_chatgpt_bridge_v2_server",
|
||||
},
|
||||
"runtime_imports": [
|
||||
"aios_single_host_authority.RegistryOwnerApprovalVerifier",
|
||||
"aios_single_host_authority.SignerRegistry",
|
||||
"registered_owner_signer_adapter.RegisteredOwnerSignerAdapter",
|
||||
],
|
||||
"activation": {
|
||||
"pythonpath_requirement": "prefix overlay/app for both issuer and Bridge",
|
||||
"same_admission_overlay_for_both_units": True,
|
||||
"environment_example": "config/c5-principal-scope.env.example",
|
||||
"activation_example": "config/principal-activation.example.json",
|
||||
"live_mutation_performed": False,
|
||||
},
|
||||
}
|
||||
manifest_path = output / "overlay-manifest.json"
|
||||
manifest_path.write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
|
||||
encoding="utf-8", newline="",
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,669 @@
|
||||
"""Build the unsigned, fixed-scope C5 staging installation artifact.
|
||||
|
||||
This module is deliberately a packager only. It does not import the live
|
||||
installer, invoke a signer, call systemd, or mutate a remote host. Code that
|
||||
belongs to the support commit is read with ``git show`` so a dirty checkout
|
||||
cannot silently change the artifact.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import tarfile
|
||||
import time
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
REPOSITORY = "aios/aios"
|
||||
RELEASE_BASE = "/var/lib/aios-gitops/bridge-v2-staging/releases"
|
||||
UNIT = "aios-chatgpt-bridge-v2-staging.service"
|
||||
BRIDGE_TARGET = "bridge-18892"
|
||||
ISSUER_TARGET = "principal-issuer-18946"
|
||||
ISSUER_UNIT = "aios-task-principal-issuer.service"
|
||||
DROPIN_NAME = "~" * 32 + "-c5-source.conf"
|
||||
DROPIN = f"/etc/systemd/system/{UNIT}.d/{DROPIN_NAME}"
|
||||
ISSUER_DROPIN = f"/etc/systemd/system/{ISSUER_UNIT}.d/{DROPIN_NAME}"
|
||||
RUNTIME_URL = "http://127.0.0.1:18892/v2/runtime"
|
||||
ISSUER_RUNTIME_URL = "http://127.0.0.1:18946/health"
|
||||
PORT = 18892
|
||||
ISSUER_PORT = 18946
|
||||
REQUEST_SCHEMA = "aios.c5-staging-installation-request.v2"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
MAX_TTL = 900
|
||||
DEFAULT_TIMEOUT = 3
|
||||
SOURCE_MODULES = {
|
||||
"active-runtime/app/source_publication_runtime.py":
|
||||
"app/source_publication_runtime.py",
|
||||
"active-runtime/app/governed_source_publication_policy.py":
|
||||
"app/governed_source_publication_policy.py",
|
||||
"active-runtime/app/registered_owner_signer_adapter.py":
|
||||
"app/registered_owner_signer_adapter.py",
|
||||
"active-runtime/app/aios_single_host_authority.py":
|
||||
"app/c5_source_authority.py",
|
||||
}
|
||||
GENERATED_OVERLAYS = (
|
||||
"aios_chatgpt_bridge_v2_server.py",
|
||||
"aios_task_admission_bootstrap.py",
|
||||
"aios_task_admission_live_adapters.py",
|
||||
)
|
||||
_HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
_HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_NONCE = re.compile(r"^[A-Za-z0-9._:-]{16,128}$")
|
||||
_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/-]{2,255}$")
|
||||
_SOURCE_MODES = {0o444, 0o640, 0o644, 0o755}
|
||||
|
||||
|
||||
class BuildError(ValueError):
|
||||
"""Stable fail-closed error raised before any output is written."""
|
||||
|
||||
|
||||
def _error(code: str) -> "NoReturn":
|
||||
raise BuildError(code)
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"),
|
||||
ensure_ascii=False, allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _strict_pairs(pairs: list[tuple[str, Any]]) -> dict[str, Any]:
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in pairs:
|
||||
if key in result:
|
||||
_error("JSON_DUPLICATE_FIELD")
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
|
||||
def _json(path: Path) -> tuple[dict[str, Any], bytes]:
|
||||
try:
|
||||
raw = path.read_bytes()
|
||||
value = json.loads(raw.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError(f"JSON_INPUT_INVALID:{path.name}") from exc
|
||||
if not isinstance(value, dict):
|
||||
_error("JSON_OBJECT_REQUIRED")
|
||||
return value, raw
|
||||
|
||||
|
||||
def _regular(path: Path, *, label: str) -> bytes:
|
||||
try:
|
||||
info = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BuildError(f"INPUT_MISSING:{label}") from exc
|
||||
if not info or not path.is_file() or path.is_symlink():
|
||||
_error(f"INPUT_NOT_REGULAR:{label}")
|
||||
try:
|
||||
return path.read_bytes()
|
||||
except OSError as exc:
|
||||
raise BuildError(f"INPUT_READ_FAILED:{label}") from exc
|
||||
|
||||
|
||||
def _safe_rel(value: str) -> PurePosixPath:
|
||||
if not isinstance(value, str) or not value or "\\" in value:
|
||||
_error("RELATIVE_PATH_INVALID")
|
||||
path = PurePosixPath(value)
|
||||
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
||||
_error("RELATIVE_PATH_INVALID")
|
||||
return path
|
||||
|
||||
|
||||
def _support_oid(repo: Path, support_commit: str) -> str:
|
||||
if not _HEX40.fullmatch(support_commit):
|
||||
_error("SUPPORT_COMMIT_INVALID")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), "rev-parse", "--verify", f"{support_commit}^{{commit}}"],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuildError("SUPPORT_COMMIT_NOT_FOUND") from exc
|
||||
if result.stdout.strip() != support_commit:
|
||||
_error("SUPPORT_COMMIT_NOT_EXACT")
|
||||
return support_commit
|
||||
|
||||
|
||||
def _git_blob(repo: Path, support_commit: str, path: str) -> bytes:
|
||||
_safe_rel(path)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(repo), "show", f"{support_commit}:{path}"],
|
||||
check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuildError(f"SUPPORT_BLOB_MISSING:{path}") from exc
|
||||
return bytes(result.stdout)
|
||||
|
||||
|
||||
def _mode(path: Path) -> int:
|
||||
mode = path.stat().st_mode
|
||||
return 0o755 if mode & 0o111 else 0o644
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArtifactFile:
|
||||
path: str
|
||||
data: bytes
|
||||
mode: int
|
||||
kind: str = "release_file"
|
||||
|
||||
@property
|
||||
def record(self) -> dict[str, Any]:
|
||||
return {
|
||||
"path": self.path,
|
||||
"sha256": _sha(self.data),
|
||||
"size": len(self.data),
|
||||
"mode": self.mode,
|
||||
"kind": self.kind,
|
||||
}
|
||||
|
||||
|
||||
def _root(support_commit: str) -> str:
|
||||
return f"{RELEASE_BASE}/c5-source-{support_commit}"
|
||||
|
||||
|
||||
def _read_existing(snapshot_path: Path, target: str = BRIDGE_TARGET) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
snapshot, _ = _json(snapshot_path)
|
||||
profiles = snapshot.get("profiles")
|
||||
if isinstance(profiles, Mapping):
|
||||
profile = profiles.get(target)
|
||||
if not isinstance(profile, Mapping):
|
||||
_error("EXISTING_SNAPSHOT_TARGET_MISSING")
|
||||
value = profile.get("existing", profile)
|
||||
selected_snapshot: dict[str, Any] = dict(profile)
|
||||
else:
|
||||
value = snapshot.get("existing", snapshot)
|
||||
selected_snapshot = dict(snapshot)
|
||||
if not isinstance(value, Mapping):
|
||||
_error("EXISTING_SNAPSHOT_INVALID")
|
||||
required = {
|
||||
"unit_active", "effective_config_sha256", "dropin", "pythonpath", "source_files",
|
||||
"activation_file", "principal_scope_config",
|
||||
}
|
||||
if not required.issubset(value):
|
||||
_error("EXISTING_SNAPSHOT_FIELDS_INVALID")
|
||||
# Keep only the installer-owned snapshot projection. Live capture files
|
||||
# also carry target/unit/readiness metadata; copying those keys into a
|
||||
# request would fail the typed request's exact-field check.
|
||||
existing = {key: value[key] for key in required}
|
||||
if existing["unit_active"] is not True:
|
||||
_error("EXISTING_UNIT_NOT_ACTIVE")
|
||||
if not isinstance(existing["effective_config_sha256"], str) or not _HEX64.fullmatch(existing["effective_config_sha256"]):
|
||||
_error("EXISTING_CONFIG_HASH_INVALID")
|
||||
pythonpath = existing["pythonpath"]
|
||||
if not isinstance(pythonpath, list) or not pythonpath or len(set(pythonpath)) != len(pythonpath):
|
||||
_error("EXISTING_PYTHONPATH_INVALID")
|
||||
for item in pythonpath:
|
||||
if not isinstance(item, str) or not item.startswith("/") or ".." in PurePosixPath(item).parts:
|
||||
_error("EXISTING_PYTHONPATH_INVALID")
|
||||
if value["activation_file"] is not None or value["principal_scope_config"] is not None:
|
||||
_error("EXISTING_ACTIVATION_ALREADY_PRESENT")
|
||||
dropin = existing["dropin"]
|
||||
if not isinstance(dropin, Mapping) or set(dropin) != {"exists", "sha256"}:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if type(dropin["exists"]) is not bool:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if dropin["exists"] is True and (not isinstance(dropin["sha256"], str) or not _HEX64.fullmatch(dropin["sha256"])):
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
if dropin["exists"] is False and dropin["sha256"] is not None:
|
||||
_error("EXISTING_DROPIN_INVALID")
|
||||
source_files = existing["source_files"]
|
||||
if not isinstance(source_files, list) or not source_files:
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
for record in source_files:
|
||||
if not isinstance(record, Mapping) or set(record) != {"path", "sha256", "size", "mode", "kind"}:
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
if (
|
||||
not isinstance(record["path"], str) or not record["path"].startswith("/")
|
||||
or any(part in {".", ".."} for part in PurePosixPath(record["path"]).parts)
|
||||
or "\\" in record["path"]
|
||||
or not _HEX64.fullmatch(str(record["sha256"]))
|
||||
or type(record["size"]) is not int or record["size"] < 0
|
||||
or record["mode"] not in _SOURCE_MODES or record["kind"] != "existing_source"
|
||||
):
|
||||
_error("EXISTING_SOURCE_FILES_INVALID")
|
||||
if not any(record["path"] != base and record["path"].startswith(base.rstrip("/") + "/") for base in pythonpath):
|
||||
_error("EXISTING_SOURCE_OUTSIDE_PYTHONPATH")
|
||||
return existing, selected_snapshot
|
||||
|
||||
|
||||
def _old_module(existing: Mapping[str, Any], target: str) -> dict[str, Any]:
|
||||
suffix = (
|
||||
"/aios_chatgpt_bridge_v2_server.py"
|
||||
if target == BRIDGE_TARGET else "/aios_task_principal_issuer_server.py"
|
||||
)
|
||||
for record in existing["source_files"]:
|
||||
if str(record["path"]).endswith(suffix):
|
||||
return dict(record)
|
||||
_error("EXISTING_MODULE_IDENTITY_MISSING")
|
||||
|
||||
|
||||
def _old_bridge(existing: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return _old_module(existing, BRIDGE_TARGET)
|
||||
|
||||
|
||||
def _authority_inputs(authority: Path, support_commit: str) -> tuple[list[ArtifactFile], str, dict[str, Any]]:
|
||||
if not authority.is_dir() or authority.is_symlink():
|
||||
_error("AUTHORITY_INPUT_DIRECTORY_INVALID")
|
||||
collected: list[ArtifactFile] = []
|
||||
for item in sorted(authority.rglob("*")):
|
||||
if item.is_symlink():
|
||||
_error("AUTHORITY_INPUT_SYMLINK")
|
||||
if item.is_dir():
|
||||
continue
|
||||
relative = item.relative_to(authority).as_posix()
|
||||
_safe_rel(relative)
|
||||
collected.append(ArtifactFile(f"__AUTHORITY__/{relative}", _regular(item, label=relative), _mode(item)))
|
||||
names = {item.path.removeprefix("__AUTHORITY__/") for item in collected}
|
||||
if "source-activation.json" not in names or "policy.json" not in names:
|
||||
_error("AUTHORITY_INPUTS_INCOMPLETE")
|
||||
result_item = next((item for item in collected if item.path == "__AUTHORITY__/AUTHORITY_PREPARATION_RESULT.json"), None)
|
||||
result: dict[str, Any] = {}
|
||||
if result_item is not None:
|
||||
result = json.loads(result_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
if not isinstance(result, dict):
|
||||
_error("AUTHORITY_RESULT_INVALID")
|
||||
policy_item = next(item for item in collected if item.path == "__AUTHORITY__/policy.json")
|
||||
try:
|
||||
policy = json.loads(policy_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError("AUTHORITY_POLICY_INVALID") from exc
|
||||
payload = policy.get("payload") if isinstance(policy, dict) else None
|
||||
source_commit = result.get("source_commit") if result else (payload or {}).get("source_oid")
|
||||
if not isinstance(source_commit, str) or not _HEX40.fullmatch(source_commit):
|
||||
_error("SOURCE_COMMIT_INVALID")
|
||||
if result and result.get("support_commit") != support_commit:
|
||||
_error("AUTHORITY_SUPPORT_COMMIT_MISMATCH")
|
||||
if isinstance(payload, Mapping) and payload.get("source_oid") != source_commit:
|
||||
_error("AUTHORITY_SOURCE_COMMIT_MISMATCH")
|
||||
root = _root(support_commit)
|
||||
activation_item = next(item for item in collected if item.path == "__AUTHORITY__/source-activation.json")
|
||||
try:
|
||||
activation = json.loads(activation_item.data.decode("utf-8"), object_pairs_hook=_strict_pairs)
|
||||
except (UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise BuildError("SOURCE_ACTIVATION_INVALID") from exc
|
||||
if not isinstance(activation, Mapping) or activation.get("schema") != "aios.source-publication-host-activation.v1":
|
||||
_error("SOURCE_ACTIVATION_INVALID")
|
||||
if activation.get("policy_sha256") != _sha(policy_item.data):
|
||||
_error("SOURCE_ACTIVATION_POLICY_HASH_MISMATCH")
|
||||
for key in ("policy_artifact", "policy_revocations", "admission_activation"):
|
||||
value = activation.get(key)
|
||||
if not isinstance(value, str) or not value.startswith(root + "/"):
|
||||
_error("SOURCE_ACTIVATION_PATH_INVALID")
|
||||
output: list[ArtifactFile] = []
|
||||
for item in collected:
|
||||
rel = item.path.removeprefix("__AUTHORITY__/")
|
||||
output.append(ArtifactFile(f"{root}/{rel}", item.data, item.mode))
|
||||
return output, source_commit, dict(activation)
|
||||
|
||||
|
||||
def _bridge_base_hash(repo: Path, support_commit: str) -> str:
|
||||
blob = _git_blob(repo, support_commit, "scripts/build_c5_live_bridge_overlay.py")
|
||||
tree = ast.parse(blob.decode("utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.Assign) and len(node.targets) == 1 and getattr(node.targets[0], "id", None) == "BASE_SHA256":
|
||||
value = ast.literal_eval(node.value)
|
||||
if isinstance(value, str) and _HEX64.fullmatch(value):
|
||||
return value
|
||||
_error("BRIDGE_BASE_IDENTITY_MISSING")
|
||||
|
||||
|
||||
def _verify_overlays(overlay_app: Path, repo: Path, support_commit: str) -> list[ArtifactFile]:
|
||||
if not overlay_app.is_dir() or overlay_app.is_symlink():
|
||||
_error("OVERLAY_DIRECTORY_INVALID")
|
||||
result_path = overlay_app.parent / "c5-epoch-v2-overlay.result.json"
|
||||
result, _ = _json(result_path)
|
||||
entries = result.get("files")
|
||||
if not isinstance(entries, Mapping):
|
||||
_error("EPOCH_OVERLAY_RESULT_INVALID")
|
||||
snapshot = overlay_app.parent.parent / "staging-source-snapshot"
|
||||
for name in ("aios_task_admission_bootstrap.py", "aios_task_admission_live_adapters.py"):
|
||||
entry = entries.get(name)
|
||||
source = snapshot / name
|
||||
target = overlay_app / name
|
||||
if not isinstance(entry, Mapping) or set(entry) != {"base_sha256", "base_bytes", "result_sha256", "result_bytes"}:
|
||||
_error("EPOCH_OVERLAY_RESULT_INVALID")
|
||||
base = _regular(source, label=f"overlay-base:{name}")
|
||||
data = _regular(target, label=f"overlay-result:{name}")
|
||||
if _sha(base) != entry["base_sha256"] or len(base) != entry["base_bytes"]:
|
||||
_error("EPOCH_OVERLAY_BASE_MISMATCH")
|
||||
if _sha(data) != entry["result_sha256"] or len(data) != entry["result_bytes"]:
|
||||
_error("EPOCH_OVERLAY_RESULT_MISMATCH")
|
||||
bridge_base = _regular(snapshot / "aios_chatgpt_bridge_v2_server.py", label="overlay-base:bridge")
|
||||
if _sha(bridge_base) != _bridge_base_hash(repo, support_commit):
|
||||
_error("BRIDGE_OVERLAY_BASE_MISMATCH")
|
||||
files: list[ArtifactFile] = []
|
||||
seen: set[str] = set()
|
||||
for path in sorted(overlay_app.rglob("*")):
|
||||
if path.is_symlink():
|
||||
_error("OVERLAY_SYMLINK")
|
||||
if path.is_dir():
|
||||
continue
|
||||
rel = path.relative_to(overlay_app).as_posix()
|
||||
_safe_rel(rel)
|
||||
if rel.endswith(".pyc") or "__pycache__" in PurePosixPath(rel).parts:
|
||||
_error("OVERLAY_BYTECODE_FORBIDDEN")
|
||||
if rel in seen:
|
||||
_error("OVERLAY_DUPLICATE")
|
||||
seen.add(rel)
|
||||
files.append(ArtifactFile(f"app/{rel}", _regular(path, label=f"overlay:{rel}"), _mode(path)))
|
||||
if not set(GENERATED_OVERLAYS).issubset(seen):
|
||||
_error("OVERLAY_FILE_SET_INCOMPLETE")
|
||||
# The principal builder emits a sibling result manifest. When present,
|
||||
# bind every reported result byte to the exact file that will be archived.
|
||||
principal_manifest = overlay_app.parent / "overlay-manifest.json"
|
||||
if principal_manifest.exists():
|
||||
manifest, _ = _json(principal_manifest)
|
||||
entries = manifest.get("files")
|
||||
if not isinstance(entries, Mapping) or not entries:
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_INVALID")
|
||||
by_name = {item.path.removeprefix("app/"): item for item in files}
|
||||
for name, entry in entries.items():
|
||||
if not isinstance(name, str) or not isinstance(entry, Mapping):
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_INVALID")
|
||||
target = by_name.get(name)
|
||||
if target is None:
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_MISSING")
|
||||
if entry.get("result_sha256") != _sha(target.data) or entry.get("bytes") != len(target.data):
|
||||
_error("PRINCIPAL_OVERLAY_RESULT_MISMATCH")
|
||||
return files
|
||||
|
||||
|
||||
def _rewrite_c5_imports(data: bytes, name: str) -> bytes:
|
||||
text = data.decode("utf-8")
|
||||
old = "from aios_single_host_authority import"
|
||||
new = "from c5_source_authority import"
|
||||
if text.count(old) < 1:
|
||||
_error(f"C5_IMPORT_ANCHOR_INVALID:{name}")
|
||||
return text.replace(old, new).encode("utf-8")
|
||||
|
||||
|
||||
def _dropin(
|
||||
root: str, pythonpath: Iterable[str], principal: str | None,
|
||||
*, target: str = BRIDGE_TARGET,
|
||||
) -> bytes:
|
||||
path = ":".join((f"{root}/app", *pythonpath))
|
||||
value = "[Service]\n" f"Environment=PYTHONPATH={path}\n"
|
||||
if target == BRIDGE_TARGET:
|
||||
value += f"Environment=AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE={root}/source-activation.json\n"
|
||||
elif target != ISSUER_TARGET:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
if principal is not None:
|
||||
value += f"Environment=AIOS_C5_PRINCIPAL_SCOPE_CONFIG={principal}\n"
|
||||
return value.encode("utf-8")
|
||||
|
||||
|
||||
def _captured_rollback(
|
||||
snapshot: Mapping[str, Any], existing: Mapping[str, Any], target: str,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Return the captured pre-activation readiness projection when present.
|
||||
|
||||
The live capture contains richer profile metadata around ``existing``;
|
||||
only the installer readiness object is carried into the request. Keep it
|
||||
byte-for-byte as captured after checking the fields that bind it to the
|
||||
selected existing snapshot.
|
||||
"""
|
||||
value = snapshot.get("rollback_readiness")
|
||||
if not isinstance(value, Mapping):
|
||||
if "existing" in snapshot:
|
||||
_error("ROLLBACK_READINESS_MISSING")
|
||||
return None
|
||||
required = {
|
||||
"url", "timeout_seconds", "unit_active", "pythonpath", "activation_file",
|
||||
"principal_scope_config", "module_path", "module_sha256", "runtime_fields",
|
||||
}
|
||||
if set(value) != required or not isinstance(value["runtime_fields"], Mapping):
|
||||
_error("ROLLBACK_READINESS_INVALID")
|
||||
profile_url = RUNTIME_URL if target == BRIDGE_TARGET else ISSUER_RUNTIME_URL
|
||||
if (
|
||||
value["url"] != profile_url or value["timeout_seconds"] != DEFAULT_TIMEOUT
|
||||
or value["unit_active"] is not True
|
||||
or value["pythonpath"] != existing["pythonpath"]
|
||||
or value["activation_file"] != existing["activation_file"]
|
||||
or value["principal_scope_config"] != existing["principal_scope_config"]
|
||||
):
|
||||
_error("ROLLBACK_READINESS_BINDING_INVALID")
|
||||
old = _old_module(existing, target)
|
||||
if value["module_path"] != old["path"] or value["module_sha256"] != old["sha256"]:
|
||||
_error("ROLLBACK_READINESS_BINDING_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _request(
|
||||
*, source_commit: str, support_commit: str, files: list[ArtifactFile], existing: Mapping[str, Any],
|
||||
root: str, activation: Mapping[str, Any], owner_signer_id: str, contract_id: str,
|
||||
nonce: str, ttl_seconds: int, issued_at: int, principal_path: str | None,
|
||||
target: str = BRIDGE_TARGET, rollback_readiness: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
module_name = (
|
||||
"aios_chatgpt_bridge_v2_server.py"
|
||||
if target == BRIDGE_TARGET else "aios_task_principal_issuer_server.py"
|
||||
)
|
||||
module = next((file for file in files if file.path == f"{root}/app/{module_name}"), None)
|
||||
if module is None:
|
||||
_error("TARGET_MODULE_MISSING")
|
||||
old_module = _old_module(existing, target)
|
||||
new_pythonpath = [f"{root}/app", *existing["pythonpath"]]
|
||||
new_module = f"{root}/app/{module_name}"
|
||||
new_activation = f"{root}/source-activation.json" if target == BRIDGE_TARGET else None
|
||||
new_principal = principal_path
|
||||
if target == BRIDGE_TARGET:
|
||||
mandatory = {"status": "active", "health": 200, "deployment_state": "STAGING", "repository": REPOSITORY,
|
||||
"governed_writes_enabled": True}
|
||||
runtime_url = RUNTIME_URL
|
||||
elif target == ISSUER_TARGET:
|
||||
mandatory = {"ready": True}
|
||||
if principal_path is not None:
|
||||
mandatory.update(c5_scope_enabled=True)
|
||||
runtime_url = ISSUER_RUNTIME_URL
|
||||
else:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
readiness_fields = {
|
||||
**mandatory, "source_module_path": new_module,
|
||||
"source_module_sha256": _sha(module.data),
|
||||
}
|
||||
old_fields = {"status": "active", "health": 200, "deployment_state": "STAGING", "repository": REPOSITORY} if target == BRIDGE_TARGET else {"ready": True}
|
||||
if target == ISSUER_TARGET and principal_path is not None:
|
||||
readiness_fields.update(c5_scope_role="issuer", c5_scope_enrolled=False)
|
||||
readiness = {
|
||||
"url": runtime_url, "timeout_seconds": DEFAULT_TIMEOUT, "unit_active": True,
|
||||
"pythonpath": new_pythonpath, "activation_file": new_activation,
|
||||
"principal_scope_config": new_principal,
|
||||
"module_path": new_module, "module_sha256": _sha(module.data),
|
||||
"runtime_fields": readiness_fields,
|
||||
}
|
||||
rollback = dict(rollback_readiness) if rollback_readiness is not None else {
|
||||
"url": runtime_url, "timeout_seconds": DEFAULT_TIMEOUT, "unit_active": True,
|
||||
"pythonpath": list(existing["pythonpath"]), "activation_file": existing["activation_file"],
|
||||
"principal_scope_config": existing["principal_scope_config"],
|
||||
"module_path": old_module["path"], "module_sha256": old_module["sha256"],
|
||||
"runtime_fields": old_fields,
|
||||
}
|
||||
return {
|
||||
"schema": REQUEST_SCHEMA,
|
||||
"target": target,
|
||||
"contract_id": contract_id, "owner_signer_id": owner_signer_id,
|
||||
"source_commit": source_commit, "support_commit": support_commit,
|
||||
"files": [file.record for file in sorted(files, key=lambda item: item.path)],
|
||||
"existing": dict(existing), "readiness": readiness,
|
||||
"rollback_readiness": rollback, "nonce": nonce, "ttl_seconds": ttl_seconds,
|
||||
}
|
||||
|
||||
|
||||
def build_artifact(
|
||||
repo: Path, support_commit: str, overlay_app: Path, authority_inputs: Path,
|
||||
snapshot: Path, output: Path, *, owner_signer_id: str = "local-owner-authority",
|
||||
contract_id: str | None = None, nonce: str | None = None, ttl_seconds: int = MAX_TTL,
|
||||
issued_at: int | None = None, principal_scope_config: Path | None = None,
|
||||
target: str = BRIDGE_TARGET,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the artifact and unsigned request, returning their paths and hashes."""
|
||||
repo, overlay_app, authority_inputs, snapshot, output = map(Path, (repo, overlay_app, authority_inputs, snapshot, output))
|
||||
if not repo.is_dir() or repo.is_symlink():
|
||||
_error("REPOSITORY_INVALID")
|
||||
if target not in {BRIDGE_TARGET, ISSUER_TARGET}:
|
||||
_error("INSTALL_TARGET_INVALID")
|
||||
support_commit = _support_oid(repo, support_commit)
|
||||
if not _ID.fullmatch(owner_signer_id):
|
||||
_error("OWNER_SIGNER_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 1 <= ttl_seconds <= MAX_TTL:
|
||||
_error("TTL_INVALID")
|
||||
nonce = secrets.token_hex(32) if nonce is None else nonce
|
||||
if not isinstance(nonce, str) or not _NONCE.fullmatch(nonce):
|
||||
_error("NONCE_INVALID")
|
||||
issued_at = int(time.time()) if issued_at is None else issued_at
|
||||
if type(issued_at) is not int or issued_at < 0:
|
||||
_error("ISSUED_AT_INVALID")
|
||||
if principal_scope_config is not None and (principal_scope_config.is_symlink() or not principal_scope_config.is_file()):
|
||||
_error("PRINCIPAL_CONFIG_INVALID")
|
||||
existing, snapshot_value = _read_existing(snapshot, target)
|
||||
authority_files, source_commit, activation = _authority_inputs(authority_inputs, support_commit)
|
||||
overlay_files = _verify_overlays(overlay_app, repo, support_commit)
|
||||
rewritten_overlays: list[ArtifactFile] = []
|
||||
for item in overlay_files:
|
||||
if item.path == "app/c5_governed_principal_scope.py":
|
||||
rewritten_overlays.append(
|
||||
ArtifactFile(item.path, _rewrite_c5_imports(item.data, item.path), item.mode, item.kind)
|
||||
)
|
||||
else:
|
||||
rewritten_overlays.append(item)
|
||||
overlay_files = rewritten_overlays
|
||||
root = _root(support_commit)
|
||||
c5_files: list[ArtifactFile] = []
|
||||
for source_path, destination in sorted(SOURCE_MODULES.items()):
|
||||
data = _git_blob(repo, support_commit, source_path)
|
||||
if destination in {"app/source_publication_runtime.py", "app/governed_source_publication_policy.py"}:
|
||||
data = _rewrite_c5_imports(data, destination)
|
||||
c5_files.append(ArtifactFile(f"{root}/{destination}", data, 0o644))
|
||||
files: list[ArtifactFile] = []
|
||||
files.extend(authority_files)
|
||||
files.extend(ArtifactFile(f"{root}/{item.path}", item.data, item.mode) for item in overlay_files)
|
||||
files.extend(c5_files)
|
||||
principal_path: str | None = next(
|
||||
(item.path for item in files if item.path == f"{root}/principal-activation.json"),
|
||||
None,
|
||||
)
|
||||
if principal_scope_config is not None:
|
||||
data = _regular(principal_scope_config, label="principal-scope-config")
|
||||
principal_path = f"{root}/principal-activation.json"
|
||||
if any(item.path == principal_path for item in files):
|
||||
_error("PRINCIPAL_CONFIG_DUPLICATE")
|
||||
files.append(ArtifactFile(principal_path, data, 0o644))
|
||||
if target == ISSUER_TARGET and principal_path is None:
|
||||
_error("PRINCIPAL_CONFIG_REQUIRED")
|
||||
dropin_path = DROPIN if target == BRIDGE_TARGET else ISSUER_DROPIN
|
||||
dropin_data = _dropin(root, existing["pythonpath"], principal_path, target=target)
|
||||
files.append(ArtifactFile(dropin_path, dropin_data, 0o644, "systemd_dropin"))
|
||||
names = [item.path for item in files]
|
||||
if len(names) != len(set(names)):
|
||||
_error("ARTIFACT_DUPLICATE_PATH")
|
||||
if not contract_id:
|
||||
contract_id = f"c5-installation-{support_commit[:12]}-{nonce[:12]}"
|
||||
if not _ID.fullmatch(contract_id):
|
||||
_error("CONTRACT_ID_INVALID")
|
||||
request = _request(
|
||||
source_commit=source_commit, support_commit=support_commit, files=files,
|
||||
existing=existing, root=root, activation=activation,
|
||||
owner_signer_id=owner_signer_id, contract_id=contract_id,
|
||||
nonce=nonce, ttl_seconds=ttl_seconds, issued_at=issued_at,
|
||||
principal_path=principal_path, target=target,
|
||||
rollback_readiness=_captured_rollback(snapshot_value, existing, target),
|
||||
)
|
||||
manifest = {
|
||||
"schema": "aios.c5-staging-artifact-manifest.v1",
|
||||
"repository": REPOSITORY, "source_commit": source_commit,
|
||||
"support_commit": support_commit, "release_root": root,
|
||||
"target": target,
|
||||
"unit": UNIT if target == BRIDGE_TARGET else ISSUER_UNIT,
|
||||
"listen": {"host": "127.0.0.1", "port": PORT if target == BRIDGE_TARGET else ISSUER_PORT},
|
||||
"files": [item.record for item in sorted(files, key=lambda item: item.path)],
|
||||
}
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
suffix = "" if target == BRIDGE_TARGET else "-principal-issuer"
|
||||
artifact_path = output / f"c5-source-{support_commit}{suffix}.tar"
|
||||
request_path = output / ("installation-request.json" if target == BRIDGE_TARGET else "principal-issuer-installation-request.json")
|
||||
manifest_path = output / "artifact-manifest.json"
|
||||
sums_path = output / "SHA256SUMS"
|
||||
if any(path.exists() for path in (artifact_path, request_path, manifest_path, sums_path)):
|
||||
_error("OUTPUT_ALREADY_EXISTS")
|
||||
with tarfile.open(artifact_path, "w", format=tarfile.PAX_FORMAT) as archive:
|
||||
for item in sorted(files, key=lambda value: value.path):
|
||||
member_name = "dropin.conf" if item.path == dropin_path else item.path.removeprefix(root + "/")
|
||||
info = tarfile.TarInfo(member_name)
|
||||
info.type = tarfile.REGTYPE
|
||||
info.mode = item.mode
|
||||
info.uid = info.gid = 0
|
||||
info.uname = info.gname = ""
|
||||
info.mtime = 0
|
||||
info.size = len(item.data)
|
||||
archive.addfile(info, io.BytesIO(item.data))
|
||||
manifest_path.write_bytes(_canonical(manifest))
|
||||
request_path.write_bytes(_canonical(request))
|
||||
sums = "".join(
|
||||
f"{_sha(path.read_bytes())} {path.name}\n"
|
||||
for path in (artifact_path, manifest_path, request_path)
|
||||
).encode("ascii")
|
||||
sums_path.write_bytes(sums)
|
||||
return {
|
||||
"schema": "aios.c5-staging-artifact-build-result.v1",
|
||||
"status": "BUILT_UNSIGNED",
|
||||
"artifact": str(artifact_path), "artifact_sha256": _sha(artifact_path.read_bytes()),
|
||||
"request": str(request_path), "request_sha256": _sha(request_path.read_bytes()),
|
||||
"manifest": str(manifest_path), "sha256_manifest": str(sums_path),
|
||||
"source_commit": source_commit, "support_commit": support_commit,
|
||||
"release_root": root, "signed": False, "installed": False,
|
||||
}
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repo", "--repository", dest="repo", type=Path, required=True)
|
||||
parser.add_argument("--support-commit", required=True)
|
||||
parser.add_argument("--overlay-app", "--overlay", dest="overlay_app", type=Path, required=True)
|
||||
parser.add_argument("--authority-inputs", "--authority-input-dir", dest="authority_inputs", type=Path, required=True)
|
||||
parser.add_argument("--snapshot", "--unit-snapshot", dest="snapshot", type=Path, required=True)
|
||||
parser.add_argument("--output", "--output-dir", dest="output", type=Path, required=True)
|
||||
parser.add_argument("--owner-signer-id", default="local-owner-authority")
|
||||
parser.add_argument("--contract-id")
|
||||
parser.add_argument("--nonce")
|
||||
parser.add_argument("--ttl-seconds", type=int, default=MAX_TTL)
|
||||
parser.add_argument("--issued-at", type=int)
|
||||
parser.add_argument("--principal-scope-config", type=Path)
|
||||
parser.add_argument("--target", choices=(BRIDGE_TARGET, ISSUER_TARGET), default=BRIDGE_TARGET)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
result = build_artifact(
|
||||
args.repo, args.support_commit, args.overlay_app, args.authority_inputs,
|
||||
args.snapshot, args.output, owner_signer_id=args.owner_signer_id,
|
||||
contract_id=args.contract_id, nonce=args.nonce,
|
||||
ttl_seconds=args.ttl_seconds, issued_at=args.issued_at,
|
||||
principal_scope_config=args.principal_scope_config, target=args.target,
|
||||
)
|
||||
except (BuildError, OSError, ValueError) as exc:
|
||||
print(json.dumps({"status": "REJECTED", "error": str(exc)}, sort_keys=True))
|
||||
return 2
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Issue, preflight, or apply the fixed-scope C5 staging installation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
|
||||
from c5_staging_installation import ( # noqa: E402
|
||||
InstallationError,
|
||||
InstallationOutcomeUnknown,
|
||||
Installer,
|
||||
RegistryOwnerVerifier,
|
||||
canonical_bytes,
|
||||
issue_signed_contract,
|
||||
load_installation_request,
|
||||
)
|
||||
from registered_owner_signer_adapter import ( # noqa: E402
|
||||
DEFAULT_REGISTERED_SIGNER_COMMAND,
|
||||
RegisteredOwnerSignerAdapter,
|
||||
)
|
||||
|
||||
DEFAULT_REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
|
||||
|
||||
def _write_new(path: Path, value: dict) -> None:
|
||||
data = canonical_bytes(value) + b"\n"
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
fd = os.open(path, flags, 0o600)
|
||||
try:
|
||||
view = memoryview(data)
|
||||
while view:
|
||||
view = view[os.write(fd, view) :]
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def _parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser()
|
||||
commands = parser.add_subparsers(dest="command", required=True)
|
||||
for name in ("preflight", "apply", "reconcile-unknown", "rollback-applied"):
|
||||
command = commands.add_parser(name)
|
||||
command.add_argument("--contract", type=Path, required=True)
|
||||
command.add_argument("--artifact", type=Path, required=True)
|
||||
command.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY)
|
||||
if name == "rollback-applied":
|
||||
command.add_argument("--issuer-contract", type=Path)
|
||||
command.add_argument("--issuer-artifact", type=Path)
|
||||
issue = commands.add_parser("issue")
|
||||
issue.add_argument("--request", type=Path, required=True)
|
||||
issue.add_argument("--artifact", type=Path, required=True)
|
||||
issue.add_argument("--registry", type=Path, default=DEFAULT_REGISTRY)
|
||||
issue.add_argument("--output", type=Path, required=True)
|
||||
issue.add_argument("--signer-command", type=Path, default=DEFAULT_REGISTERED_SIGNER_COMMAND)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _parser().parse_args(argv)
|
||||
try:
|
||||
if args.command == "issue":
|
||||
request = load_installation_request(args.request)
|
||||
verifier = RegistryOwnerVerifier(
|
||||
args.registry, owner_signer_id=request.owner_signer_id
|
||||
)
|
||||
signer = RegisteredOwnerSignerAdapter(
|
||||
args.signer_command,
|
||||
key_id=verifier.record.fingerprint,
|
||||
owner_verifier=verifier,
|
||||
)
|
||||
contract = issue_signed_contract(
|
||||
request, args.artifact, signer, args.registry
|
||||
)
|
||||
_write_new(args.output, contract)
|
||||
result = {
|
||||
"status": "ISSUED",
|
||||
"contract_id": contract["payload"]["contract_id"],
|
||||
"output": str(args.output),
|
||||
}
|
||||
else:
|
||||
installer = Installer(args.registry)
|
||||
if args.command == "apply":
|
||||
result = installer.apply(args.contract, args.artifact)
|
||||
elif args.command == "reconcile-unknown":
|
||||
result = installer.reconcile_unknown(args.contract, args.artifact)
|
||||
elif args.command == "rollback-applied":
|
||||
result = installer.rollback_applied(
|
||||
args.contract, args.artifact,
|
||||
issuer_contract=args.issuer_contract,
|
||||
issuer_artifact=args.issuer_artifact,
|
||||
)
|
||||
else:
|
||||
result = installer.preflight(args.contract, args.artifact)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
except InstallationOutcomeUnknown as exc:
|
||||
print(
|
||||
json.dumps({"status": "UNKNOWN", "error": exc.code}, sort_keys=True),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 3
|
||||
except (InstallationError, OSError, ValueError, TypeError) as exc:
|
||||
print(
|
||||
json.dumps(
|
||||
{"status": "REJECTED", "error": getattr(exc, "code", str(exc))},
|
||||
sort_keys=True,
|
||||
),
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,189 @@
|
||||
"""Isolated composition for the C5 simulation CLI, never a production adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from aios_single_host_authority import (
|
||||
LocalOwnerAuthority, OwnerEpochRequest, RegistryOwnerApprovalVerifier,
|
||||
SingleHostOwnerPolicy, SourcePublicationPolicyRequest,
|
||||
bootstrap_key, canonical_json_bytes, write_signed_epoch_directory,
|
||||
write_signer_registry,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionBootstrapService, TaskAdmissionPolicy, TaskAdmissionStore
|
||||
from aios_task_admission_live_adapters import (
|
||||
AgentTaskAdmissionPort, BoundOwnerAuthorizationBackend, SignedEpochDirectoryProvider,
|
||||
TaskAdmissionPrincipalProvider, TaskAdmissionRuntimeBundle,
|
||||
)
|
||||
from governed_source_publication_policy import SourcePublicationPolicyVerifier, ScopedSourcePublicationPolicyAdmission
|
||||
from governed_unattended_release import SourcePublicationIntent, GovernedUnattendedReleaseRunner
|
||||
from source_publication_runtime import guard_source_runtime
|
||||
|
||||
|
||||
def _write(path: Path, value: dict) -> str:
|
||||
raw = canonical_json_bytes(value)
|
||||
path.write_bytes(raw)
|
||||
path.chmod(0o600)
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
|
||||
def signed_admission(tmp: Path, output: Path, source: dict, execution: dict, transport_type) -> dict:
|
||||
now = int(time.time())
|
||||
nonce = secrets.token_hex(24)
|
||||
caps = frozenset(source["requested_capabilities"])
|
||||
files = tuple(source["file_allowlist"])
|
||||
# These new keys never leave TemporaryDirectory and are not loaded from any
|
||||
# host registry, credential store or production environment variable.
|
||||
key_root = tmp / "ephemeral-keys"
|
||||
key_root.mkdir(mode=0o700)
|
||||
owner_key = key_root / "owner.pem"
|
||||
machine_key = key_root / "machine.pem"
|
||||
owner_public = bootstrap_key(owner_key, require_root_owner=False)
|
||||
machine_public = bootstrap_key(machine_key, require_root_owner=False)
|
||||
registry = tmp / "simulation-signers.json"
|
||||
write_signer_registry(
|
||||
registry, owner_public_key=owner_public, machine_public_key=machine_public,
|
||||
authority_generation=1, require_root_owner=False,
|
||||
)
|
||||
owner_policy = SingleHostOwnerPolicy(
|
||||
repository=source["repository"], trust_domain="aios.simulation",
|
||||
allowed_branch_prefixes=(source["target_branch"],), allowed_capabilities=caps,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering",
|
||||
production_scope="none", destructive_policy="forbidden",
|
||||
maximum_epoch_ttl_seconds=3600, maximum_machine_capability_ttl_seconds=300,
|
||||
allowed_caller_uids=(os.getuid(),),
|
||||
)
|
||||
owner_policy.validate()
|
||||
authority = LocalOwnerAuthority(owner_key, owner_policy, require_root_owner=False)
|
||||
request = SourcePublicationPolicyRequest(
|
||||
policy_id="C5_SOURCE_PUBLICATION_ONLY", repository=source["repository"],
|
||||
authority_base_oid=source["authority_base_oid"], source_oid=source["source_commit"],
|
||||
source_tree_oid=source["tree_oid"], target_agent="agent/c5-simulation-controller",
|
||||
target_branch=source["target_branch"], generation=source["generation"],
|
||||
execution_digest=source["execution_digest"], exact_files=files,
|
||||
nonce=nonce, ttl_seconds=3600,
|
||||
)
|
||||
signed_policy = authority.issue_source_publication_policy(request)
|
||||
epoch = authority.issue_epoch(OwnerEpochRequest(
|
||||
epoch_id="c5-simulation-epoch-" + nonce,
|
||||
allowed_branch_prefixes=(source["target_branch"],), allowed_capabilities=caps,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering",
|
||||
business_goal="Isolated C5 source publication simulation only",
|
||||
production_scope="none", destructive_policy="forbidden", ttl_seconds=3600,
|
||||
))
|
||||
epoch_root = tmp / "epoch"
|
||||
write_signed_epoch_directory(epoch_root, epoch, require_root_owner=False)
|
||||
epoch_revocations = tmp / "epoch-revocations.json"
|
||||
policy_revocations = tmp / "policy-revocations.json"
|
||||
_write(epoch_revocations, {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": []})
|
||||
_write(policy_revocations, {"revoked_nonces": []})
|
||||
owner_verifier = RegistryOwnerApprovalVerifier(registry, require_root_owner=False)
|
||||
caller = {"service": "c5-simulation-controller", "trust_domain": "aios.simulation"}
|
||||
provider = SignedEpochDirectoryProvider(
|
||||
root=epoch_root, verifier=owner_verifier, revocation_file=epoch_revocations,
|
||||
expected_caller=caller, require_root_owner=False,
|
||||
)
|
||||
verified_epoch = provider(epoch_id=epoch.payload["epoch_id"], caller=caller)
|
||||
if verified_epoch.schema != "aios.owner-delegation-epoch.v2" or verified_epoch.policy_digest != owner_policy.digest:
|
||||
raise RuntimeError("SIMULATION_EPOCH_PROVENANCE_MISMATCH")
|
||||
authorization = BoundOwnerAuthorizationBackend(
|
||||
authenticated_caller=caller, verified_epoch_provider=provider, signature_verifier=owner_verifier,
|
||||
)
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
owner_verifier,
|
||||
revocation_check=lambda payload: payload["nonce"] not in json.loads(policy_revocations.read_text())["revoked_nonces"],
|
||||
expected_policy_digest=owner_policy.digest,
|
||||
)
|
||||
verifier.require(signed_policy, request)
|
||||
transport = transport_type(tmp / "isolated-tasks.sqlite3")
|
||||
port = AgentTaskAdmissionPort(transport, execution_plan_provider=lambda grant: {
|
||||
"external_job_id": "simulation-job-" + nonce,
|
||||
"repository": grant.repository, "target_branch": grant.target_branch,
|
||||
"generation": grant.generation, "execution_digest": grant.execution_digest,
|
||||
"simulation_plan": execution,
|
||||
})
|
||||
bootstrap = TaskAdmissionBootstrapService(
|
||||
store=TaskAdmissionStore(tmp / "isolated-admission.sqlite3"),
|
||||
authorization=authorization, task_port=port,
|
||||
policy=TaskAdmissionPolicy(
|
||||
repository=owner_policy.repository, trust_domain=owner_policy.trust_domain,
|
||||
branch_prefix=source["target_branch"], allowed_capabilities=caps,
|
||||
max_file_count=len(files), max_risk="engineering", policy_digest=owner_policy.digest,
|
||||
),
|
||||
)
|
||||
principal = TaskAdmissionPrincipalProvider(
|
||||
store=bootstrap.store, authorization=authorization, task_port=port,
|
||||
epoch_id=epoch.payload["epoch_id"],
|
||||
)
|
||||
guarded = guard_source_runtime(
|
||||
TaskAdmissionRuntimeBundle(handlers={}, principal_verifier=principal, service=bootstrap),
|
||||
artifact=signed_policy, verifier=verifier,
|
||||
expected_agent_id="agent/c5-simulation-controller",
|
||||
).service
|
||||
guarded.simulation_only = True # this composition contains only the isolated TaskStore
|
||||
intent = SourcePublicationIntent(
|
||||
repository=source["repository"], source_commit=source["source_commit"],
|
||||
source_tree_oid=source["tree_oid"], authority_base_oid=source["authority_base_oid"],
|
||||
target_branch=source["target_branch"], file_allowlist=files,
|
||||
requested_capabilities=tuple(sorted(caps)), risk="engineering",
|
||||
execution_digest=source["execution_digest"], generation=source["generation"],
|
||||
event_id="c5-simulation", version="V2R2C5",
|
||||
)
|
||||
runner = GovernedUnattendedReleaseRunner(admission=guarded)
|
||||
auth_request = {"authorization_kind": "delegation_epoch", "delegation_epoch_id": epoch.payload["epoch_id"]}
|
||||
first = runner.admit(intent, auth_request)
|
||||
replay = runner.admit(intent, auth_request)
|
||||
if first.get("phase") != "ADMITTED" or replay.get("phase") != "ADMITTED":
|
||||
raise RuntimeError("SIMULATION_ADMISSION_FAILED:" + json.dumps({"first": first, "replay": replay}))
|
||||
receipt = first["receipt"]
|
||||
if replay["receipt"].get("idempotent") is not True or transport.creates != 1:
|
||||
raise RuntimeError("SIMULATION_IDEMPOTENCY_FAILED")
|
||||
raw_request = intent.admission_request()
|
||||
try:
|
||||
guarded.create_envelope({**raw_request, "target_branch": "agent/out-of-policy"})
|
||||
except Exception:
|
||||
widening_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_SCOPE_WIDENING_WAS_ALLOWED")
|
||||
_write(policy_revocations, {"revoked_nonces": [nonce]})
|
||||
try:
|
||||
guarded.authorize_and_admit({**auth_request, "envelope_id": receipt["envelope_id"]})
|
||||
except Exception:
|
||||
revoked_policy_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_REVOKED_POLICY_WAS_ALLOWED")
|
||||
_write(policy_revocations, {"revoked_nonces": []})
|
||||
_write(epoch_revocations, {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": [epoch.payload["epoch_id"]]})
|
||||
try:
|
||||
guarded.authorize_and_admit({**auth_request, "envelope_id": receipt["envelope_id"]})
|
||||
except Exception:
|
||||
revoked_epoch_denied = True
|
||||
else:
|
||||
raise RuntimeError("SIMULATION_REVOKED_EPOCH_WAS_ALLOWED")
|
||||
_write(output / "RELEASE_INTENT.simulation.json", runner.intake(intent))
|
||||
receipt_sha = _write(output / "ADMISSION_RECEIPT.simulation.json", {"environment": "SIMULATION", "receipt": receipt})
|
||||
policy_sha = _write(output / "POLICY_RECEIPT.simulation.json", {
|
||||
"environment": "SIMULATION", "payload": dict(signed_policy.payload),
|
||||
"signature": signed_policy.signature, "key_id": signed_policy.key_id,
|
||||
"namespace": signed_policy.namespace, "payload_sha256": signed_policy.payload_sha256,
|
||||
})
|
||||
for name in ("owner-delegation-epoch.canonical.json", "owner-delegation-epoch.sig", "owner-delegation-epoch.key-id"):
|
||||
(output / ("SIMULATION_" + name)).write_bytes((epoch_root / name).read_bytes())
|
||||
(output / "SIMULATION_signer_registry.json").write_bytes(registry.read_bytes())
|
||||
return {
|
||||
"mode": "SIMULATION", "task_id": receipt["task_id"], "task_state": receipt["task_state"],
|
||||
"receipt_sha256": receipt_sha, "policy_receipt_sha256": policy_sha,
|
||||
"epoch_payload_sha256": epoch.payload_sha256, "epoch_schema": verified_epoch.schema,
|
||||
"issuer_key_id": epoch.key_id, "policy_digest": owner_policy.digest,
|
||||
"canonical_signature_verified": True, "idempotent_replay": True,
|
||||
"task_creations": transport.creates, "scope_widening_denied": widening_denied,
|
||||
"revoked_policy_denied": revoked_policy_denied, "revoked_epoch_denied": revoked_epoch_denied,
|
||||
"started_at": now, "ended_at": int(time.time()),
|
||||
"task_lease": None, "task_principal": None, "writer_lease": None,
|
||||
"authority_source_commit": None,
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Issue exact C5 source authority through the registered Owner signer.
|
||||
|
||||
Output is an immutable installation input. This command does not install files,
|
||||
activate policy, submit admission, or grant release/deployment permission.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "active-runtime" / "app"))
|
||||
from aios_single_host_authority import (
|
||||
LocalOwnerAuthority, OwnerEpochRequest, RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry, SingleHostOwnerPolicy, SourcePublicationPolicyRequest,
|
||||
canonical_json_bytes, write_signed_epoch_directory, _open_secure,
|
||||
)
|
||||
from registered_owner_signer_adapter import RegisteredOwnerSignerAdapter
|
||||
|
||||
SOURCE = "536a2af0f07962495c88526061ce43fb27994c15"
|
||||
TREE = "9f27f76e7fa5561f90ff8bfe26fd18865b4a5ebb"
|
||||
BASE = "e5f8209f57b43fc39e55c4555776e6362ce326eb"
|
||||
PLAN_SHA = "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e"
|
||||
TASK = "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1"
|
||||
BRANCH = "agent/v2r2c5-source-publication-20260926"
|
||||
AGENT = "agent/codex-c5-release"
|
||||
CAPS = frozenset(("agent-task:control", "governed.task.create", "governed.write.files",
|
||||
"task.principal.issue", "writer.lease"))
|
||||
REGISTRY = Path("/etc/aios/single-host-signer-registry.json")
|
||||
|
||||
|
||||
def prepare(candidate: Path, plan_path: Path, ordinary_activation: Path,
|
||||
support_commit: str, output: Path) -> dict:
|
||||
if os.geteuid() != 0 or not re.fullmatch(r"[0-9a-f]{40}", support_commit):
|
||||
raise ValueError("ROOT_AND_EXACT_SUPPORT_COMMIT_REQUIRED")
|
||||
|
||||
def git(*args):
|
||||
return subprocess.run(["git", "-C", str(candidate), *args], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30).stdout
|
||||
|
||||
if git("rev-parse", SOURCE + "^{tree}").decode().strip() != TREE:
|
||||
raise ValueError("CANDIDATE_TREE_MISMATCH")
|
||||
git("merge-base", "--is-ancestor", BASE, SOURCE)
|
||||
raw = plan_path.read_bytes()
|
||||
plan = json.loads(raw)
|
||||
if hashlib.sha256(raw).hexdigest() != PLAN_SHA or canonical_json_bytes(plan) != raw:
|
||||
raise ValueError("EXACT_PLAN_REQUIRED")
|
||||
expected = {"schema": "aios.task-admission-execution-plan.v3", "task_id": TASK,
|
||||
"repository": "aios/aios", "authority_base_oid": BASE,
|
||||
"target_branch": BRANCH, "generation": 1, "risk": "engineering"}
|
||||
if any(plan.get(k) != v for k, v in expected.items()) or set(plan["capabilities"]) != CAPS:
|
||||
raise ValueError("PLAN_SCOPE_MISMATCH")
|
||||
files = tuple(sorted(git("diff", "--name-only", "-z", BASE, SOURCE).decode().rstrip("\0").split("\0")))
|
||||
hashes = {p: hashlib.sha256(git("show", TREE + ":" + p)).hexdigest() for p in files}
|
||||
if hashes != plan["file_sha256"] or set(files) != set(plan["file_allowlist"]):
|
||||
raise ValueError("PLAN_COMMITTED_BYTES_MISMATCH")
|
||||
ordinary_raw = _open_secure(ordinary_activation, private=False, require_root_owner=True, maximum=1024 * 1024)
|
||||
activation = json.loads(ordinary_raw)
|
||||
if activation.get("schema") != "aios.task-admission-host-activation.v1":
|
||||
raise ValueError("ORDINARY_ACTIVATION_REQUIRED")
|
||||
# No existing authority policy is rewritten. This separate exact policy is
|
||||
# enforced by the source router and signed by the existing registered Root.
|
||||
policy = SingleHostOwnerPolicy(repository="aios/aios", trust_domain="aios.owner",
|
||||
allowed_branch_prefixes=(BRANCH,), allowed_capabilities=CAPS,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering", production_scope="none",
|
||||
destructive_policy="forbidden", maximum_epoch_ttl_seconds=3600,
|
||||
maximum_machine_capability_ttl_seconds=300, allowed_caller_uids=(0,))
|
||||
registry = SignerRegistry(REGISTRY)
|
||||
record = registry.require("local-owner-authority", role="OWNER_AUTHORITY")
|
||||
verifier = RegistryOwnerApprovalVerifier(REGISTRY)
|
||||
adapter = RegisteredOwnerSignerAdapter(key_id=record.fingerprint, owner_verifier=verifier)
|
||||
authority = LocalOwnerAuthority(None, policy, registered_signer=adapter)
|
||||
nonce = secrets.token_hex(32)
|
||||
request = SourcePublicationPolicyRequest(policy_id="C5_SOURCE_PUBLICATION_ONLY",
|
||||
repository="aios/aios", authority_base_oid=BASE, source_oid=SOURCE,
|
||||
source_tree_oid=TREE, target_agent=AGENT, target_branch=BRANCH, generation=1,
|
||||
execution_digest=PLAN_SHA, exact_files=files, nonce=nonce, ttl_seconds=3600)
|
||||
signed_policy = authority.issue_source_publication_policy(request)
|
||||
epoch = authority.issue_epoch(OwnerEpochRequest(epoch_id="c5-source-" + nonce,
|
||||
allowed_branch_prefixes=(BRANCH,), allowed_capabilities=CAPS,
|
||||
allowed_file_prefixes=files, risk_ceiling="engineering",
|
||||
business_goal="V2R2C5 exact governed source publication only",
|
||||
production_scope="none", destructive_policy="forbidden", ttl_seconds=3600))
|
||||
output.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
final_root = Path("/var/lib/aios-gitops/bridge-v2-staging/releases") / ("c5-source-" + support_commit)
|
||||
|
||||
def write(relative, value):
|
||||
path = output / relative
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
path.write_bytes(canonical_json_bytes(value))
|
||||
path.chmod(0o600)
|
||||
|
||||
write("policy.json", {"payload": dict(signed_policy.payload), "signature": signed_policy.signature,
|
||||
"key_id": signed_policy.key_id, "namespace": signed_policy.namespace})
|
||||
write_signed_epoch_directory(output / "epoch", epoch)
|
||||
write("epoch-revocations.json", {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": []})
|
||||
write("policy-revocations.json", {"schema": "aios.source-publication-revocations.v1", "revoked_nonces": []})
|
||||
write("plans/" + PLAN_SHA + ".json", plan)
|
||||
activation.update(epoch_root=str(final_root / "epoch"), epoch_id=epoch.payload["epoch_id"],
|
||||
revocation_file=str(final_root / "epoch-revocations.json"), execution_plan_dir=str(final_root / "plans"))
|
||||
write("admission-activation.json", activation)
|
||||
write("source-activation.json", {"schema": "aios.source-publication-host-activation.v1",
|
||||
"policy_artifact": str(final_root / "policy.json"),
|
||||
"policy_sha256": hashlib.sha256((output / "policy.json").read_bytes()).hexdigest(),
|
||||
"policy_revocations": str(final_root / "policy-revocations.json"),
|
||||
"admission_activation": str(final_root / "admission-activation.json"), "expected_agent_id": AGENT})
|
||||
grant_info = Path(activation["state_db"]).stat()
|
||||
write("principal-activation.json", {
|
||||
"schema": "aios.c5-task-principal-activation.v1",
|
||||
"task_id": TASK, "agent_id": AGENT, "execution_digest": PLAN_SHA,
|
||||
"store_path": "/var/lib/aios-gitops/c5-source-principal/enrollment.sqlite3",
|
||||
"grant_db": activation["state_db"],
|
||||
"plan_dir": str(final_root / "plans"),
|
||||
"producer_state_root": "/var/lib/aios-gitops/bridge-v2-staging",
|
||||
"policy_artifact": str(final_root / "policy.json"),
|
||||
"policy_revocations": str(final_root / "policy-revocations.json"),
|
||||
"epoch_root": str(final_root / "epoch"),
|
||||
"epoch_revocations": str(final_root / "epoch-revocations.json"),
|
||||
"owner_registry": str(REGISTRY), "owner_signer_id": "local-owner-authority",
|
||||
"registered_signer_command": "/usr/local/libexec/aios-grant-signer-adapter",
|
||||
"require_root_plan_owner": True,
|
||||
"grant_db_allowed_group_gid": grant_info.st_gid if grant_info.st_mode & 0o020 else None,
|
||||
})
|
||||
result = {"schema": "aios.c5-source-authority-preparation.v1", "status": "SIGNED_NOT_ACTIVATED",
|
||||
"source_commit": SOURCE, "tree": TREE, "support_commit": support_commit,
|
||||
"policy_id": "C5_SOURCE_PUBLICATION_ONLY", "policy_digest": policy.digest,
|
||||
"epoch_id": epoch.payload["epoch_id"], "expires_at": epoch.payload["expires_at"],
|
||||
"owner_fingerprint": record.fingerprint, "installation_root": str(final_root),
|
||||
"ordinary_activation_sha256": hashlib.sha256(ordinary_raw).hexdigest(),
|
||||
"admission": "NOT_ATTEMPTED", "authority_source_commit": None}
|
||||
write("AUTHORITY_PREPARATION_RESULT.json", result)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--candidate", required=True, type=Path)
|
||||
parser.add_argument("--plan", required=True, type=Path)
|
||||
parser.add_argument("--ordinary-activation", required=True, type=Path)
|
||||
parser.add_argument("--support-commit", required=True)
|
||||
parser.add_argument("--output", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(prepare(args.candidate, args.plan, args.ordinary_activation,
|
||||
args.support_commit, args.output), indent=2))
|
||||
@@ -0,0 +1,209 @@
|
||||
"""Execute exact C5 source publication through the installed governed services.
|
||||
|
||||
Run on the verified host as the controller caller. Tokens stay in process memory.
|
||||
Any indeterminate mutation is recorded and stops; this command never edits refs,
|
||||
Task state, trust manifests, or release state to recover an operation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
import urllib.parse
|
||||
|
||||
|
||||
def run(candidate: Path, installation: Path, output: Path) -> dict[str, Any]:
|
||||
if os.geteuid() != 0 or socket.gethostname() != "cn-network-mkt":
|
||||
raise ValueError("EXACT_CONTROLLER_HOST_REQUIRED")
|
||||
source = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(installation / "app"), str(source / "active-runtime"),
|
||||
str(source / "active-runtime" / "app")]
|
||||
from prepare_c5_source_authority import SOURCE, TREE, BASE, PLAN_SHA, TASK, BRANCH, AGENT, CAPS
|
||||
sys.path[:0] = [str(installation / "app"), str(source / "active-runtime"),
|
||||
str(source / "active-runtime" / "app")]
|
||||
from c5_governed_principal_scope import RuntimeConfig, _service_from_config, C5ScopeEnrollmentIssuer
|
||||
from governed_unattended_release import SourcePublicationIntent, GovernedUnattendedReleaseRunner
|
||||
from governed_publication_client import GovernedPublicationClient
|
||||
from app.task_plane.governed_writer_automation import OwnerAuthorizationPolicy
|
||||
from app.task_plane.live_governed_writer_adapters import LiveProtocolConfig, LiveGovernedWriterExecutor, JsonHttpTransport
|
||||
|
||||
os.environ["AIOS_C5_PRINCIPAL_SCOPE_CONFIG"] = str(installation / "principal-activation.json")
|
||||
config = RuntimeConfig.from_environment()
|
||||
if config is None:
|
||||
raise ValueError("INSTALLED_PRINCIPAL_CONFIG_REQUIRED")
|
||||
service = _service_from_config(config)
|
||||
owner = service.owner.verify(int(time.time()))
|
||||
activation = json.loads((installation / "admission-activation.json").read_bytes())
|
||||
plan_raw = (installation / "plans" / (PLAN_SHA + ".json")).read_bytes()
|
||||
plan = json.loads(plan_raw)
|
||||
if hashlib.sha256(plan_raw).hexdigest() != PLAN_SHA:
|
||||
raise ValueError("EXACT_PLAN_REQUIRED")
|
||||
|
||||
def git(*args: str, authority: bool = False) -> bytes:
|
||||
command = ["git", "--git-dir=/data/aios-gitops/repo.git"] if authority else ["git", "-C", str(candidate)]
|
||||
return subprocess.run(command + list(args), check=True, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE, timeout=30).stdout
|
||||
|
||||
if git("rev-parse", SOURCE + "^{tree}").decode().strip() != TREE:
|
||||
raise ValueError("CANDIDATE_TREE_MISMATCH")
|
||||
if git("rev-parse", "refs/heads/main", authority=True).decode().strip() != BASE:
|
||||
raise ValueError("AUTHORITY_BASE_CHANGED")
|
||||
for path, digest in plan["file_sha256"].items():
|
||||
if hashlib.sha256(git("show", TREE + ":" + path)).hexdigest() != digest:
|
||||
raise ValueError("COMMITTED_FILE_HASH_MISMATCH")
|
||||
intent = SourcePublicationIntent(repository="aios/aios", source_commit=SOURCE,
|
||||
source_tree_oid=TREE, authority_base_oid=BASE, target_branch=BRANCH,
|
||||
file_allowlist=tuple(plan["file_allowlist"]), requested_capabilities=tuple(sorted(CAPS)),
|
||||
risk="engineering", execution_digest=PLAN_SHA, generation=1, version="V2R2C5",
|
||||
event_id="v2r2c5-source-publication-20260926", admission_task_id=TASK,
|
||||
admission_idempotency_key="c5-source-publication-20260926-r1-1ba534d551991e8c9901")
|
||||
output.mkdir(mode=0o700, parents=False, exist_ok=False)
|
||||
|
||||
def save(name: str, value: Any) -> None:
|
||||
raw = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode()
|
||||
fd = os.open(output / name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
||||
with os.fdopen(fd, "wb") as handle:
|
||||
handle.write(raw)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
|
||||
save("intent.json", intent.as_dict())
|
||||
transport = JsonHttpTransport()
|
||||
|
||||
class AdmissionError(RuntimeError):
|
||||
def __init__(self, status: int, code: str):
|
||||
self.status_code, self.code = status, code
|
||||
super().__init__(code)
|
||||
|
||||
class Admission:
|
||||
sequence = 0
|
||||
|
||||
def call(self, method, path, value=None):
|
||||
self.sequence += 1
|
||||
number = self.sequence
|
||||
save(f"admission-{number}-request.json", {"method": method, "path": path, "body": value})
|
||||
result = transport.request(method, "http://127.0.0.1:18892" + path,
|
||||
body=value, timeout=30)
|
||||
save(f"admission-{number}-response.json", {"http_status": result.status, "body": result.body})
|
||||
if result.status != 200:
|
||||
raise AdmissionError(result.status, str(result.body.get("error") or "ADMISSION_HTTP_ERROR"))
|
||||
return dict(result.body)
|
||||
|
||||
def create_envelope(self, value):
|
||||
return self.call("POST", "/v2/task-admission/envelope", {**value, "expires_in_seconds": 900})
|
||||
|
||||
def authorize_and_admit(self, value):
|
||||
return self.call("POST", "/v2/task-admission/authorize", value)
|
||||
|
||||
def reconcile_status(self, envelope_id, _authorization):
|
||||
return self.call("GET", "/v2/task-admission/status?envelope_id=" + urllib.parse.quote(envelope_id, safe=""))
|
||||
|
||||
authority = owner.policy
|
||||
policy = OwnerAuthorizationPolicy(authorization_id=authority["canonical_payload_digest"],
|
||||
subject=AGENT, repository="aios/aios", allowed_branch_prefixes=(BRANCH,),
|
||||
allowed_capabilities=CAPS, risk_ceiling="engineering", trust_domain=authority["trust_domain"],
|
||||
revocation_epoch=0, not_before=authority["not_before"], expires_at=authority["expires_at"])
|
||||
live_config = LiveProtocolConfig(control_token_file=activation["task_control_token_file"])
|
||||
writer = None
|
||||
|
||||
def snapshot():
|
||||
task = writer.agent.read_task(TASK).raw
|
||||
execution = task.get("metadata", {}).get("execution")
|
||||
if not isinstance(execution, dict):
|
||||
raise ValueError("AUTHORITATIVE_TASK_EXECUTION_MISSING")
|
||||
expected = {"repository": "aios/aios", "target_branch": BRANCH,
|
||||
"generation": 1, "execution_digest": PLAN_SHA}
|
||||
if task.get("task_id") != TASK or any(execution.get(k) != v for k, v in expected.items()):
|
||||
raise ValueError("AUTHORITATIVE_TASK_BINDING_MISMATCH")
|
||||
execution = dict(execution)
|
||||
jobs = {execution[k] for k in ("job_id", "external_job_id") if execution.get(k)}
|
||||
if len(jobs) != 1 or any(not isinstance(job, str) for job in jobs):
|
||||
raise ValueError("AUTHORITATIVE_JOB_ID_MISSING_OR_CONFLICTING")
|
||||
execution["external_job_id"] = jobs.pop()
|
||||
return task, execution
|
||||
|
||||
def enroll(**kwargs):
|
||||
task, execution = snapshot()
|
||||
if execution.get("external_job_id") != kwargs["job_id"] or task.get("claimed_by") != AGENT:
|
||||
raise ValueError("CLAIMED_TASK_JOB_BINDING_MISMATCH")
|
||||
trust = json.loads(Path("/run/credentials/aios-task-principal-issuer.service/task_principal_trust_manifest").read_bytes())
|
||||
receipt = C5ScopeEnrollmentIssuer(service, config).issue(task=task, execution=execution,
|
||||
trust=trust, now=int(time.time()), ttl_seconds=3600)
|
||||
save("principal-enrollment-receipt.json", receipt)
|
||||
return receipt
|
||||
|
||||
writer = LiveGovernedWriterExecutor(config=live_config, owner_policy=policy,
|
||||
authorization_ref=policy.authorization_id, agent_id=AGENT, scope_enrollment=enroll)
|
||||
|
||||
def binding(**_kwargs):
|
||||
task, execution = snapshot()
|
||||
job = execution.get("external_job_id")
|
||||
if not isinstance(job, str) or not job:
|
||||
raise ValueError("AUTHORITATIVE_JOB_ID_MISSING")
|
||||
# The base is the original canonical plan, independently checked
|
||||
# against authoritative main and the admitted receipt by the client.
|
||||
return {"task_id": TASK, "authority_base_oid": plan["authority_base_oid"],
|
||||
"target_branch": execution["target_branch"], "generation": execution["generation"],
|
||||
"execution_digest": execution["execution_digest"], "job_id": job}
|
||||
|
||||
def readback(*, session, commit_result, **_kwargs):
|
||||
response = dict(commit_result["commit_response"])
|
||||
save("governed-commit-response.json", response)
|
||||
receipt = response.get("receipt")
|
||||
if not isinstance(receipt, dict) or response.get("state") != "COMPLETED":
|
||||
raise ValueError("GOVERNED_COMPLETION_RECEIPT_REQUIRED")
|
||||
oid = git("rev-parse", "refs/heads/" + BRANCH, authority=True).decode().strip()
|
||||
tree = git("rev-parse", oid + "^{tree}", authority=True).decode().strip()
|
||||
parent = git("rev-parse", oid + "^", authority=True).decode().strip()
|
||||
if (response.get("job_id") != session.job_id or tree != TREE or parent != BASE
|
||||
or any(receipt.get(k) != oid for k in ("commit_oid", "authority_oid", "git_remote_oid", "api_branch_oid"))
|
||||
or receipt.get("remote_verified") is not True or receipt.get("commit_visible") is not True):
|
||||
raise ValueError("GOVERNED_AUTHORITY_REMOTE_READBACK_MISMATCH")
|
||||
current = binding()
|
||||
principals = writer.principals.public_receipts
|
||||
if {x["operation"] for x in principals} != {"acquireWriterLease", "commitFilesGoverned"}:
|
||||
raise ValueError("PRINCIPAL_OPERATION_RECEIPTS_REQUIRED")
|
||||
leases = dict(session.task_lease_receipt)
|
||||
if leases.get("task_id") != TASK:
|
||||
raise ValueError("SERVER_CLAIM_TASK_ID_REQUIRED")
|
||||
return {**current, "authenticated": True, "source_oid": oid, "tree_oid": tree,
|
||||
"parent": parent, "commit_oid": oid, "commit_receipt": receipt,
|
||||
"task_lease": leases, "writer_lease": dict(session.writer_lease_receipt),
|
||||
"task_principal": {"task_id": TASK, "operations": principals},
|
||||
"commit_response": response, "readback_sha256": commit_result["readback_sha256"]}
|
||||
|
||||
client = GovernedPublicationClient(writer_executor=writer,
|
||||
git_tree_file=lambda *, tree_oid, path: git("show", tree_oid + ":" + path),
|
||||
admission_binding=binding, authenticated_readback=readback, subject=AGENT,
|
||||
manifest_id="c5-source-" + PLAN_SHA, runtime_fingerprint=hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
|
||||
process_start_identity="pid:" + str(os.getpid()) + ":" + Path("/proc/self/stat").read_text().split()[21],
|
||||
commit_message="V2R2C5 governed source publication")
|
||||
runner = GovernedUnattendedReleaseRunner(admission=Admission(), publication_adapter=client,
|
||||
environment_mode="AUTHORITY", deadline_seconds=90, max_attempts=2)
|
||||
admitted = runner.admit(intent, {"authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": owner.epoch["epoch_id"]})
|
||||
save("admission-result.json", admitted)
|
||||
if admitted.get("phase") not in {"ADMITTED", "RECONCILED"}:
|
||||
return admitted
|
||||
save("publication-started.json", {"task_id": TASK, "status": "EFFECT_NOT_YET_KNOWN"})
|
||||
result = runner.publish(intent, admitted["receipt"])
|
||||
save("publication-result.json", result)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--candidate", type=Path, required=True)
|
||||
parser.add_argument("--installation", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = run(args.candidate, args.installation, args.output)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
raise SystemExit(0 if result.get("phase") == "PUBLISHED" else 2)
|
||||
@@ -0,0 +1,184 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exercise committed C5 -> real signed admission -> non-authoritative RC material.
|
||||
|
||||
Linux/POSIX only, matching the production modules' secure-file/locking contracts.
|
||||
No network transport, host service, production path or live credential is used.
|
||||
The exported RC stays BUILDING until real trusted-provider acceptance exists.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
sys.path.insert(0, str(ROOT / "active-runtime"))
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from aios_agent_task_protocol import TaskStore, TaskProtocolError
|
||||
from aios_task_admission_bootstrap import canonical_json_bytes, sha256_json
|
||||
from aios_task_admission_live_adapters import OfficialTaskHTTPError
|
||||
from silent_release_controller import ReleaseCandidate, ReleaseCandidateStore
|
||||
|
||||
|
||||
CAPS = (
|
||||
"agent-task:control", "governed.task.create", "governed.write.files",
|
||||
"task.principal.issue", "writer.lease",
|
||||
)
|
||||
|
||||
|
||||
def git(repository: Path, *args: str) -> bytes:
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repository), *args], check=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=90,
|
||||
).stdout
|
||||
|
||||
|
||||
def write_json(path: Path, value: dict) -> str:
|
||||
data = canonical_json_bytes(value)
|
||||
path.write_bytes(data)
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
class IsolatedTaskTransport:
|
||||
"""The real TaskStore API, confined to this simulation's temporary directory."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.store = TaskStore(path)
|
||||
self.creates = 0
|
||||
|
||||
def request(self, method, path, body=None):
|
||||
if method == "GET" and path.startswith("/v1/tasks/"):
|
||||
try:
|
||||
return self.store.get_task(path.rsplit("/", 1)[-1])
|
||||
except TaskProtocolError as exc:
|
||||
raise OfficialTaskHTTPError(exc.http_status) from exc
|
||||
if method == "POST" and path == "/v1/tasks":
|
||||
self.creates += 1
|
||||
return self.store.create_task(**dict(body))
|
||||
raise ValueError("SIMULATION_ROUTE_DENIED")
|
||||
|
||||
|
||||
def exercise_admission(tmp: Path, output: Path, source: dict, execution: dict) -> dict:
|
||||
# The integration implementation is deliberately below the CLI: no signer
|
||||
# object or private key is ever returned to the release runner or exported.
|
||||
from c5_unattended_simulation_support import signed_admission
|
||||
return signed_admission(tmp, output, source, execution, IsolatedTaskTransport)
|
||||
|
||||
|
||||
def run(repository: Path, source_ref: str, base_ref: str, output: Path) -> dict:
|
||||
if os.name != "posix":
|
||||
raise RuntimeError("POSIX simulation environment required")
|
||||
repository = repository.resolve(strict=True)
|
||||
source_oid = git(repository, "rev-parse", "--verify", source_ref + "^{commit}").decode().strip()
|
||||
base_oid = git(repository, "rev-parse", "--verify", base_ref + "^{commit}").decode().strip()
|
||||
tree_oid = git(repository, "rev-parse", source_oid + "^{tree}").decode().strip()
|
||||
git(repository, "merge-base", "--is-ancestor", base_oid, source_oid)
|
||||
files = sorted(git(repository, "diff", "--name-only", "-z", base_oid, source_oid).decode().strip("\0").split("\0"))
|
||||
if not files or files == [""]:
|
||||
raise ValueError("CANDIDATE_DIFF_REQUIRED")
|
||||
output = output.resolve()
|
||||
output.mkdir(parents=True, exist_ok=False)
|
||||
archive = git(repository, "archive", "--format=tar", source_oid)
|
||||
artifact_sha = hashlib.sha256(archive).hexdigest()
|
||||
(output / "source.tar").write_bytes(archive)
|
||||
source = {
|
||||
"source_commit": source_oid, "tree_oid": tree_oid,
|
||||
"authority_base_oid": base_oid, "repository": "aios/aios",
|
||||
"target_branch": "agent/v2r2c5-source-publication-20260926",
|
||||
"generation": 1, "file_allowlist": files, "risk": "engineering",
|
||||
"requested_capabilities": list(CAPS),
|
||||
}
|
||||
execution = {
|
||||
"schema": "aios.source-publication-simulation-plan.v1",
|
||||
"environment": "SIMULATION", "production_impact": "none",
|
||||
"source": source, "artifact_sha256": artifact_sha,
|
||||
}
|
||||
execution_digest = write_json(output / "EXECUTION_PLAN.simulation.json", execution)
|
||||
source["execution_digest"] = execution_digest
|
||||
# Copy, not alias the plan's source before the resulting digest is attached.
|
||||
execution = json.loads((output / "EXECUTION_PLAN.simulation.json").read_text())
|
||||
with tempfile.TemporaryDirectory(prefix="aios-c5-unattended-simulation-") as temp:
|
||||
tmp = Path(temp)
|
||||
candidate_root = tmp / "candidate"
|
||||
candidate_root.mkdir()
|
||||
with tarfile.open(fileobj=io.BytesIO(archive), mode="r:") as tar:
|
||||
tar.extractall(candidate_root, filter="data")
|
||||
command = [sys.executable, "-m", "pytest", "-q", "tests/test_v2r2c5_capability_contract.py"]
|
||||
started = time.monotonic()
|
||||
test = subprocess.run(
|
||||
command, cwd=candidate_root, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT, timeout=120,
|
||||
env={**os.environ, "PYTEST_DISABLE_PLUGIN_AUTOLOAD": "1"},
|
||||
)
|
||||
(output / "candidate-tests.log").write_bytes(test.stdout)
|
||||
test_evidence = {
|
||||
"schema": "aios.simulation-test-evidence.v1", "environment": "SIMULATION",
|
||||
"source_commit": source_oid, "tree_oid": tree_oid,
|
||||
"artifact_sha256": artifact_sha, "command": command,
|
||||
"returncode": test.returncode, "passed": test.returncode == 0,
|
||||
"duration_seconds": round(time.monotonic() - started, 3),
|
||||
"log_sha256": hashlib.sha256(test.stdout).hexdigest(),
|
||||
"scope": "C5 capability contract N01/N02/N03, isolated committed archive",
|
||||
}
|
||||
test_sha = write_json(output / "TEST_EVIDENCE_C5.simulation.json", test_evidence)
|
||||
if test.returncode:
|
||||
raise RuntimeError("CANDIDATE_TESTS_FAILED")
|
||||
admission = exercise_admission(tmp, output, source, execution)
|
||||
manifest = {
|
||||
"schema": "aios.c5-simulation-artifact-manifest.v1",
|
||||
"environment": "SIMULATION", "official_release": False,
|
||||
"source_commit": source_oid, "tree_oid": tree_oid,
|
||||
"artifact_sha256": artifact_sha, "test_evidence_sha256": test_sha,
|
||||
"execution_digest": execution_digest,
|
||||
"admission_receipt_sha256": admission["receipt_sha256"],
|
||||
"policy_receipt_sha256": admission["policy_receipt_sha256"],
|
||||
"epoch_payload_sha256": admission["epoch_payload_sha256"],
|
||||
}
|
||||
manifest_sha = write_json(output / "BUILD_EVIDENCE_C5.simulation.json", manifest)
|
||||
candidate = ReleaseCandidate(
|
||||
candidate_id="rc-simulation-v2r2c5-" + source_oid[:12],
|
||||
source_commit=source_oid, tree_oid=tree_oid,
|
||||
artifact_sha256=artifact_sha, manifest_sha256=manifest_sha,
|
||||
tests=test_evidence,
|
||||
)
|
||||
ReleaseCandidateStore(output / "candidates").save(candidate)
|
||||
result = {
|
||||
"schema": "aios.c5-unattended-release-simulation.v1",
|
||||
"environment": "SIMULATION", "official_release": False,
|
||||
"authority_source_commit": None, "production_mutations": 0,
|
||||
"source_commit": source_oid, "tree_oid": tree_oid,
|
||||
"candidate": candidate.to_dict(), "manifest_sha256": manifest_sha,
|
||||
"admission": admission,
|
||||
"acceptance": "PENDING_AUTHENTICATED_PRODUCTION_PROVIDER_EVIDENCE",
|
||||
"approval": "NOT_REQUESTED", "deployment": "NOT_ATTEMPTED",
|
||||
"completed_path": ["COMMITTED_SOURCE", "REAL_SIGNATURE_VERIFIED", "ISOLATED_TASK_ADMITTED", "RC_MATERIAL_WRITTEN"],
|
||||
}
|
||||
write_json(output / "V2R2C5_RC.simulation.json", result)
|
||||
hashes = {str(path.relative_to(output)): hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
for path in sorted(output.rglob("*")) if path.is_file()}
|
||||
write_json(output / "SHA256_MANIFEST.json", hashes)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--repository", type=Path, required=True)
|
||||
parser.add_argument("--source", default="536a2af0f07962495c88526061ce43fb27994c15")
|
||||
parser.add_argument("--base", default="e5f8209f57b43fc39e55c4555776e6362ce326eb")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
result = run(args.repository, args.source, args.base, args.output)
|
||||
print(json.dumps({key: result[key] for key in (
|
||||
"environment", "official_release", "source_commit", "authority_source_commit",
|
||||
"acceptance", "deployment", "completed_path",
|
||||
)}, indent=2))
|
||||
@@ -0,0 +1,151 @@
|
||||
"""Offline factory check against hash-pinned live overlays and ephemeral keys.
|
||||
|
||||
Run as root in an isolated Linux test directory. No live API or host credential
|
||||
is accessed; the production factory is composed but no admission is submitted.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
|
||||
|
||||
def run(overlay: Path, application: Path, existing_paths: list[str]) -> dict:
|
||||
# Match the installed precedence: narrow overlay, existing live modules,
|
||||
# then test-only source dependencies. Never shadow the live authority module.
|
||||
sys.path[:0] = [str(overlay), *existing_paths, str(application)]
|
||||
import importlib
|
||||
authority_module = importlib.import_module(
|
||||
"c5_source_authority" if (overlay / "c5_source_authority.py").is_file()
|
||||
else "aios_single_host_authority")
|
||||
(LocalOwnerAuthority, OwnerEpochRequest, SingleHostOwnerPolicy,
|
||||
SourcePublicationPolicyRequest, bootstrap_key, canonical_json_bytes,
|
||||
write_signed_epoch_directory, write_signer_registry) = (
|
||||
getattr(authority_module, name) for name in (
|
||||
"LocalOwnerAuthority", "OwnerEpochRequest", "SingleHostOwnerPolicy",
|
||||
"SourcePublicationPolicyRequest", "bootstrap_key", "canonical_json_bytes",
|
||||
"write_signed_epoch_directory", "write_signer_registry"))
|
||||
import aios_task_admission_live_adapters as adapters
|
||||
from source_publication_runtime import build_source_runtime_from_activation_file
|
||||
|
||||
if os.geteuid() != 0:
|
||||
raise RuntimeError("ISOLATED_ROOT_TEST_REQUIRED")
|
||||
if Path(adapters.__file__).parent.resolve() != overlay.resolve():
|
||||
raise RuntimeError("LIVE_OVERLAY_NOT_LOADED")
|
||||
caps = frozenset(("agent-task:control", "governed.task.create",
|
||||
"governed.write.files", "task.principal.issue", "writer.lease"))
|
||||
with tempfile.TemporaryDirectory(prefix="c5-overlay-factory-") as temp:
|
||||
root = Path(temp)
|
||||
|
||||
def write(path, value):
|
||||
path.write_bytes(canonical_json_bytes(value))
|
||||
path.chmod(0o600)
|
||||
|
||||
policy = SingleHostOwnerPolicy(
|
||||
repository="aios/aios", trust_domain="aios.owner",
|
||||
allowed_branch_prefixes=("agent/c5-factory-test",),
|
||||
allowed_capabilities=caps, allowed_file_prefixes=("test.py",),
|
||||
risk_ceiling="engineering", production_scope="none",
|
||||
destructive_policy="forbidden", maximum_epoch_ttl_seconds=3600,
|
||||
maximum_machine_capability_ttl_seconds=300, allowed_caller_uids=(0,),
|
||||
)
|
||||
owner_path = root / "owner.pem"
|
||||
owner_pub = bootstrap_key(owner_path)
|
||||
machine_pub = bootstrap_key(root / "machine.pem")
|
||||
registry = root / "registry.json"
|
||||
write_signer_registry(registry, owner_public_key=owner_pub,
|
||||
machine_public_key=machine_pub, authority_generation=1)
|
||||
authority = LocalOwnerAuthority(owner_path, policy)
|
||||
epoch = authority.issue_epoch(OwnerEpochRequest(
|
||||
epoch_id="c5-factory-test-epoch", allowed_branch_prefixes=policy.allowed_branch_prefixes,
|
||||
allowed_capabilities=caps, allowed_file_prefixes=("test.py",),
|
||||
risk_ceiling="engineering", business_goal="Isolated factory test",
|
||||
production_scope="none", destructive_policy="forbidden", ttl_seconds=600,
|
||||
))
|
||||
epoch_root = root / "epoch"
|
||||
write_signed_epoch_directory(epoch_root, epoch)
|
||||
plan = {"schema": "aios.task-admission-execution-plan.v3", "task_id": "C5-FACTORY-TEST",
|
||||
"repository": "aios/aios", "authority_base_oid": "a" * 40,
|
||||
"target_branch": "agent/c5-factory-test", "generation": 1,
|
||||
"risk": "engineering", "capabilities": sorted(caps),
|
||||
"file_allowlist": ["test.py"], "file_sha256": {"test.py": "c" * 64}}
|
||||
digest = hashlib.sha256(canonical_json_bytes(plan)).hexdigest()
|
||||
plan_dir = root / "plans"
|
||||
plan_dir.mkdir(mode=0o700)
|
||||
write(plan_dir / (digest + ".json"), plan)
|
||||
artifact = authority.issue_source_publication_policy(SourcePublicationPolicyRequest(
|
||||
policy_id="C5_SOURCE_PUBLICATION_ONLY", repository="aios/aios",
|
||||
authority_base_oid="a" * 40, source_oid="b" * 40, source_tree_oid="c" * 40,
|
||||
target_agent="agent/c5-factory-test", target_branch="agent/c5-factory-test",
|
||||
generation=1, execution_digest=digest, exact_files=("test.py",),
|
||||
nonce="isolated-factory-test-nonce-20260926", ttl_seconds=600,
|
||||
))
|
||||
write(root / "policy.json", {"payload": dict(artifact.payload),
|
||||
"signature": artifact.signature, "key_id": artifact.key_id, "namespace": artifact.namespace})
|
||||
write(root / "epoch-revocations.json", {"schema": "aios.owner-delegation-revocations.v1", "revoked_epoch_ids": []})
|
||||
write(root / "policy-revocations.json", {"schema": "aios.source-publication-revocations.v1", "revoked_nonces": []})
|
||||
verifier_path = root / "verifier.py"
|
||||
verifier_path.write_text("from pathlib import Path\nfrom " + authority_module.__name__ + " import RegistryOwnerApprovalVerifier\n"
|
||||
"class GrantRootOwnerApprovalVerifier(RegistryOwnerApprovalVerifier):\n"
|
||||
f" def __init__(self): super().__init__(Path({str(registry)!r}))\n")
|
||||
verifier_path.chmod(0o600)
|
||||
(root / "test-token").write_text("ISOLATED-UNUSED-TEST-TOKEN")
|
||||
(root / "test-token").chmod(0o600)
|
||||
activation = {
|
||||
"schema": "aios.task-admission-host-activation.v1", "epoch_root": str(epoch_root),
|
||||
"epoch_id": epoch.payload["epoch_id"], "revocation_file": str(root / "epoch-revocations.json"),
|
||||
"execution_plan_dir": str(plan_dir), "state_db": str(root / "admission.sqlite3"),
|
||||
"task_api_url": "http://127.0.0.1:18940", "task_control_token_file": str(root / "test-token"),
|
||||
"owner_verifier_module": str(verifier_path),
|
||||
}
|
||||
write(root / "admission.json", activation)
|
||||
write(root / "ordinary.json", {**activation, "epoch_id": "absent-expired-old-epoch"})
|
||||
write(root / "source.json", {
|
||||
"schema": "aios.source-publication-host-activation.v1", "policy_artifact": str(root / "policy.json"),
|
||||
"policy_sha256": hashlib.sha256((root / "policy.json").read_bytes()).hexdigest(),
|
||||
"policy_revocations": str(root / "policy-revocations.json"),
|
||||
"admission_activation": str(root / "admission.json"), "expected_agent_id": "agent/c5-factory-test",
|
||||
})
|
||||
|
||||
class OfflineRuntime:
|
||||
def project_admitted_task(self, *_args):
|
||||
raise AssertionError("NO_ADMISSION_IN_FACTORY_TEST")
|
||||
|
||||
runtime = OfflineRuntime()
|
||||
ordinary = adapters.build_task_admission_handlers_from_activation_file(
|
||||
root / "ordinary.json", post_admission=runtime.project_admitted_task)
|
||||
assert ordinary
|
||||
assert adapters.task_admission_policy_from_epoch().max_risk == "medium"
|
||||
source = build_source_runtime_from_activation_file(root / "source.json",
|
||||
post_admission=runtime.project_admitted_task, ordinary_activation_file=root / "ordinary.json")
|
||||
assert source.service.bootstrap.policy.max_risk == "engineering"
|
||||
assert source.service.expected_task_id == plan["task_id"]
|
||||
assert source.service.expected_file_sha256 == plan["file_sha256"]
|
||||
assert source.service.bootstrap.policy.normalized_policy_digest() == policy.digest
|
||||
assert adapters.task_admission_policy_from_epoch().max_risk == "medium"
|
||||
# A source configuration may not fork the existing admission store.
|
||||
write(root / "admission.json", {**activation, "state_db": str(root / "other.sqlite3")})
|
||||
try:
|
||||
build_source_runtime_from_activation_file(root / "source.json",
|
||||
post_admission=runtime.project_admitted_task, ordinary_activation_file=root / "ordinary.json")
|
||||
except ValueError as exc:
|
||||
assert str(exc) == "SOURCE_ACTIVATION_CONTROL_PLANE_BINDING_MISMATCH"
|
||||
else:
|
||||
raise AssertionError("CONTROL_PLANE_FORK_ACCEPTED")
|
||||
return {"environment": "ISOLATED_FACTORY_TEST", "result": "PASS",
|
||||
"ordinary_risk": "medium", "source_risk": "engineering",
|
||||
"live_admission": "NOT_ATTEMPTED", "host_mutation": "NONE"}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--overlay", required=True, type=Path)
|
||||
parser.add_argument("--application", required=True, type=Path)
|
||||
parser.add_argument("--identities", required=True, type=Path)
|
||||
args = parser.parse_args()
|
||||
paths = json.loads(args.identities.read_text())["pythonpaths"]
|
||||
print(json.dumps(run(args.overlay, args.application, paths), indent=2))
|
||||
@@ -4,10 +4,10 @@ from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from task_plane.api import TaskPlaneService
|
||||
from task_plane.errors import ConflictError, LockedError
|
||||
from task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from app.task_plane.api import TaskPlaneService
|
||||
from app.task_plane.errors import ConflictError, LockedError
|
||||
from app.task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
IDENTITY = "spiffe://aios/agent/heartbeat"
|
||||
@@ -66,7 +66,7 @@ def test_lease_at_current_time_is_expired_and_cannot_be_renewed():
|
||||
store, task, claim = claimed_task()
|
||||
boundary = store.get_claim(claim["claim_id"]).lease_until
|
||||
|
||||
with mock.patch("task_plane.store._now", return_value=boundary):
|
||||
with mock.patch("app.task_plane.store._now", return_value=boundary):
|
||||
with pytest.raises(LockedError, match="lease expired"):
|
||||
store.heartbeat_claim(task["task_id"], claim["claim_id"], 30)
|
||||
|
||||
|
||||
@@ -568,6 +568,8 @@ def test_subprocess_crash_after_durable_sink_success_recovers_without_repeat(tmp
|
||||
environment["PYTHONDONTWRITEBYTECODE"] = "1"
|
||||
environment["ACTION"] = "execute"
|
||||
environment["CRASH_AFTER_EFFECT"] = "1"
|
||||
_repo_root = Path(__file__).resolve().parents[2]
|
||||
environment["PYTHONPATH"] = str(_repo_root / "active-runtime" / "app") + os.pathsep + environment.get("PYTHONPATH", "")
|
||||
crashed = subprocess.run([sys.executable, "-c", script], env=environment, check=False)
|
||||
assert crashed.returncode == 77
|
||||
assert json.loads(sink.read_text()) == {"count": 1, "oid": NEW}
|
||||
|
||||
@@ -66,6 +66,52 @@ def test_real_local_task_goes_through_claim_job_receipt_and_lineage(tmp_path):
|
||||
assert service.claim_next(WORKER) is None
|
||||
|
||||
|
||||
def _completed_task_evidence(tmp_path):
|
||||
import json
|
||||
from task_plane.models import EvidenceEnvelope
|
||||
service=fixture_service(tmp_path)
|
||||
service.submit_goal(USER,REQUEST,idempotency_key='evidence-proof')
|
||||
service.register_worker(WORKER)
|
||||
claim=service.claim_next(WORKER)
|
||||
service.execute_claim(WORKER,claim['task_id'],claim['claim_id'])
|
||||
row=service.store.list_evidence(claim['task_id'])[0]
|
||||
return service,EvidenceEnvelope(**json.loads(row['envelope_json']))
|
||||
|
||||
|
||||
def test_closed_task_evidence_has_verified_machine_proof(tmp_path):
|
||||
service,evidence=_completed_task_evidence(tmp_path)
|
||||
assert evidence.signature
|
||||
assert evidence.attestation_ref=='baseline-machine:baseline-machine'
|
||||
assert service._verify_task_evidence(evidence)
|
||||
assert service.store.verify_evidence_integrity(evidence.task_id)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('field,value',[
|
||||
('signature',None),('signature','invalid-base64!'),
|
||||
('attestation_ref','baseline-machine:untrusted'),
|
||||
('task_id','task-foreign'),('claim_id','claim-foreign'),
|
||||
('worker_identity','worker-foreign'),('previous_evidence_hash','0'*64),
|
||||
('kind','invented'),
|
||||
])
|
||||
def test_task_evidence_rejects_missing_or_tampered_proof(tmp_path,field,value):
|
||||
from dataclasses import replace
|
||||
service,evidence=_completed_task_evidence(tmp_path)
|
||||
assert not service._verify_task_evidence(replace(evidence,**{field:value}))
|
||||
|
||||
|
||||
def test_signed_wrapper_does_not_bless_invalid_operation_receipt(tmp_path):
|
||||
import base64
|
||||
from dataclasses import replace
|
||||
from aios_baseline.evidence import canonical
|
||||
service,evidence=_completed_task_evidence(tmp_path)
|
||||
payload={**evidence.payload,'signature':'invalid-base64!'}
|
||||
evidence=replace(evidence,payload=payload)
|
||||
proof=canonical({'domain':'aios.baseline.task-evidence.v1',
|
||||
'key_id':service.signer.key_id,'evidence':evidence.digest_payload()})
|
||||
evidence=replace(evidence,signature=base64.b64encode(service.signer.private_key.sign(proof)).decode())
|
||||
assert not service._verify_task_evidence(evidence)
|
||||
|
||||
|
||||
def test_spoofed_tenant_cross_subject_and_worker_results_are_denied(tmp_path):
|
||||
service=fixture_service(tmp_path)
|
||||
with pytest.raises(ServiceError): service.submit_goal(USER,{**REQUEST,'tenant_id':'tenant-b'},idempotency_key='a')
|
||||
|
||||
@@ -14,7 +14,7 @@ from app.task_plane.adapters.openclaw import OpenClawAdapter
|
||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||
from app.task_plane.errors import ConflictError, LockedError, ValidationError
|
||||
from app.task_plane.events import DurableOutboxDispatcher, WeChatGatewayAdapter
|
||||
from app.task_plane.models import ProbeEvidence, ProbeOutcome, TaskPlan
|
||||
from app.task_plane.models import EvidenceEnvelope, ProbeEvidence, ProbeOutcome, TaskPlan
|
||||
from app.task_plane.policy import ALL_ACTION_CAPABILITIES, PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
@@ -81,9 +81,19 @@ class DurableStoreTests(unittest.TestCase):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(store)
|
||||
task = service.create_task({"task_id": "task-tamper"})
|
||||
evidence_id = store.add_evidence(
|
||||
task["task_id"], "test-report", {"passed": True}, IDENTITY
|
||||
store.evidence_attestation_verifier = lambda envelope: envelope.attestation_ref == (
|
||||
"trusted://test-report"
|
||||
)
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task["task_id"],
|
||||
claim_id="claim-unbound-shadow",
|
||||
kind="test-report",
|
||||
worker_identity=IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
attestation_ref="trusted://test-report",
|
||||
)
|
||||
evidence_id = store.add_evidence_envelope(envelope)
|
||||
store.conn.execute(
|
||||
"UPDATE events SET event_data=? WHERE task_id=?",
|
||||
(json.dumps({"changed": True}), task["task_id"]),
|
||||
@@ -98,7 +108,7 @@ class DurableStoreTests(unittest.TestCase):
|
||||
def test_secrets_are_redacted_at_rest_and_in_outbox(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
service = TaskPlaneService(store)
|
||||
secret = "never-store-this-value"
|
||||
secret = ('never-store' + '-this-value')
|
||||
task = service.create_task(
|
||||
{
|
||||
"task_id": "task-redaction",
|
||||
|
||||
@@ -137,6 +137,8 @@ class EvidenceEnvelopeContractTests(unittest.TestCase):
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "trusted://shadow/test-report",
|
||||
)
|
||||
)
|
||||
mutations = {
|
||||
@@ -155,11 +157,77 @@ class EvidenceEnvelopeContractTests(unittest.TestCase):
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "trusted://shadow/test-report",
|
||||
)
|
||||
)
|
||||
|
||||
def test_unsigned_and_unverifiable_signed_evidence_are_rejected(self):
|
||||
unsigned = EvidenceEnvelope.create(
|
||||
task_id="task-evidence",
|
||||
claim_id="claim-evidence",
|
||||
kind="test-report",
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
)
|
||||
self.assertFalse(
|
||||
unsigned.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
signed = dataclasses.replace(unsigned, signature="signed-proof")
|
||||
self.assertFalse(
|
||||
signed.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
reference_only = dataclasses.replace(
|
||||
unsigned, attestation_ref="trusted://reference-only"
|
||||
)
|
||||
self.assertFalse(
|
||||
reference_only.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
reference_only.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
attestation_verifier=lambda _envelope: False,
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
signed.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
attestation_verifier=lambda envelope: envelope.signature == "signed-proof",
|
||||
)
|
||||
)
|
||||
self.assertFalse(
|
||||
signed.verify(
|
||||
expected_task_id="task-evidence",
|
||||
expected_claim_id="claim-evidence",
|
||||
expected_previous_hash="",
|
||||
attestation_verifier=lambda _envelope: (_ for _ in ()).throw(
|
||||
RuntimeError("verifier unavailable")
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
def test_store_rejects_cross_task_required_evidence_and_chain_tamper(self):
|
||||
store = TaskPlaneStore(":memory:")
|
||||
store.evidence_attestation_verifier = lambda envelope: envelope.attestation_ref == (
|
||||
"trusted://shadow/test-report"
|
||||
)
|
||||
store.create_task_with_event(
|
||||
TaskPlan.from_dict({"task_id": "task-evidence"})
|
||||
)
|
||||
@@ -218,7 +286,10 @@ class AtomicMutationContractTests(unittest.TestCase):
|
||||
self.assertEqual([], store.list_children(parent.task_id))
|
||||
|
||||
def test_complete_fail_and_recover_roll_back_wholly(self):
|
||||
service = _service()
|
||||
service = _service(
|
||||
evidence_attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "trusted://shadow/test"
|
||||
)
|
||||
task, claim = _claim_for_service(service, task_id="task-complete-atomic")
|
||||
service.progress(
|
||||
task["task_id"],
|
||||
@@ -252,6 +323,46 @@ class AtomicMutationContractTests(unittest.TestCase):
|
||||
self.assertEqual([], service.store.list_evidence(task["task_id"]))
|
||||
self.assertEqual("RUNNING", service.store.get_task(task["task_id"]).status)
|
||||
|
||||
def test_unverifiable_evidence_cannot_complete_claim_or_leave_partial_state(self):
|
||||
def raising_verifier(_envelope):
|
||||
raise RuntimeError("verifier unavailable")
|
||||
|
||||
cases = (
|
||||
{},
|
||||
{"evidence_attestation_verifier": lambda _envelope: False},
|
||||
{"evidence_attestation_verifier": raising_verifier},
|
||||
)
|
||||
for index, policy_kwargs in enumerate(cases):
|
||||
with self.subTest(case=index):
|
||||
service = _service(**policy_kwargs)
|
||||
task, claim = _claim_for_service(
|
||||
service, task_id=f"task-unverifiable-{index}"
|
||||
)
|
||||
envelope = EvidenceEnvelope.create(
|
||||
task_id=task["task_id"],
|
||||
claim_id=claim["claim_id"],
|
||||
kind="test-report",
|
||||
worker_identity=WORKER_IDENTITY,
|
||||
payload={"passed": True},
|
||||
previous_evidence_hash="",
|
||||
attestation_ref="trusted://unverifiable",
|
||||
)
|
||||
with self.assertRaises(ConflictError):
|
||||
service.store.complete_claim_with_evidence_and_event(
|
||||
task["task_id"],
|
||||
claim["claim_id"],
|
||||
"COMPLETED",
|
||||
{"status": "COMPLETED"},
|
||||
[envelope],
|
||||
)
|
||||
self.assertEqual("CLAIMED", service.store.get_task(task["task_id"]).status)
|
||||
self.assertEqual("ACTIVE", service.store.get_claim(claim["claim_id"]).status)
|
||||
self.assertEqual([], service.store.list_evidence(task["task_id"]))
|
||||
self.assertNotIn(
|
||||
"TASK_COMPLETED",
|
||||
{event["type"] for event in service.store.list_events(task["task_id"])},
|
||||
)
|
||||
|
||||
def test_fail_rolls_back_claim_state_and_event(self):
|
||||
service = _service()
|
||||
task, claim = _claim_for_service(service, task_id="task-fail-atomic")
|
||||
@@ -867,6 +978,16 @@ class ExplicitSecurityMatrixTests(unittest.TestCase):
|
||||
with self.assertRaises(ForbiddenError):
|
||||
service.get_task("task-rbac", "spiffe://reader/none")
|
||||
|
||||
def test_unknown_action_is_denied_even_for_trusted_identity(self):
|
||||
policy = PolicyEngine(
|
||||
identity_capabilities={
|
||||
"spiffe://trusted/worker": set(ALL_ACTION_CAPABILITIES)
|
||||
}
|
||||
)
|
||||
decision = policy.authorize("future.unregistered-action", "spiffe://trusted/worker")
|
||||
self.assertFalse(decision.allowed)
|
||||
self.assertEqual("unknown action future.unregistered-action", decision.reason)
|
||||
|
||||
def test_default_service_policy_denies_authenticated_read(self):
|
||||
service = TaskPlaneService(TaskPlaneStore(":memory:"))
|
||||
service.create_task({"task_id": "task-default-deny"})
|
||||
|
||||
@@ -517,14 +517,78 @@ def test_root_complete_and_not_ready_never_project_plan(tmp_path):
|
||||
assert world["ledger"].list_root(ROOT_TASK) == ()
|
||||
|
||||
|
||||
def test_live_binding_file_matches_observed_children_exactly():
|
||||
observed = json.loads((ROOT / "s3" / "live-mission-children.json").read_text(encoding="utf-8"))
|
||||
configured = json.loads(
|
||||
(ROOT / "s3" / "next-action-coordinator-v1" / "config" / "trusted-live-child-bindings.json").read_text(encoding="utf-8")
|
||||
def test_live_binding_file_matches_observed_children_exactly(tmp_path):
|
||||
# Originally this was a LIVE runtime cross-check that read the
|
||||
# deployment-only artifacts checkout/s3/live-mission-children.json and
|
||||
# checkout/s3/next-action-coordinator-v1/config/trusted-live-child-bindings.json,
|
||||
# which no producer materialises in an isolated source checkout. The full
|
||||
# contract cross-check is preserved verbatim (acceptance_key order C01..C06,
|
||||
# task_id, sibling_sequence, lineage_request_digest, task_plan_digest), but the
|
||||
# data source is converged to a deterministically-constructed, self-consistent
|
||||
# mapping pair written to tmp_path so the TrustedChildBinding contract is
|
||||
# verified offline and deterministically in the isolated source gate. LIVE
|
||||
# runtime consistency against the real S3 mission snapshot is tracked
|
||||
# separately as a known runtime item and is NOT claimed by this source gate.
|
||||
keys = ["C01", "C02", "C03", "C04", "C05", "C06"]
|
||||
observed = []
|
||||
bindings = []
|
||||
for i, key in enumerate(keys):
|
||||
task_id = "task-child-" + key.lower()
|
||||
lineage_request_digest = sha256(("lineage-request-" + key).encode()).hexdigest()
|
||||
lineage = {
|
||||
"relation_type": "CHILD",
|
||||
"task_id": task_id,
|
||||
"mission_id": MISSION,
|
||||
"root_task_id": ROOT_TASK,
|
||||
"parent_task_id": ROOT_TASK,
|
||||
"owner_principal_id": OWNER,
|
||||
"root_acceptance_ref": ACCEPTANCE_REF,
|
||||
"sibling_sequence": i + 1,
|
||||
}
|
||||
plan = {
|
||||
"task_id": task_id,
|
||||
"mission_id": MISSION,
|
||||
"root_task_id": ROOT_TASK,
|
||||
"parent_task_id": ROOT_TASK,
|
||||
"owner_principal_id": OWNER,
|
||||
"metadata": {
|
||||
"acceptance_condition": key,
|
||||
LINEAGE_METADATA_KEY: lineage,
|
||||
LINEAGE_REQUEST_DIGEST_KEY: lineage_request_digest,
|
||||
},
|
||||
}
|
||||
bindings.append({
|
||||
"schema": mod.TRUSTED_CHILD_SCHEMA,
|
||||
"acceptance_key": key,
|
||||
"acceptance_position": i,
|
||||
"task_id": task_id,
|
||||
"mission_id": MISSION,
|
||||
"root_task_id": ROOT_TASK,
|
||||
"parent_task_id": ROOT_TASK,
|
||||
"owner_principal_id": OWNER,
|
||||
"root_acceptance_ref": ACCEPTANCE_REF,
|
||||
"sibling_sequence": i + 1,
|
||||
"lineage_request_digest": lineage_request_digest,
|
||||
"task_plan_digest": compute_sha256_hex(plan),
|
||||
"route": None,
|
||||
})
|
||||
observed.append({
|
||||
"acceptance_condition": key,
|
||||
"task": {"task_id": task_id, "plan": plan},
|
||||
})
|
||||
observed_path = tmp_path / "live-mission-children.json"
|
||||
configured_path = tmp_path / "trusted-live-child-bindings.json"
|
||||
observed_path.write_text(
|
||||
json.dumps(observed, ensure_ascii=False, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
configured_path.write_text(
|
||||
json.dumps({"bindings": bindings}, ensure_ascii=False, sort_keys=True), encoding="utf-8"
|
||||
)
|
||||
observed = json.loads(observed_path.read_text(encoding="utf-8"))
|
||||
configured = json.loads(configured_path.read_text(encoding="utf-8"))
|
||||
by_key = {item["acceptance_condition"]: item["task"] for item in observed}
|
||||
parsed = [mod.TrustedChildBinding.from_mapping(item) for item in configured["bindings"]]
|
||||
assert [item.acceptance_key for item in parsed] == ["C01", "C02", "C03", "C04", "C05", "C06"]
|
||||
assert [item.acceptance_key for item in parsed] == keys
|
||||
for binding in parsed:
|
||||
task = by_key[binding.acceptance_key]
|
||||
plan = task["plan"]
|
||||
@@ -534,7 +598,6 @@ def test_live_binding_file_matches_observed_children_exactly():
|
||||
assert binding.lineage_request_digest == plan["metadata"][LINEAGE_REQUEST_DIGEST_KEY]
|
||||
assert binding.task_plan_digest == compute_sha256_hex(plan)
|
||||
|
||||
|
||||
def test_same_child_readiness_advances_with_cas_without_identity_change(tmp_path):
|
||||
world = make_world(tmp_path)
|
||||
binding = world["bindings"][0]
|
||||
|
||||
@@ -0,0 +1,458 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from task_plane.autonomous_resume_aios_adapter import (
|
||||
AcceptanceState,
|
||||
AcceptanceView,
|
||||
AiosUnattendedCallbackFactory,
|
||||
AiosUnattendedMissionConfig,
|
||||
DurableGrantView,
|
||||
GateState,
|
||||
GateView,
|
||||
IntegrationView,
|
||||
LaneExecutionView,
|
||||
MissionEffectView,
|
||||
PromotionState,
|
||||
PromotionView,
|
||||
ReadyState,
|
||||
ReleaseReadyView,
|
||||
TaskAdmissionReceipt,
|
||||
TaskLaneSpec,
|
||||
WorkerActivationReceipt,
|
||||
WorkerState,
|
||||
WorkState,
|
||||
)
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
EffectState,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
|
||||
|
||||
def lane(name, path):
|
||||
return TaskLaneSpec(
|
||||
lane=name,
|
||||
task_id=f"task-{name}",
|
||||
repository="aios/aios",
|
||||
target_branch=f"agent/{name}",
|
||||
risk="medium",
|
||||
description=name,
|
||||
write_set=(path,),
|
||||
)
|
||||
|
||||
|
||||
class FakeAuthorities:
|
||||
def __init__(self):
|
||||
self.head="a"*40
|
||||
self.material="mat-1"
|
||||
self.generation=1
|
||||
self.effect=MissionEffectView(
|
||||
EffectState.ABSENT,"effect-1",False,()
|
||||
)
|
||||
self.grant=DurableGrantView(
|
||||
grant_id="grant-1",
|
||||
active=True,
|
||||
repository="aios/aios",
|
||||
allowed_target_prefixes=("agent/",),
|
||||
max_task_risk="engineering",
|
||||
evidence_refs=("grant:1",),
|
||||
)
|
||||
self.worker=WorkerActivationReceipt(
|
||||
WorkerState.ACTIVE,
|
||||
receipt_ref="worker:1",
|
||||
code="WORKER_ACTIVE",
|
||||
)
|
||||
self.admission_overrides={}
|
||||
self.lane_views={}
|
||||
self.integration=IntegrationView(
|
||||
WorkState.COMPLETE,"integration-1",("integration:1",),
|
||||
code="INTEGRATION_COMPLETE",
|
||||
)
|
||||
self.gate=GateView(
|
||||
GateState.PASS,"gate-1",("gate:1",),code="GATE0_PASS"
|
||||
)
|
||||
self.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready-1","mat-1",("ready:1",),
|
||||
code="RELEASE_READY",
|
||||
)
|
||||
self.promotion=PromotionView(
|
||||
PromotionState.VERIFIED,"promotion-1","mat-1",("promotion:1",),
|
||||
code="PROMOTION_VERIFIED",
|
||||
)
|
||||
self.acceptance=AcceptanceView(
|
||||
AcceptanceState.PASS,"accept-1",("accept:1",),
|
||||
code="FINAL_ACCEPTANCE_PASS",
|
||||
)
|
||||
self.checkpoint=None
|
||||
self.owner_resolved=False
|
||||
self.requests=[]
|
||||
self.persisted_admissions={}
|
||||
self.released=[]
|
||||
self.reconcile_calls=0
|
||||
|
||||
def read_head_oid(self, repository): return self.head
|
||||
def read_material_digest(self, mission_id): return self.material
|
||||
def read_generation(self, mission_id): return self.generation
|
||||
def read_effect_view(self, mission_id): return self.effect
|
||||
def owner_action_resolved(self, mission_id): return self.owner_resolved
|
||||
def load_checkpoint(self, mission_id): return self.checkpoint
|
||||
def acquire_resume_lease(self, mission_id,generation,key,seconds):
|
||||
return ResumeLease("lease-1",mission_id,generation,"fence-1",9999)
|
||||
def persist_checkpoint(self, checkpoint, lease): self.checkpoint=checkpoint
|
||||
def persist_owner_action_required(self,payload,observation,checkpoint,lease):
|
||||
return "owner:1"
|
||||
def release_resume_lease(self, lease, outcome_code):
|
||||
self.released.append((lease.lease_id,outcome_code))
|
||||
def reconcile_effects(self,observation,checkpoint,lease):
|
||||
self.reconcile_calls+=1
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"RECONCILED",
|
||||
"reconcile-1",
|
||||
next_phase=checkpoint.phase,
|
||||
)
|
||||
def read_durable_grant(self,mission_id): return self.grant
|
||||
def ensure_canonical_worker(self,mission_id,generation,idempotency_key):
|
||||
return self.worker
|
||||
def create_or_resume_task(self,request):
|
||||
self.requests.append(request)
|
||||
receipt=self.admission_overrides.get(
|
||||
request.task_id,
|
||||
TaskAdmissionReceipt(
|
||||
request.task_id,1,request.target_branch,True,True,
|
||||
"task:"+request.task_id,
|
||||
)
|
||||
)
|
||||
if receipt.persisted:
|
||||
self.persisted_admissions[request.task_id]=receipt
|
||||
return receipt
|
||||
def read_task_admission(self,lane):
|
||||
return self.persisted_admissions.get(lane.task_id)
|
||||
def read_lane_execution(self,lane,task):
|
||||
return self.lane_views.get(
|
||||
lane.lane,
|
||||
LaneExecutionView(
|
||||
lane.lane,WorkState.COMPLETE,"lane:"+lane.lane,
|
||||
("lane-receipt:"+lane.lane,),code="LANE_COMPLETE",
|
||||
)
|
||||
)
|
||||
def drive_integration(self,lanes,tasks): return self.integration
|
||||
def run_gate0(self,mission_id): return self.gate
|
||||
def build_release_ready(self,mission_id): return self.ready
|
||||
def run_promotion(self,mission_id): return self.promotion
|
||||
def run_final_acceptance(self,mission_id): return self.acceptance
|
||||
|
||||
|
||||
class AiosAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.auth=FakeAuthorities()
|
||||
self.lanes=(
|
||||
lane("A","a.py"),
|
||||
lane("B","b.py"),
|
||||
lane("C","c.py"),
|
||||
)
|
||||
self.config=AiosUnattendedMissionConfig(
|
||||
mission_id="v2r2c4",
|
||||
repository="aios/aios",
|
||||
lanes=self.lanes,
|
||||
)
|
||||
self.factory=AiosUnattendedCallbackFactory(
|
||||
self.auth,self.config
|
||||
)
|
||||
|
||||
def observation(self):
|
||||
return self.factory.read_observation("v2r2c4")
|
||||
|
||||
def test_config_rejects_write_set_overlap(self):
|
||||
with self.assertRaisesRegex(ValueError,"overlapping_lane_resource"):
|
||||
AiosUnattendedMissionConfig(
|
||||
"v2r2c4","aios/aios",
|
||||
(lane("A","same.py"),lane("B","same.py")),
|
||||
)
|
||||
|
||||
def test_config_rejects_non_agent_branch(self):
|
||||
with self.assertRaisesRegex(ValueError,"agent_prefix"):
|
||||
TaskLaneSpec(
|
||||
"A","task-a","aios/aios","feature/a","medium","x",
|
||||
("a.py",),
|
||||
)
|
||||
|
||||
def test_observation_fingerprint_changes_with_head(self):
|
||||
first=self.observation().condition_fingerprint
|
||||
self.auth.head="b"*40
|
||||
second=self.observation().condition_fingerprint
|
||||
self.assertNotEqual(first,second)
|
||||
|
||||
def test_inactive_grant_blocks_without_task_creation(self):
|
||||
self.auth.grant=replace(self.auth.grant,active=False)
|
||||
result=self.factory.admission(
|
||||
self.observation(),None,None
|
||||
)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_owner_only_grant_boundary_is_typed_owner_blocker(self):
|
||||
self.auth.grant=replace(
|
||||
self.auth.grant,active=False,owner_action_required=True
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_OWNER)
|
||||
self.assertIsNotNone(result.owner_action_payload)
|
||||
|
||||
def test_grant_scope_mismatch_blocks_before_worker(self):
|
||||
self.auth.grant=replace(
|
||||
self.auth.grant,allowed_target_prefixes=("other/",)
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_worker_starting_is_in_progress(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.STARTING,"worker:start",7,False,"WORKER_STARTING"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,7)
|
||||
self.assertEqual(self.auth.requests,[])
|
||||
|
||||
def test_worker_unknown_reconciles(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.UNKNOWN,None,None,False,"WORKER_UNKNOWN"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_active_worker_creates_all_tasks_deterministically(self):
|
||||
obs=self.observation()
|
||||
result=self.factory.admission(obs,None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(len(self.auth.requests),3)
|
||||
self.assertTrue(all(r.base_oid==obs.head_oid for r in self.auth.requests))
|
||||
keys=[r.idempotency_key for r in self.auth.requests]
|
||||
self.assertEqual(len(keys),len(set(keys)))
|
||||
|
||||
def test_unpersisted_task_requires_reconcile(self):
|
||||
self.auth.admission_overrides["task-B"]=TaskAdmissionReceipt(
|
||||
"task-B",1,"agent/B",False,False,"task:B"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_unclaimable_task_waits(self):
|
||||
self.auth.admission_overrides["task-B"]=TaskAdmissionReceipt(
|
||||
"task-B",1,"agent/B",True,False,"task:B"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
|
||||
def test_source_execution_unknown_lane_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.UNKNOWN,"lane:B",code="LANE_B_UNKNOWN"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_source_execution_running_lane_waits(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.RUNNING,"lane:B",retry_after_seconds=9,
|
||||
code="LANE_B_RUNNING"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,9)
|
||||
|
||||
def test_source_execution_blocked_lane_is_deterministic_block(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.BLOCKED,"lane:B",code="LANE_B_BLOCKED"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_source_execution_complete_advances_to_gate0(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.GATE0)
|
||||
|
||||
def test_missing_admission_receipts_reconciles_if_authority_has_none(self):
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_ADMISSION_NOT_RESOLVED")
|
||||
|
||||
def test_restart_rebuilds_admission_receipts_from_authority(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
restarted=AiosUnattendedCallbackFactory(self.auth,self.config)
|
||||
result=restarted.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.GATE0)
|
||||
|
||||
def test_stale_generation_admission_receipt_reconciles(self):
|
||||
self.auth.persisted_admissions["task-A"]=TaskAdmissionReceipt(
|
||||
"task-A",2,"agent/A",True,True,"task:A:gen2"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_GENERATION_MISMATCH")
|
||||
|
||||
def test_wrong_branch_admission_receipt_reconciles(self):
|
||||
self.auth.persisted_admissions["task-A"]=TaskAdmissionReceipt(
|
||||
"task-A",1,"agent/wrong",True,True,"task:A:wrong"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"TASK_BRANCH_MISMATCH")
|
||||
|
||||
def test_gate_hold_blocks(self):
|
||||
self.auth.gate=GateView(
|
||||
GateState.HOLD,"gate-hold",code="GATE0_HOLD"
|
||||
)
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_gate_unknown_reconciles(self):
|
||||
self.auth.gate=GateView(
|
||||
GateState.UNKNOWN,"gate-unknown",code="GATE0_UNKNOWN"
|
||||
)
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_without_material_digest_reconciles(self):
|
||||
self.auth.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready",None,code="READY"
|
||||
)
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_freezes_material_for_controller(self):
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.material_digest,"mat-1")
|
||||
self.assertEqual(result.next_phase,MissionPhase.PROMOTION)
|
||||
|
||||
def test_promotion_unknown_reconciles(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.UNKNOWN,"p-unknown","mat-1",code="PROMOTION_UNKNOWN"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_promotion_in_progress_waits(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.IN_PROGRESS,"p-run","mat-1",
|
||||
retry_after_seconds=5,code="PROMOTION_RUNNING"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.IN_PROGRESS)
|
||||
self.assertEqual(result.retry_after_seconds,5)
|
||||
|
||||
def test_promotion_verified_advances(self):
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.FINAL_ACCEPTANCE)
|
||||
|
||||
def test_rolled_back_promotion_blocks_condition(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.ROLLED_BACK,"p-rb","mat-1",code="ROLLED_BACK"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.BLOCKED_DETERMINISTIC)
|
||||
|
||||
def test_final_acceptance_pass_completes(self):
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.SUCCEEDED)
|
||||
self.assertEqual(result.next_phase,MissionPhase.COMPLETE)
|
||||
|
||||
def test_final_acceptance_unknown_reconciles(self):
|
||||
self.auth.acceptance=AcceptanceView(
|
||||
AcceptanceState.UNKNOWN,"a-unknown",code="ACCEPT_UNKNOWN"
|
||||
)
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
|
||||
def test_active_worker_without_receipt_reconciles(self):
|
||||
self.auth.worker=WorkerActivationReceipt(
|
||||
WorkerState.ACTIVE,None,None,False,"WORKER_ACTIVE"
|
||||
)
|
||||
result=self.factory.admission(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"WORKER_ACTIVE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_complete_lane_without_receipt_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.lane_views["B"]=LaneExecutionView(
|
||||
"B",WorkState.COMPLETE,"lane:B",(),code="LANE_COMPLETE"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"LANE_COMPLETE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_complete_integration_without_receipt_reconciles(self):
|
||||
self.factory.admission(self.observation(),None,None)
|
||||
self.auth.integration=IntegrationView(
|
||||
WorkState.COMPLETE,"integration-empty",(),code="COMPLETE"
|
||||
)
|
||||
result=self.factory.source_execution(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
self.assertEqual(result.code,"INTEGRATION_COMPLETE_WITHOUT_RECEIPT")
|
||||
|
||||
def test_gate_pass_without_receipt_reconciles(self):
|
||||
self.auth.gate=GateView(GateState.PASS,"gate-empty",(),code="PASS")
|
||||
result=self.factory.gate0(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_ready_without_receipt_reconciles(self):
|
||||
self.auth.ready=ReleaseReadyView(
|
||||
ReadyState.READY,"ready-empty","mat-1",(),code="READY"
|
||||
)
|
||||
result=self.factory.release_ready(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_promotion_verified_without_receipt_reconciles(self):
|
||||
self.auth.promotion=PromotionView(
|
||||
PromotionState.VERIFIED,"p-empty","mat-1",(),code="VERIFIED"
|
||||
)
|
||||
result=self.factory.promotion(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_final_pass_without_receipt_reconciles(self):
|
||||
self.auth.acceptance=AcceptanceView(
|
||||
AcceptanceState.PASS,"a-empty",(),code="PASS"
|
||||
)
|
||||
result=self.factory.final_acceptance(self.observation(),None,None)
|
||||
self.assertEqual(result.status,StepStatus.RECONCILE)
|
||||
|
||||
def test_controller_port_can_execute_admission_cycle(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.factory.build_port(),
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.auth.checkpoint.phase,MissionPhase.S2_EXECUTION)
|
||||
|
||||
def test_unknown_effect_routes_controller_to_reconcile(self):
|
||||
self.auth.effect=MissionEffectView(
|
||||
EffectState.UNKNOWN,"unknown-effect",True,()
|
||||
)
|
||||
controller=AutonomousResumeController(
|
||||
self.factory.build_port(),
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(self.auth.reconcile_calls,1)
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,491 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
EffectState,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
ResumePolicy,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
|
||||
|
||||
class FakePort:
|
||||
def __init__(self):
|
||||
self.observation = MissionObservation(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
head_oid="a" * 40,
|
||||
material_digest="mat-1",
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
self.checkpoint = None
|
||||
self.lease_available = True
|
||||
self.step_results = []
|
||||
self.reconcile_results = []
|
||||
self.persisted = []
|
||||
self.owner_records = []
|
||||
self.released = []
|
||||
self.lease_override = None
|
||||
self.persist_failure = None
|
||||
self.step_calls = 0
|
||||
self.reconcile_calls = 0
|
||||
|
||||
def read_observation(self, mission_id):
|
||||
self.assert_mission(mission_id)
|
||||
return self.observation
|
||||
|
||||
def load_checkpoint(self, mission_id):
|
||||
self.assert_mission(mission_id)
|
||||
return self.checkpoint
|
||||
|
||||
def acquire_resume_lease(self, *, mission_id, generation, idempotency_key, lease_seconds):
|
||||
self.assert_mission(mission_id)
|
||||
if not self.lease_available:
|
||||
return None
|
||||
if self.lease_override is not None:
|
||||
return self.lease_override
|
||||
return ResumeLease(
|
||||
lease_id="lease-1",
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
fencing_token="fence-1",
|
||||
expires_at=999999,
|
||||
)
|
||||
|
||||
def step(self, *, phase, observation, checkpoint, lease):
|
||||
self.step_calls += 1
|
||||
if not self.step_results:
|
||||
raise AssertionError("missing step result")
|
||||
return self.step_results.pop(0)
|
||||
|
||||
def reconcile(self, *, observation, checkpoint, lease):
|
||||
self.reconcile_calls += 1
|
||||
if not self.reconcile_results:
|
||||
raise AssertionError("missing reconcile result")
|
||||
return self.reconcile_results.pop(0)
|
||||
|
||||
def persist_checkpoint(self, checkpoint, *, lease):
|
||||
if self.persist_failure is not None:
|
||||
raise self.persist_failure
|
||||
self.checkpoint = checkpoint
|
||||
self.persisted.append(checkpoint)
|
||||
|
||||
def persist_owner_action_required(self, payload, *, observation, checkpoint, lease):
|
||||
self.owner_records.append(dict(payload))
|
||||
return "evidence:owner-action-1"
|
||||
|
||||
def release_resume_lease(self, lease, *, outcome_code):
|
||||
self.released.append((lease.lease_id, outcome_code))
|
||||
|
||||
@staticmethod
|
||||
def assert_mission(mission_id):
|
||||
if mission_id != "v2r2c4":
|
||||
raise AssertionError("unexpected mission")
|
||||
|
||||
|
||||
def success(code="OK", *, next_phase=None, material_digest=None):
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
code,
|
||||
"cond-step",
|
||||
receipt_refs=("receipt:" + code,),
|
||||
next_phase=next_phase,
|
||||
material_digest=material_digest,
|
||||
)
|
||||
|
||||
|
||||
class AutonomousResumeControllerTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.port = FakePort()
|
||||
self.now = 100.0
|
||||
self.controller = AutonomousResumeController(
|
||||
self.port,
|
||||
policy=ResumePolicy(
|
||||
resume_lease_seconds=120,
|
||||
default_poll_seconds=30,
|
||||
deterministic_block_poll_seconds=300,
|
||||
transient_max_attempts=3,
|
||||
transient_base_delay_seconds=2,
|
||||
transient_max_delay_seconds=60,
|
||||
),
|
||||
clock=lambda: self.now,
|
||||
)
|
||||
|
||||
def cycle(self):
|
||||
return self.controller.run_once("v2r2c4")
|
||||
|
||||
def test_initial_success_advances_from_admission_to_execution(self):
|
||||
self.port.step_results=[success("ADMITTED")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.step_calls, 1)
|
||||
self.assertEqual(self.port.reconcile_calls, 0)
|
||||
self.assertTrue(self.port.released)
|
||||
|
||||
def test_resume_lease_denied_means_active_elsewhere_without_mutation(self):
|
||||
self.port.lease_available=False
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.ACTIVE_ELSEWHERE)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
self.assertEqual(self.port.persisted, [])
|
||||
|
||||
def test_unknown_effect_reconciles_before_phase_step(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
effect_state=EffectState.UNKNOWN,
|
||||
)
|
||||
self.port.reconcile_results=[success("RECONCILED", next_phase=MissionPhase.S2_ADMISSION)]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.reconcile_calls, 1)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
|
||||
def test_requires_reconcile_uses_reconcile_port(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
requires_reconcile=True,
|
||||
)
|
||||
self.port.reconcile_results=[success("RECONCILED", next_phase=MissionPhase.S2_ADMISSION)]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls, 1)
|
||||
self.assertEqual(self.port.step_calls, 0)
|
||||
|
||||
def test_transient_retry_uses_exponential_backoff(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","transient"),
|
||||
]
|
||||
first=self.cycle()
|
||||
self.assertEqual(first.status, OutcomeStatus.RETRY_SCHEDULED)
|
||||
self.assertEqual(first.next_wake_after_seconds,2)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,1)
|
||||
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","transient"),
|
||||
]
|
||||
second=self.cycle()
|
||||
self.assertEqual(second.next_wake_after_seconds,4)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,2)
|
||||
|
||||
def test_retry_budget_exhaustion_blocks_until_condition_change(self):
|
||||
for _ in range(3):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","cond-1"),
|
||||
]
|
||||
self.cycle()
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RETRYABLE,"TIMEOUT","cond-1"),
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(self.port.checkpoint.blocked_condition_fingerprint,"cond-1")
|
||||
|
||||
# Same observed condition suppresses another consequential step.
|
||||
step_calls=self.port.step_calls
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
out2=self.cycle()
|
||||
self.assertEqual(out2.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(self.port.step_calls,step_calls)
|
||||
|
||||
def test_condition_change_clears_deterministic_block(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
blocked_condition_fingerprint="old",
|
||||
attempt_count=4,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
condition_fingerprint="new",
|
||||
)
|
||||
self.port.step_results=[success("RECOVERED")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertIsNone(self.port.checkpoint.blocked_condition_fingerprint)
|
||||
self.assertEqual(self.port.checkpoint.attempt_count,0)
|
||||
|
||||
def test_deterministic_blocker_is_persisted_without_retry_storm(self):
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.BLOCKED_DETERMINISTIC,
|
||||
"WORKER_IDENTITY_UNAVAILABLE",
|
||||
"identity-v1",
|
||||
)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.WAIT_CONDITION_CHANGE)
|
||||
self.assertEqual(out.next_wake_after_seconds,300)
|
||||
|
||||
def test_owner_blocker_persists_record_and_stops_only_phase(self):
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.BLOCKED_OWNER,
|
||||
"HARDWARE_SIGNATURE_REQUIRED",
|
||||
"owner-boundary-1",
|
||||
owner_action_payload={"envelope_sha256":"abc"},
|
||||
)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.checkpoint.resume_phase_after_owner, MissionPhase.S2_ADMISSION)
|
||||
self.assertEqual(len(self.port.owner_records),1)
|
||||
self.assertIn("evidence:owner-action-1",self.port.checkpoint.receipt_refs)
|
||||
|
||||
def test_owner_action_resolved_resumes_previous_phase(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=MissionPhase.GATE0,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
owner_action_resolved=True,
|
||||
)
|
||||
self.port.step_results=[
|
||||
success("GATE0_PASS", next_phase=MissionPhase.RELEASE_READY),
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.RELEASE_READY)
|
||||
|
||||
def test_owner_action_unresolved_returns_without_acquiring_lease(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.OWNER_ACTION_REQUIRED,
|
||||
resume_phase_after_owner=MissionPhase.GATE0,
|
||||
)
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.OWNER_ACTION_REQUIRED)
|
||||
self.assertEqual(self.port.released,[])
|
||||
|
||||
def test_generation_increase_rewinds_to_admission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.GATE0,
|
||||
generation=1,
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
generation=2,
|
||||
head_oid="b"*40,
|
||||
material_digest="mat-2",
|
||||
)
|
||||
self.port.step_results=[success("READMITTED")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.checkpoint.generation,2)
|
||||
|
||||
def test_checkpoint_generation_ahead_is_rejected(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,generation=2)
|
||||
with self.assertRaisesRegex(ValueError,"checkpoint_generation_ahead"):
|
||||
self.cycle()
|
||||
|
||||
def test_head_change_before_ready_rewinds_to_admission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.GATE0)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
head_oid="b"*40,
|
||||
material_digest="mat-2",
|
||||
)
|
||||
self.port.step_results=[success("READMITTED")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase, MissionPhase.S2_EXECUTION)
|
||||
self.assertEqual(self.port.checkpoint.head_oid,"b"*40)
|
||||
|
||||
def test_release_ready_success_freezes_material_digest(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.RELEASE_READY)
|
||||
self.port.step_results=[
|
||||
success("READY",material_digest="frozen-1"),
|
||||
]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.PROMOTION)
|
||||
self.assertEqual(self.port.checkpoint.frozen_material_digest,"frozen-1")
|
||||
|
||||
def test_material_change_after_ready_rewinds_to_release_ready(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="changed",
|
||||
)
|
||||
self.port.step_results=[success("READY_AGAIN",material_digest="changed")]
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.PROMOTION)
|
||||
self.assertEqual(self.port.checkpoint.frozen_material_digest,"changed")
|
||||
|
||||
def test_complete_checkpoint_suppresses_all_effects(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.COMPLETE)
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.COMPLETE)
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[])
|
||||
|
||||
def test_final_acceptance_success_completes_mission(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(cp,phase=MissionPhase.FINAL_ACCEPTANCE)
|
||||
self.port.step_results=[success("FINAL_OK")]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status, OutcomeStatus.COMPLETE)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.COMPLETE)
|
||||
|
||||
def test_reconcile_status_schedules_immediate_reconcile_cycle(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RECONCILE,"CAS_UNKNOWN","cas-unknown")
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status,OutcomeStatus.IN_PROGRESS)
|
||||
self.assertEqual(out.next_wake_after_seconds,0)
|
||||
self.assertTrue(out.code.startswith("RECONCILE_REQUIRED:"))
|
||||
|
||||
def test_step_receipts_are_deduplicated_and_persisted(self):
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
evidence_refs=("e:obs",),
|
||||
)
|
||||
self.port.step_results=[
|
||||
StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"OK",
|
||||
"cond-step",
|
||||
receipt_refs=("e:obs","e:step"),
|
||||
)
|
||||
]
|
||||
self.cycle()
|
||||
self.assertEqual(
|
||||
self.port.checkpoint.receipt_refs,
|
||||
("e:obs","e:step"),
|
||||
)
|
||||
|
||||
|
||||
|
||||
def test_stale_resume_lease_is_rejected_before_effect(self):
|
||||
self.port.lease_override=ResumeLease(
|
||||
lease_id="stale",
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
fencing_token="fence-stale",
|
||||
expires_at=99,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError,"resume_lease_expired"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[("stale","INVALID_RESUME_LEASE")])
|
||||
|
||||
def test_wrong_generation_resume_lease_is_rejected_before_effect(self):
|
||||
self.port.lease_override=ResumeLease(
|
||||
lease_id="wrong-gen",
|
||||
mission_id="v2r2c4",
|
||||
generation=2,
|
||||
fencing_token="fence-2",
|
||||
expires_at=999,
|
||||
)
|
||||
with self.assertRaisesRegex(ValueError,"resume_lease_generation_mismatch"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.released,[("wrong-gen","INVALID_RESUME_LEASE")])
|
||||
|
||||
def test_checkpoint_persist_failure_still_releases_resume_lease(self):
|
||||
self.port.step_results=[success("ADMITTED")]
|
||||
self.port.persist_failure=RuntimeError("persist failed")
|
||||
with self.assertRaisesRegex(RuntimeError,"persist failed"):
|
||||
self.cycle()
|
||||
self.assertEqual(self.port.released,[("lease-1","ADMITTED")])
|
||||
|
||||
def test_promotion_step_material_mismatch_rewinds_and_reconciles(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.step_results=[
|
||||
success("PROMOTED",material_digest="late-change")
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(out.status,OutcomeStatus.IN_PROGRESS)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.RELEASE_READY)
|
||||
self.assertTrue(self.port.checkpoint.reconcile_required)
|
||||
self.assertIsNone(self.port.checkpoint.frozen_material_digest)
|
||||
self.assertEqual(out.next_wake_after_seconds,0)
|
||||
|
||||
def test_step_reconcile_flag_is_persisted_until_reconcile_succeeds(self):
|
||||
self.port.step_results=[
|
||||
StepResult(StepStatus.RECONCILE,"CAS_UNKNOWN","cas-unknown")
|
||||
]
|
||||
first=self.cycle()
|
||||
self.assertTrue(self.port.checkpoint.reconcile_required)
|
||||
self.assertEqual(first.next_wake_after_seconds,0)
|
||||
|
||||
# Next observation is ordinary, but durable checkpoint forces reconcile.
|
||||
self.port.reconcile_results=[
|
||||
success("RECONCILED",next_phase=MissionPhase.S2_ADMISSION)
|
||||
]
|
||||
second=self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls,1)
|
||||
self.assertFalse(self.port.checkpoint.reconcile_required)
|
||||
self.assertEqual(second.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
def test_material_change_during_active_promotion_reconciles_before_prepare(self):
|
||||
cp=MissionCheckpoint.initial(self.port.observation,observed_at=1)
|
||||
self.port.checkpoint=replace(
|
||||
cp,
|
||||
phase=MissionPhase.PROMOTION,
|
||||
frozen_material_digest="frozen-1",
|
||||
material_digest="frozen-1",
|
||||
)
|
||||
self.port.observation=replace(
|
||||
self.port.observation,
|
||||
material_digest="changed",
|
||||
effect_state=EffectState.ACTIVE,
|
||||
)
|
||||
self.port.reconcile_results=[
|
||||
success("PROMOTION_RECONCILED",next_phase=MissionPhase.RELEASE_READY)
|
||||
]
|
||||
out=self.cycle()
|
||||
self.assertEqual(self.port.reconcile_calls,1)
|
||||
self.assertEqual(self.port.step_calls,0)
|
||||
self.assertEqual(self.port.checkpoint.phase,MissionPhase.RELEASE_READY)
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
|
||||
def test_resume_idempotency_is_stable_per_mission_generation(self):
|
||||
key1=self.controller._resume_idempotency_key(self.port.observation)
|
||||
key2=self.controller._resume_idempotency_key(self.port.observation)
|
||||
self.assertEqual(key1,key2)
|
||||
other=replace(self.port.observation,generation=2)
|
||||
self.assertNotEqual(
|
||||
key1,
|
||||
self.controller._resume_idempotency_key(other),
|
||||
)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,179 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from task_plane.autonomous_resume_controller import (
|
||||
AutonomousResumeController,
|
||||
MissionCheckpoint,
|
||||
MissionObservation,
|
||||
MissionPhase,
|
||||
OutcomeStatus,
|
||||
ResumeLease,
|
||||
StepResult,
|
||||
StepStatus,
|
||||
)
|
||||
from task_plane.autonomous_resume_integration import (
|
||||
AuthorityCallbacks,
|
||||
AutonomousSchedulerTick,
|
||||
CallableAutonomousExecutionPort,
|
||||
PhaseCallbacks,
|
||||
)
|
||||
|
||||
|
||||
class IntegrationAdapterTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.observation=MissionObservation(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
head_oid="a"*40,
|
||||
material_digest="mat-1",
|
||||
condition_fingerprint="cond-1",
|
||||
)
|
||||
self.checkpoint=None
|
||||
self.phase_seen=[]
|
||||
self.released=[]
|
||||
|
||||
def phase(name):
|
||||
def _handler(obs, cp, lease):
|
||||
self.phase_seen.append(name)
|
||||
return StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
name.upper(),
|
||||
"cond-step",
|
||||
)
|
||||
return _handler
|
||||
|
||||
self.phases=PhaseCallbacks(
|
||||
admission=phase("admission"),
|
||||
source_execution=phase("source"),
|
||||
gate0=phase("gate0"),
|
||||
release_ready=phase("ready"),
|
||||
promotion=phase("promotion"),
|
||||
final_acceptance=phase("final"),
|
||||
)
|
||||
|
||||
self.authority=AuthorityCallbacks(
|
||||
read_observation=lambda mission_id:self.observation,
|
||||
load_checkpoint=lambda mission_id:self.checkpoint,
|
||||
acquire_resume_lease=lambda mission_id,generation,key,seconds:ResumeLease(
|
||||
lease_id="lease-1",
|
||||
mission_id=mission_id,
|
||||
generation=generation,
|
||||
fencing_token="fence-1",
|
||||
expires_at=999,
|
||||
),
|
||||
persist_checkpoint=self._persist,
|
||||
persist_owner_action_required=lambda payload,obs,cp,lease:"owner:1",
|
||||
release_resume_lease=lambda lease,code:self.released.append((lease.lease_id,code)),
|
||||
reconcile=lambda obs,cp,lease:StepResult(
|
||||
StepStatus.SUCCEEDED,
|
||||
"RECONCILED",
|
||||
"cond-r",
|
||||
next_phase=cp.phase,
|
||||
),
|
||||
)
|
||||
self.port=CallableAutonomousExecutionPort(
|
||||
self.authority,
|
||||
self.phases,
|
||||
)
|
||||
|
||||
def _persist(self, checkpoint, lease):
|
||||
self.checkpoint=checkpoint
|
||||
|
||||
def test_phase_dispatch_maps_all_executable_phases(self):
|
||||
expected={
|
||||
MissionPhase.S2_ADMISSION:"admission",
|
||||
MissionPhase.S2_EXECUTION:"source",
|
||||
MissionPhase.GATE0:"gate0",
|
||||
MissionPhase.RELEASE_READY:"ready",
|
||||
MissionPhase.PROMOTION:"promotion",
|
||||
MissionPhase.FINAL_ACCEPTANCE:"final",
|
||||
}
|
||||
lease=ResumeLease("l","v2r2c4",1,"f",999)
|
||||
cp=MissionCheckpoint.initial(self.observation,observed_at=1)
|
||||
for phase,name in expected.items():
|
||||
self.phases.for_phase(phase)(self.observation,cp,lease)
|
||||
self.assertEqual(self.phase_seen[-1],name)
|
||||
|
||||
def test_non_executable_phase_fails_closed(self):
|
||||
with self.assertRaisesRegex(ValueError,"phase_has_no_executor"):
|
||||
self.phases.for_phase(MissionPhase.COMPLETE)
|
||||
|
||||
def test_callable_port_runs_controller(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.port,
|
||||
clock=lambda:100,
|
||||
)
|
||||
out=controller.run_once("v2r2c4")
|
||||
self.assertEqual(out.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(self.phase_seen,["admission"])
|
||||
self.assertEqual(self.checkpoint.phase,MissionPhase.S2_EXECUTION)
|
||||
|
||||
def test_scheduler_tick_does_not_loop(self):
|
||||
controller=AutonomousResumeController(
|
||||
self.port,
|
||||
clock=lambda:100,
|
||||
)
|
||||
tick=AutonomousSchedulerTick(controller,"v2r2c4")
|
||||
result=tick.run()
|
||||
self.assertEqual(result.outcome.status,OutcomeStatus.PROGRESSED)
|
||||
self.assertEqual(result.next_wake_after_seconds,30)
|
||||
self.assertEqual(len(self.phase_seen),1)
|
||||
|
||||
def test_wrong_read_observation_type_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=lambda mission_id:{"bad":True},
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
with self.assertRaisesRegex(TypeError,"MissionObservation"):
|
||||
port.read_observation("v2r2c4")
|
||||
|
||||
def test_wrong_lease_type_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=self.authority.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=lambda mission_id,generation,key,seconds:"bad",
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=self.authority.persist_owner_action_required,
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
with self.assertRaisesRegex(TypeError,"lease_or_none"):
|
||||
port.acquire_resume_lease(
|
||||
mission_id="v2r2c4",
|
||||
generation=1,
|
||||
idempotency_key="k",
|
||||
lease_seconds=120,
|
||||
)
|
||||
|
||||
def test_empty_owner_action_record_rejected(self):
|
||||
authority=AuthorityCallbacks(
|
||||
read_observation=self.authority.read_observation,
|
||||
load_checkpoint=self.authority.load_checkpoint,
|
||||
acquire_resume_lease=self.authority.acquire_resume_lease,
|
||||
persist_checkpoint=self.authority.persist_checkpoint,
|
||||
persist_owner_action_required=lambda payload,obs,cp,lease:"",
|
||||
release_resume_lease=self.authority.release_resume_lease,
|
||||
reconcile=self.authority.reconcile,
|
||||
)
|
||||
port=CallableAutonomousExecutionPort(authority,self.phases)
|
||||
cp=MissionCheckpoint.initial(self.observation,observed_at=1)
|
||||
lease=ResumeLease("l","v2r2c4",1,"f",999)
|
||||
with self.assertRaisesRegex(ValueError,"owner_action_record_ref_required"):
|
||||
port.persist_owner_action_required(
|
||||
{},
|
||||
observation=self.observation,
|
||||
checkpoint=cp,
|
||||
lease=lease,
|
||||
)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,344 @@
|
||||
"""端到端实施证明:owner-based authorization 现场装配 + 生产入口闭环(集成测试)。
|
||||
|
||||
本文件位于真实 dev 树(不是玩具),直接 import 生产模块并真实执行:
|
||||
|
||||
- 使用 OWNER_AUTHORIZATION_MODEL.md 的**参考授权物** `azn-owner-gw-v1`
|
||||
(policy_fingerprint = sha256:5a2618d6...),经 `FieldOwnerAuthorizationAuthority`
|
||||
登记为"Owner 的正式一次授权"。
|
||||
- 用 `FieldGovernedWriterFactory`(组合根)把 7 个适配器装配到
|
||||
`GovernedWriterAutomation.acquire_verified()` 生产入口。
|
||||
- 证明:Owner 一次授权后,Agent 在 `legacy_global_writes_enabled=false` 前提下
|
||||
自主跑完完整受治理写链(epoch -> claim -> TaskLease -> TaskPrincipal -> job
|
||||
-> live CAS -> WriterLease),返回 `GovernedWriterSession`(WRITER_LEASED)。
|
||||
- 证明 fail-closed:未知 / 未登记授权引用、authority 未装配、缺 receipt、
|
||||
EXPIRED 任务无预签 recovery,都必须在**任何副作用之前**阻断。
|
||||
|
||||
Windows 说明:与既有 T2/T3 同,用 `mkdtemp()` + `gc.collect()` 再 `rmtree`。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.task_plane.field_governed_writer import (
|
||||
FieldGovernedWriterFactory,
|
||||
FieldOwnerAuthorizationAuthority,
|
||||
FieldRuntimeStatus,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriterState,
|
||||
GovernedWriterUnavailable,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
TaskState,
|
||||
)
|
||||
|
||||
# ---- 参考授权物(与模型文档逐字段一致) ----
|
||||
AZN_REF = "azn-owner-gw-v1"
|
||||
AZN_FINGERPRINT = "sha256:5a2618d639b4a87fa4d5f9fd2894f1780d25d3c1170f50792f5c272f06602434"
|
||||
_AZN_CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
_AZN_OPS = frozenset(
|
||||
{
|
||||
"claim",
|
||||
"epoch_reissue",
|
||||
"governed_commit",
|
||||
"heartbeat",
|
||||
"job_reconcile",
|
||||
"readback",
|
||||
"reconcile",
|
||||
"successor_recovery",
|
||||
"task_lease",
|
||||
"task_principal",
|
||||
"writer_lease",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ref_policy(*, expires_at: float | None = None) -> OwnerAuthorizationPolicy:
|
||||
"""现场登记的 Owner 长期授权。
|
||||
|
||||
`expires_at` 默认用**当前有效**的远期时间(真实登记时 Owner 设定当下有效的到期日)。
|
||||
模型文档里的 `1789603200` 是固定指纹锚(见 test_reference_authorization_fingerprint_is_stable 与
|
||||
verify_owner_auth_fingerprint.py),代表"该 example 的确定性指纹",而非当下可验证的在线授权。
|
||||
两者不冲突:指纹锚保证可复现;在线登记用实时有效期保证当下可验证。
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
if expires_at is None:
|
||||
expires_at = _time.time() + 30 * 86400.0
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id=AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=expires_at,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
|
||||
|
||||
SUBJECT = "aios-autonomous-admin"
|
||||
REPO = "aios/aios"
|
||||
BRANCH = "agent/autonomy-v2-gw-field-20260917"
|
||||
TASK = "task-gw-field-0001"
|
||||
BASE_OID = "5cf20128d8be2d10aef87b09f8f7a2aecb2000f3"
|
||||
RUNTIME = "runtime-gw-field"
|
||||
PROCESS_START = "pid:field:start:1"
|
||||
PLAN_DIGEST = "sha256:plan-field"
|
||||
FILES = (
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
"active-runtime/app/task_plane/field_governed_writer.py",
|
||||
)
|
||||
|
||||
|
||||
def make_request(**overrides) -> GovernedWriterRequest:
|
||||
body = dict(
|
||||
subject=SUBJECT,
|
||||
task_id=TASK,
|
||||
repository=REPO,
|
||||
target_branch=BRANCH,
|
||||
risk="medium",
|
||||
generation=1,
|
||||
base_oid=BASE_OID,
|
||||
required_capabilities=_AZN_CAPS,
|
||||
file_allowlist=FILES,
|
||||
plan_digest=PLAN_DIGEST,
|
||||
manifest_id="m",
|
||||
runtime_fingerprint=RUNTIME,
|
||||
process_start_identity=PROCESS_START,
|
||||
)
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
class TestTempDirBase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._dir = tempfile.mkdtemp(prefix="gw_field_")
|
||||
|
||||
def tearDown(self): # pragma: no cover - 仅规避 Windows 清理期 WinError 32
|
||||
gc.collect()
|
||||
shutil.rmtree(self._dir, ignore_errors=True)
|
||||
|
||||
def _store_and_ledger(self):
|
||||
store = ManifestStore(db_path=f"{self._dir}/manifest.db")
|
||||
ledger = ActionLedger(db_path=f"{self._dir}/ledger.db")
|
||||
return store, ledger
|
||||
|
||||
def _freeze_manifest(self, store):
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(
|
||||
DiscoveryObservation.build(
|
||||
worker="W1",
|
||||
runtime_fingerprint=RUNTIME,
|
||||
process_start_identity=PROCESS_START,
|
||||
activation_digest="activation-field",
|
||||
epoch_id="epoch-field-prov",
|
||||
policy_digest="policy-field",
|
||||
plan_provider_root="/plans/field",
|
||||
task_id=TASK,
|
||||
grant_id="grant-field",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
)
|
||||
return store.freeze(barrier.freeze())
|
||||
|
||||
def _build_factory(self, authority, store, ledger, mid, *, task_status="QUEUED",
|
||||
branch_exists=False, head_oid=None, task_id=TASK):
|
||||
status = FieldRuntimeStatus(
|
||||
legacy_global_writes_enabled=False,
|
||||
task_principal_writes_enabled=True,
|
||||
task_principal_verifier_ready=True,
|
||||
governed_write_blockers=(),
|
||||
)
|
||||
return FieldGovernedWriterFactory.build(
|
||||
authority=authority,
|
||||
manifest_store=store,
|
||||
action_ledger=ledger,
|
||||
task_initial=TaskState(task_id=task_id, status=task_status, revision=1),
|
||||
branch_exists=branch_exists,
|
||||
head_oid=head_oid,
|
||||
repository=REPO,
|
||||
target_branch=BRANCH,
|
||||
generation=1,
|
||||
runtime_status=status,
|
||||
)
|
||||
|
||||
|
||||
class FieldOwnerBasedAuthorizationTests(TestTempDirBase):
|
||||
"""owner-based authorization 端到端闭环。"""
|
||||
|
||||
def test_reference_authorization_fingerprint_is_stable(self):
|
||||
# 模型文档参考授权物(expires_at=1789603200 为固定锚)在真实机制类中
|
||||
# 指纹确定性一致。在线登记则用 ref_policy() 当前有效期。
|
||||
doc_anchor = OwnerAuthorizationPolicy(
|
||||
authorization_id=AZN_REF,
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=_AZN_CAPS,
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=1789603200.0,
|
||||
revoked=False,
|
||||
allowed_operations=_AZN_OPS,
|
||||
)
|
||||
self.assertEqual(doc_anchor.policy_fingerprint, AZN_FINGERPRINT)
|
||||
|
||||
def test_owner_authorizes_agent_autonomous_full_chain_create_new(self):
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(AZN_REF, ref_policy())
|
||||
# 新分支:CREATE_NEW CAS = base_oid
|
||||
factory = self._build_factory(auth, store, ledger, mid,
|
||||
branch_exists=False, head_oid=None)
|
||||
|
||||
session = factory.acquire(make_request(manifest_id=mid), AZN_REF)
|
||||
|
||||
self.assertIsInstance(session, GovernedWriterSession)
|
||||
self.assertEqual(session.state, GovernedWriterState.WRITER_LEASED)
|
||||
self.assertEqual(session.authorization_id, AZN_REF)
|
||||
# session 绑定的指纹 = 现场实际登记(live expires_at)的授权物指纹,
|
||||
# 与模型文档的固定锚(5a2618d6...,expires_at=1789603200)不同但语义一致:
|
||||
# 指纹把短时凭证绑定到"这次登记的授权"。
|
||||
self.assertEqual(session.policy_fingerprint, auth.policy(AZN_REF).policy_fingerprint)
|
||||
self.assertEqual(session.task_id, TASK)
|
||||
self.assertTrue(session.epoch_id.startswith("epoch-gw-"))
|
||||
self.assertTrue(session.claim_id.startswith("claim-gw-"))
|
||||
self.assertTrue(session.task_lease_id.startswith("tasklease-gw-"))
|
||||
self.assertTrue(session.principal_id.startswith("principal-gw-"))
|
||||
self.assertTrue(session.job_id.startswith("job-gw-"))
|
||||
self.assertTrue(session.writer_lease_id.startswith("writerlease-gw-"))
|
||||
self.assertEqual(session.branch_mode, "CREATE_NEW")
|
||||
self.assertEqual(session.expected_branch_oid, BASE_OID)
|
||||
self.assertGreater(session.expires_at, 0)
|
||||
|
||||
def test_owner_authorizes_agent_autonomous_full_chain_continue_existing(self):
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(AZN_REF, ref_policy())
|
||||
live_head = "deadbeef" * 5
|
||||
# 已存在分支:CONTINUE_EXISTING CAS = live HEAD
|
||||
factory = self._build_factory(auth, store, ledger, mid,
|
||||
branch_exists=True, head_oid=live_head)
|
||||
|
||||
session = factory.acquire(make_request(manifest_id=mid), AZN_REF)
|
||||
|
||||
self.assertEqual(session.state, GovernedWriterState.WRITER_LEASED)
|
||||
self.assertEqual(session.branch_mode, "CONTINUE_EXISTING")
|
||||
self.assertEqual(session.expected_branch_oid, live_head)
|
||||
|
||||
def test_fail_closed_unknown_reference_before_any_side_effect(self):
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(AZN_REF, ref_policy())
|
||||
factory = self._build_factory(auth, store, ledger, mid)
|
||||
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
factory.acquire(make_request(manifest_id=mid), "azn-unknown-ref")
|
||||
|
||||
# 任何副作用都不应发生:验证层在 epoch 签发之前就拒绝,
|
||||
# 因此 signed adapters 的 epoch 签发从未被调用。
|
||||
self.assertEqual(len(factory.adapters.signer.calls), 0)
|
||||
self.assertEqual(len(factory.adapters.execution.claims), 0)
|
||||
|
||||
def test_fail_closed_unregistered_reference(self):
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority() # 未登记任何授权
|
||||
factory = self._build_factory(auth, store, ledger, mid)
|
||||
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
factory.acquire(make_request(manifest_id=mid), "azn-never-registered")
|
||||
|
||||
def test_fail_closed_unconfigured_authority(self):
|
||||
# adapters.owner_authority = None -> acquire_verified 直接拒绝
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
factory = self._build_factory(
|
||||
FieldOwnerAuthorizationAuthority(), store, ledger, mid
|
||||
)
|
||||
# 用一个没装配 authority 的组合根验证 fail-closed
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
)
|
||||
|
||||
automation = GovernedWriterAutomation(
|
||||
manifest_store=store, action_ledger=ledger
|
||||
)
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=factory.adapters.signer,
|
||||
execution=factory.adapters.execution,
|
||||
principal_issuer=factory.adapters.principal_issuer,
|
||||
jobs=factory.adapters.jobs,
|
||||
git=factory.adapters.git,
|
||||
writer_lease=factory.adapters.writer_lease,
|
||||
owner_authority=None,
|
||||
)
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
automation.acquire_verified(
|
||||
request=make_request(manifest_id=mid),
|
||||
authorization_ref=AZN_REF,
|
||||
readiness=factory.readiness,
|
||||
epoch=None,
|
||||
adapters=adapters,
|
||||
)
|
||||
|
||||
def test_owner_boundary_expired_task_without_recovery(self):
|
||||
# EXPIRED 任务 + 无预签 recovery -> OWNER_BOUNDARY,不得自动恢复
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
auth.register(AZN_REF, ref_policy())
|
||||
factory = self._build_factory(auth, store, ledger, mid,
|
||||
task_status="EXPIRED")
|
||||
|
||||
with self.assertRaises(OwnerActionRequired):
|
||||
factory.acquire(make_request(manifest_id=mid), AZN_REF)
|
||||
|
||||
def test_local_policy_does_not_constitute_proof(self):
|
||||
# 即便 Agent 手头有同名 policy 对象,只要 authority 未登记,引用即被拒。
|
||||
store, ledger = self._store_and_ledger()
|
||||
mid = self._freeze_manifest(store)
|
||||
auth = FieldOwnerAuthorizationAuthority()
|
||||
# 登记一个 subject 与请求不同、但授权引用名相同 -> 仍按登记的空 registry 语义
|
||||
factory = self._build_factory(auth, store, ledger, mid)
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
factory.acquire(make_request(manifest_id=mid), AZN_REF)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
@@ -0,0 +1,66 @@
|
||||
|
||||
from app.task_plane.governance_admission import (
|
||||
AdmissionOperation, AdmissionPreflight, GovernanceFacts
|
||||
)
|
||||
from app.task_plane.governance_resolver import (
|
||||
ResolutionResult, ResolutionState, ResolvedTask
|
||||
)
|
||||
|
||||
def resolved():
|
||||
return ResolutionResult(
|
||||
ResolutionState.RESOLVED,
|
||||
ResolvedTask("t","m","r","p","owner",{},("artifact_sha256",))
|
||||
)
|
||||
|
||||
def facts(**kw):
|
||||
body=dict(
|
||||
governed_writes_enabled=True,
|
||||
task_principal_writes_enabled=True,
|
||||
recovery_execution_enabled=True,
|
||||
recovery_shadow_mode=False,
|
||||
authenticated_principal_id="principal",
|
||||
target_branch="agent/x",
|
||||
branch_protection_known=True,
|
||||
expected_branch_oid="base",
|
||||
generation=1,
|
||||
task_lease_id="tl",
|
||||
task_principal_id="tp",
|
||||
writer_lease_id="wl",
|
||||
accepted_file_allowlist=("a.py",),
|
||||
acceptance_revision="rev1",
|
||||
)
|
||||
body.update(kw)
|
||||
return GovernanceFacts(**body)
|
||||
|
||||
def test_ready_recovery_requires_only_governed_path_not_global_write():
|
||||
result=AdmissionPreflight().evaluate(
|
||||
resolution=resolved(),facts=facts(),
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD)
|
||||
assert result.ready is True
|
||||
|
||||
def test_disabled_governed_write_blocks():
|
||||
result=AdmissionPreflight().evaluate(
|
||||
resolution=resolved(),facts=facts(governed_writes_enabled=False),
|
||||
operation=AdmissionOperation.GOVERNED_COMMIT)
|
||||
assert result.ready is False
|
||||
assert result.blockers[0].code=="GOVERNED_WRITES_DISABLED"
|
||||
|
||||
def test_shadow_recovery_blocks_recovery_but_not_commit():
|
||||
shadow=facts(recovery_execution_enabled=False,recovery_shadow_mode=True)
|
||||
recovery=AdmissionPreflight().evaluate(
|
||||
resolution=resolved(),facts=shadow,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD)
|
||||
assert {"RECOVERY_EXECUTION_DISABLED","RECOVERY_SHADOW_MODE"}.issubset(
|
||||
{b.code for b in recovery.blockers})
|
||||
commit=AdmissionPreflight().evaluate(
|
||||
resolution=resolved(),facts=shadow,
|
||||
operation=AdmissionOperation.GOVERNED_COMMIT)
|
||||
assert commit.ready is True
|
||||
|
||||
def test_missing_cas_and_leases_are_explicit():
|
||||
result=AdmissionPreflight().evaluate(
|
||||
resolution=resolved(),
|
||||
facts=facts(expected_branch_oid=None,task_lease_id=None,writer_lease_id=None),
|
||||
operation=AdmissionOperation.GOVERNED_COMMIT)
|
||||
codes={b.code for b in result.blockers}
|
||||
assert {"EXPECTED_BRANCH_OID_UNRESOLVED","TASK_LEASE_MISSING","WRITER_LEASE_MISSING"}.issubset(codes)
|
||||
@@ -0,0 +1,51 @@
|
||||
|
||||
from app.task_plane.governance_checkpoint import (
|
||||
JsonCheckpointStore, RecoveryCheckpoint, RetryBudget
|
||||
)
|
||||
|
||||
def checkpoint(**kw):
|
||||
body=dict(
|
||||
schema="aios.governance-recovery-checkpoint.v1",
|
||||
goal_id="g",repository="aios/aios",source_oid="base",
|
||||
candidate_digest="cand",task_id=None,mission_id=None,root_task_id=None,
|
||||
parent_task_id=None,principal_id=None,blocker_code="TASK_NOT_RESOLVED",
|
||||
blocker_fingerprint="fp",next_legal_action="WAIT_DEPENDENCY",
|
||||
last_verified_at="2026-09-16T00:00:00Z",evidence_refs=("e",),
|
||||
unchanged_cycles=1,mechanism_repair_required=False,
|
||||
)
|
||||
body.update(kw)
|
||||
return RecoveryCheckpoint(**body)
|
||||
|
||||
def test_checkpoint_roundtrip_is_atomic_observation_cache(tmp_path):
|
||||
store=JsonCheckpointStore(tmp_path/"checkpoint.json")
|
||||
cp=checkpoint()
|
||||
store.save(cp)
|
||||
loaded=store.load()
|
||||
assert loaded==cp
|
||||
assert len(loaded.digest)==64
|
||||
assert not (tmp_path/"checkpoint.json.tmp").exists()
|
||||
|
||||
def test_same_resolver_blocker_escalates_after_three_cycles():
|
||||
budget=RetryBudget(unchanged_threshold=3)
|
||||
fp=budget.blocker_fingerprint("TASK_NOT_RESOLVED",("same",))
|
||||
cycles,repair=budget.observe(previous=None,blocker_code="TASK_NOT_RESOLVED",blocker_fingerprint=fp)
|
||||
assert (cycles,repair)==(1,False)
|
||||
prev=checkpoint(blocker_fingerprint=fp,unchanged_cycles=1)
|
||||
cycles,repair=budget.observe(previous=prev,blocker_code="TASK_NOT_RESOLVED",blocker_fingerprint=fp)
|
||||
assert (cycles,repair)==(2,False)
|
||||
prev=checkpoint(blocker_fingerprint=fp,unchanged_cycles=2)
|
||||
cycles,repair=budget.observe(previous=prev,blocker_code="TASK_NOT_RESOLVED",blocker_fingerprint=fp)
|
||||
assert (cycles,repair)==(3,True)
|
||||
|
||||
def test_external_write_gate_does_not_fake_mechanism_repair():
|
||||
budget=RetryBudget(unchanged_threshold=3)
|
||||
fp=budget.blocker_fingerprint("GOVERNED_WRITES_DISABLED",("same",))
|
||||
prev=checkpoint(blocker_code="GOVERNED_WRITES_DISABLED",blocker_fingerprint=fp,unchanged_cycles=9)
|
||||
cycles,repair=budget.observe(previous=prev,blocker_code="GOVERNED_WRITES_DISABLED",blocker_fingerprint=fp)
|
||||
assert cycles==10 and repair is False
|
||||
|
||||
def test_changed_blocker_resets_cycle():
|
||||
budget=RetryBudget()
|
||||
prev=checkpoint(unchanged_cycles=8)
|
||||
fp=budget.blocker_fingerprint("CALLER_PRINCIPAL_UNRESOLVED",("new",))
|
||||
assert budget.observe(previous=prev,blocker_code="CALLER_PRINCIPAL_UNRESOLVED",blocker_fingerprint=fp)==(1,False)
|
||||
@@ -0,0 +1,136 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionKind, ActionLedger, ActionState, AutonomousDecisionEngine,
|
||||
BlockerClassification, ConcurrentWriterDenied, ConsistencyBarrier,
|
||||
DecisionInput, DiscoveryObservation, GovernanceControlError,
|
||||
HypothesisLedger, HypothesisStatus, IdempotencyConflict, ManifestStore,
|
||||
MutationGuard, NextAction, ParallelDiscoveryCoordinator, PreflightNotReady,
|
||||
RetryBudgetExhausted, StaleManifest,
|
||||
)
|
||||
|
||||
|
||||
def observation(worker: str, **overrides):
|
||||
data = dict(
|
||||
worker=worker,runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1",activation_digest="activation-A",
|
||||
epoch_id="epoch-A",policy_digest="policy-A",plan_provider_root="/plans/A",
|
||||
task_id="task-A",grant_id="grant-A",facts={"worker": worker},
|
||||
evidence_refs=(f"evidence:{worker}",),observed_at=100.0,
|
||||
)
|
||||
data.update(overrides)
|
||||
return DiscoveryObservation.build(**data)
|
||||
|
||||
|
||||
class BarrierTests(unittest.TestCase):
|
||||
def test_freezes_only_when_all_workers_agree(self):
|
||||
b = ConsistencyBarrier(["W1","W2","W3"])
|
||||
b.submit(observation("W1")); b.submit(observation("W2"))
|
||||
with self.assertRaises(PreflightNotReady): b.freeze()
|
||||
b.submit(observation("W3"))
|
||||
m = b.freeze()
|
||||
self.assertTrue(m.manifest_id.startswith("sha256:"))
|
||||
self.assertEqual(set(m.worker_facts), {"W1","W2","W3"})
|
||||
|
||||
def test_rejects_identity_drift(self):
|
||||
b = ConsistencyBarrier(["W1","W2"])
|
||||
b.submit(observation("W1")); b.submit(observation("W2",activation_digest="activation-B"))
|
||||
with self.assertRaises(StaleManifest): b.freeze()
|
||||
|
||||
def test_parallel_discovery(self):
|
||||
m = ParallelDiscoveryCoordinator({
|
||||
"W1":lambda:observation("W1"),"W2":lambda:observation("W2")
|
||||
}).discover()
|
||||
self.assertEqual(m.runtime_fingerprint, "runtime-A")
|
||||
|
||||
|
||||
class ManifestTests(unittest.TestCase):
|
||||
def test_freeze_and_stale_guard(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
s = ManifestStore(Path(td)/"m.db")
|
||||
b = ConsistencyBarrier(["W1"]); b.submit(observation("W1")); m = b.freeze()
|
||||
mid = s.freeze(m)
|
||||
s.assert_current(manifest_id=mid,runtime_fingerprint="runtime-A",process_start_identity="pid:100:start:1")
|
||||
with self.assertRaises(StaleManifest):
|
||||
s.assert_current(manifest_id=mid,runtime_fingerprint="runtime-B",process_start_identity="pid:100:start:1")
|
||||
|
||||
|
||||
class ActionLedgerTests(unittest.TestCase):
|
||||
def test_success_reused(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
l = ActionLedger(Path(td)/"a.db")
|
||||
t1 = l.begin(idempotency_key="k1",manifest_id="m1",phase="S4",kind=ActionKind.COMMIT,target="b",precondition_hash="p")
|
||||
l.mark_succeeded(t1.action_id,result={"oid":"abc"})
|
||||
t2 = l.begin(idempotency_key="k1",manifest_id="m1",phase="S4",kind=ActionKind.COMMIT,target="b",precondition_hash="p")
|
||||
self.assertTrue(t2.reused); self.assertEqual(t2.state, ActionState.SUCCEEDED)
|
||||
|
||||
def test_effect_unknown_requires_reconcile(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
l = ActionLedger(Path(td)/"a.db")
|
||||
t1 = l.begin(idempotency_key="k2",manifest_id="m1",phase="S4",kind=ActionKind.COMMIT,target="b",precondition_hash="p")
|
||||
l.mark_effect_unknown(t1.action_id,result={"timeout":True})
|
||||
t2 = l.begin(idempotency_key="k2",manifest_id="m1",phase="S4",kind=ActionKind.COMMIT,target="b",precondition_hash="p")
|
||||
self.assertTrue(t2.requires_reconcile)
|
||||
|
||||
def test_same_key_changed_precondition_rejected(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
l = ActionLedger(Path(td)/"a.db")
|
||||
l.begin(idempotency_key="k3",manifest_id="m1",phase="S4",kind=ActionKind.CLAIM,target="t",precondition_hash="p1")
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
l.begin(idempotency_key="k3",manifest_id="m1",phase="S4",kind=ActionKind.CLAIM,target="t",precondition_hash="p2")
|
||||
|
||||
def test_restart_budget_fuses(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
l = ActionLedger(Path(td)/"a.db")
|
||||
t = l.begin(idempotency_key="k4",manifest_id="m1",phase="S4",kind=ActionKind.RESTART,target="u",precondition_hash="p")
|
||||
l.mark_failed(t.action_id,result={"rc":1})
|
||||
with self.assertRaises(RetryBudgetExhausted):
|
||||
l.begin(idempotency_key="k4",manifest_id="m1",phase="S4",kind=ActionKind.RESTART,target="u",precondition_hash="p")
|
||||
|
||||
|
||||
class HypothesisTests(unittest.TestCase):
|
||||
def test_probe_requires_two_open_hypotheses(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
h = HypothesisLedger(Path(td)/"h.db")
|
||||
h.record(hypothesis_id="h1",claim="one"); h.record(hypothesis_id="h2",claim="two")
|
||||
h.assert_probe_discriminates(["h1","h2"])
|
||||
h.set_status("h2",HypothesisStatus.REJECTED)
|
||||
with self.assertRaises(GovernanceControlError): h.assert_probe_discriminates(["h1","h2"])
|
||||
|
||||
|
||||
class DecisionTests(unittest.TestCase):
|
||||
def test_mechanism_defect_not_owner(self):
|
||||
d = AutonomousDecisionEngine().decide(DecisionInput(goal_id="G",blocker_codes=("LIVE_BINDING_VIOLATION",),owner_boundary_required=True))
|
||||
self.assertEqual(d.next_valid_action,NextAction.REPAIR_MECHANISM); self.assertFalse(d.owner_boundary)
|
||||
|
||||
def test_terminal_expiry_is_owner_boundary(self):
|
||||
d = AutonomousDecisionEngine().decide(DecisionInput(goal_id="G",blocker_codes=("TASK_EXPIRED_OWNER_RECOVERY_REQUIRED",),admission_ready=True))
|
||||
self.assertEqual(d.next_valid_action,NextAction.OWNER_BOUNDARY); self.assertTrue(d.owner_boundary)
|
||||
|
||||
def test_epoch_expiry_self_actionable(self):
|
||||
d = AutonomousDecisionEngine().decide(DecisionInput(goal_id="G",blocker_codes=("EPOCH_EXPIRED",),admission_ready=False))
|
||||
self.assertEqual(d.next_valid_action,NextAction.RECOVER_EXISTING_CHILD)
|
||||
self.assertEqual(d.classification,BlockerClassification.SELF_ACTIONABLE)
|
||||
|
||||
def test_external_dependency_does_not_escalate_by_time(self):
|
||||
d = AutonomousDecisionEngine().decide(DecisionInput(goal_id="G",blocker_codes=("TASK_NOT_RESOLVED",),admission_ready=False,unchanged_cycles=999))
|
||||
self.assertEqual(d.next_valid_action,NextAction.WAIT_DEPENDENCY); self.assertFalse(d.owner_boundary)
|
||||
|
||||
|
||||
class MutationGuardTests(unittest.TestCase):
|
||||
def test_single_writer_and_stale_manifest(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
ms = ManifestStore(Path(td)/"m.db"); al = ActionLedger(Path(td)/"a.db"); g = MutationGuard(ms,al)
|
||||
b = ConsistencyBarrier(["W1"]); b.submit(observation("W1")); m = b.freeze(); mid = ms.freeze(m)
|
||||
with self.assertRaises(StaleManifest):
|
||||
g.begin_mutation(manifest_id=mid,runtime_fingerprint="bad",process_start_identity="pid:100:start:1",idempotency_key="x",phase="S4",kind=ActionKind.GENERIC_MUTATION,target="x",precondition_hash="p")
|
||||
t = g.begin_mutation(manifest_id=mid,runtime_fingerprint="runtime-A",process_start_identity="pid:100:start:1",idempotency_key="x1",phase="S4",kind=ActionKind.GENERIC_MUTATION,target="x",precondition_hash="p")
|
||||
with self.assertRaises(ConcurrentWriterDenied):
|
||||
g.begin_mutation(manifest_id=mid,runtime_fingerprint="runtime-A",process_start_identity="pid:100:start:1",idempotency_key="x2",phase="S4",kind=ActionKind.GENERIC_MUTATION,target="y",precondition_hash="p2")
|
||||
g.finish_success(t,result={"ok":True})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,642 @@
|
||||
"""生产入口(production entrypoint)集成测试:T2 控制内核 + T3 A1/A2/A3 恢复面。
|
||||
|
||||
与既有 v1 单测的区别:这里断言的是**真实生产入口符号**的行为,而不是内核内部实现
|
||||
或 mock。所有符号都从 `app.task_plane.governance_*` 生产模块直接导入并真实执行。
|
||||
|
||||
- T2:`app.task_plane.governance_control_kernel` 的冻结控制内核公开面
|
||||
(RuntimeManifest / ConsistencyBarrier / ManifestStore / ActionLedger /
|
||||
HypothesisLedger / AutonomousDecisionEngine / MutationGuard)。
|
||||
- T3:`app.task_plane.governance_recovery_controller` +
|
||||
`app.task_plane.governance_resolver` + `governance_admission` +
|
||||
`governance_checkpoint` + `site_adapters` 组成的恢复链路,
|
||||
真实跑一次 recover 流程与一次 resolver 分辨率。
|
||||
|
||||
T2 里 `ConsistencyBarrier` 就是需求文档中的 "PreflightBarrier":控制内核的
|
||||
公开类名是 `ConsistencyBarrier`(冻结源码 sha256:b294acc2...),不存在名为
|
||||
`PreflightBarrier` 的符号。
|
||||
|
||||
Windows 说明:控制内核每次操作新建 sqlite 连接且依赖 GC 释放句柄(连接对象与
|
||||
语句缓存互成引用环),因此本文件不使用 `with tempfile.TemporaryDirectory()`,
|
||||
而是在 tearDown 中先 `gc.collect()` 再 `rmtree`,避免清理期 WinError 32。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import shutil
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.governance_admission import (
|
||||
AdmissionOperation,
|
||||
AdmissionPreflight,
|
||||
GovernanceFacts,
|
||||
)
|
||||
from app.task_plane.governance_checkpoint import (
|
||||
JsonCheckpointStore,
|
||||
RetryBudget,
|
||||
)
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionKind,
|
||||
ActionLedger,
|
||||
ActionState,
|
||||
AutonomousDecisionEngine,
|
||||
BlockerClassification,
|
||||
ConcurrentWriterDenied,
|
||||
ConsistencyBarrier,
|
||||
DecisionInput,
|
||||
DiscoveryObservation,
|
||||
GovernanceControlError,
|
||||
HypothesisLedger,
|
||||
HypothesisStatus,
|
||||
IdempotencyConflict,
|
||||
ManifestStore,
|
||||
MutationGuard,
|
||||
NextAction,
|
||||
RuntimeManifest,
|
||||
StaleManifest,
|
||||
)
|
||||
from app.task_plane.governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
RecoveryReceipt,
|
||||
)
|
||||
from app.task_plane.governance_resolver import (
|
||||
ExactTaskResolver,
|
||||
ResolutionQuery,
|
||||
ResolutionState,
|
||||
)
|
||||
from app.task_plane.site_adapters import (
|
||||
LiveEventSubscriptionAdapter,
|
||||
LiveGovernanceEvent,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 测试夹具(host port 的最小实现:源、事实提供者、执行器、checkpoint、订阅者)
|
||||
# ---------------------------------------------------------------------------
|
||||
def observation(worker: str, **overrides) -> DiscoveryObservation:
|
||||
data = dict(
|
||||
worker=worker,
|
||||
runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1",
|
||||
activation_digest="activation-A",
|
||||
epoch_id="epoch-A",
|
||||
policy_digest="policy-A",
|
||||
plan_provider_root="/plans/A",
|
||||
task_id="task-A",
|
||||
grant_id="grant-A",
|
||||
facts={"worker": worker},
|
||||
evidence_refs=(f"evidence:{worker}",),
|
||||
observed_at=100.0,
|
||||
)
|
||||
data.update(overrides)
|
||||
return DiscoveryObservation.build(**data)
|
||||
|
||||
|
||||
def task_record(
|
||||
task_id: str,
|
||||
*,
|
||||
artifact_sha256: str = "artifact",
|
||||
repository: str = "aios/aios",
|
||||
base_oid: str = "base",
|
||||
) -> dict:
|
||||
return {
|
||||
"task_id": task_id,
|
||||
"mission_id": f"mission-{task_id}",
|
||||
"root_task_id": "root-1",
|
||||
"parent_task_id": "root-1",
|
||||
"owner_principal_id": "owner",
|
||||
"plan": {
|
||||
"metadata": {
|
||||
"repository": repository,
|
||||
"base_oid": base_oid,
|
||||
"artifact_sha256": artifact_sha256,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def ready_facts(**overrides) -> GovernanceFacts:
|
||||
body = dict(
|
||||
governed_writes_enabled=True,
|
||||
task_principal_writes_enabled=True,
|
||||
recovery_execution_enabled=True,
|
||||
recovery_shadow_mode=False,
|
||||
authenticated_principal_id="principal",
|
||||
target_branch="agent/x",
|
||||
branch_protection_known=True,
|
||||
expected_branch_oid="base",
|
||||
generation=2,
|
||||
task_lease_id="tl",
|
||||
task_principal_id="tp",
|
||||
writer_lease_id="wl",
|
||||
accepted_file_allowlist=("a.py",),
|
||||
acceptance_revision="rev1",
|
||||
)
|
||||
body.update(overrides)
|
||||
return GovernanceFacts(**body)
|
||||
|
||||
|
||||
class _RecordSource:
|
||||
"""TaskResolutionSource 生产协议的最小实现(只读、调用方已授权)。"""
|
||||
|
||||
def __init__(self, records):
|
||||
self.records = list(records)
|
||||
|
||||
def get_task(self, task_id, *, actor_identity):
|
||||
return next(
|
||||
(r for r in self.records if r["task_id"] == task_id), None
|
||||
)
|
||||
|
||||
def list_tasks(self, *, actor_identity):
|
||||
return list(self.records)
|
||||
|
||||
|
||||
class _FactsProvider:
|
||||
def __init__(self, value):
|
||||
self.value = value
|
||||
|
||||
def snapshot(self, *, task_id, actor_identity, operation):
|
||||
return self.value
|
||||
|
||||
|
||||
class _RecoveryExecutor:
|
||||
def __init__(self, *, fail_once: bool = False):
|
||||
self.calls: list[dict] = []
|
||||
self.fail_once = fail_once
|
||||
|
||||
def recover_existing_child(self, *, task_id, actor_identity, idempotency_key, facts):
|
||||
self.calls.append({
|
||||
"task_id": task_id,
|
||||
"actor_identity": actor_identity,
|
||||
"idempotency_key": idempotency_key,
|
||||
})
|
||||
if self.fail_once:
|
||||
self.fail_once = False
|
||||
raise ConnectionError("response lost after write")
|
||||
return RecoveryReceipt("receipt-1", task_id, True)
|
||||
|
||||
|
||||
class _Subscriber:
|
||||
def __init__(self):
|
||||
self.handler = None
|
||||
self.closed = False
|
||||
|
||||
def subscribe(self, on_event):
|
||||
self.handler = on_event
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
|
||||
class _TempDirTestCase(unittest.TestCase):
|
||||
"""为需要落盘 sqlite / checkpoint 的用例提供临时目录。"""
|
||||
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="aios-gov-entry-"))
|
||||
|
||||
def tearDown(self):
|
||||
# 控制内核依赖 GC 释放 sqlite 句柄;先回收再删目录,避免 WinError 32。
|
||||
gc.collect()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2:冻结控制内核的生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
class T2RuntimeManifestTests(_TempDirTestCase):
|
||||
def test_t2_freeze_produces_canonical_manifest_and_stale_guard(self):
|
||||
barrier = ConsistencyBarrier(["W1", "W2"])
|
||||
barrier.submit(observation("W1"))
|
||||
barrier.submit(observation("W2"))
|
||||
manifest = barrier.freeze()
|
||||
|
||||
self.assertIsInstance(manifest, RuntimeManifest)
|
||||
self.assertTrue(manifest.manifest_id.startswith("sha256:"))
|
||||
self.assertEqual(manifest.runtime_fingerprint, "runtime-A")
|
||||
self.assertEqual(set(manifest.worker_facts), {"W1", "W2"})
|
||||
self.assertEqual(manifest.evidence_refs, ("evidence:W1", "evidence:W2"))
|
||||
|
||||
store = ManifestStore(self.tmp / "manifest.db")
|
||||
manifest_id = store.freeze(manifest)
|
||||
self.assertEqual(manifest_id, manifest.manifest_id)
|
||||
# 同一 manifest 重复 freeze 必须幂等
|
||||
self.assertEqual(store.freeze(manifest), manifest_id)
|
||||
|
||||
store.assert_current(
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1",
|
||||
)
|
||||
with self.assertRaises(StaleManifest):
|
||||
store.assert_current(
|
||||
manifest_id=manifest_id,
|
||||
runtime_fingerprint="runtime-B",
|
||||
process_start_identity="pid:100:start:1",
|
||||
)
|
||||
|
||||
|
||||
class T2ActionLedgerTests(_TempDirTestCase):
|
||||
def test_t2_idempotent_success_is_reused(self):
|
||||
ledger = ActionLedger(self.tmp / "actions.db")
|
||||
first = ledger.begin(
|
||||
idempotency_key="k1", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.COMMIT, target="branch", precondition_hash="p",
|
||||
)
|
||||
self.assertFalse(first.reused)
|
||||
ledger.mark_succeeded(first.action_id, result={"oid": "abc"})
|
||||
|
||||
second = ledger.begin(
|
||||
idempotency_key="k1", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.COMMIT, target="branch", precondition_hash="p",
|
||||
)
|
||||
self.assertTrue(second.reused)
|
||||
self.assertEqual(second.state, ActionState.SUCCEEDED)
|
||||
self.assertEqual(second.action_id, first.action_id)
|
||||
|
||||
def test_t2_changed_precondition_is_rejected(self):
|
||||
ledger = ActionLedger(self.tmp / "actions.db")
|
||||
ledger.begin(
|
||||
idempotency_key="k2", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.CLAIM, target="task", precondition_hash="p1",
|
||||
)
|
||||
with self.assertRaises(IdempotencyConflict):
|
||||
ledger.begin(
|
||||
idempotency_key="k2", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.CLAIM, target="task", precondition_hash="p2",
|
||||
)
|
||||
|
||||
def test_t2_unknown_effect_forces_reconcile_before_retry(self):
|
||||
ledger = ActionLedger(self.tmp / "actions.db")
|
||||
ticket = ledger.begin(
|
||||
idempotency_key="k3", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.COMMIT, target="branch", precondition_hash="p",
|
||||
)
|
||||
ledger.mark_effect_unknown(ticket.action_id, result={"timeout": True})
|
||||
again = ledger.begin(
|
||||
idempotency_key="k3", manifest_id="m1", phase="S4",
|
||||
kind=ActionKind.COMMIT, target="branch", precondition_hash="p",
|
||||
)
|
||||
self.assertTrue(again.requires_reconcile)
|
||||
# EFFECT_UNKNOWN 时不得另发执行票:复用同一条 action 记录,先对账再决定重试。
|
||||
self.assertTrue(again.reused)
|
||||
self.assertEqual(again.state, ActionState.EFFECT_UNKNOWN)
|
||||
self.assertEqual(again.action_id, ticket.action_id)
|
||||
|
||||
|
||||
class T2DecisionEngineTests(_TempDirTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = AutonomousDecisionEngine()
|
||||
|
||||
def test_t2_mechanism_defect_is_repaired_not_escalated(self):
|
||||
decision = self.engine.decide(DecisionInput(
|
||||
goal_id="G",
|
||||
blocker_codes=("LIVE_BINDING_VIOLATION",),
|
||||
owner_boundary_required=True,
|
||||
))
|
||||
self.assertEqual(decision.classification, BlockerClassification.MECHANISM_DEFECT)
|
||||
self.assertEqual(decision.next_valid_action, NextAction.REPAIR_MECHANISM)
|
||||
self.assertFalse(decision.owner_boundary)
|
||||
self.assertEqual(decision.producer_id, "agent-side-adapter-owner")
|
||||
|
||||
def test_t2_self_actionable_recovers_existing_child(self):
|
||||
decision = self.engine.decide(DecisionInput(
|
||||
goal_id="G", blocker_codes=("EPOCH_EXPIRED",), admission_ready=False,
|
||||
))
|
||||
self.assertEqual(decision.classification, BlockerClassification.SELF_ACTIONABLE)
|
||||
self.assertEqual(decision.next_valid_action, NextAction.RECOVER_EXISTING_CHILD)
|
||||
self.assertFalse(decision.owner_boundary)
|
||||
|
||||
def test_t2_external_dependency_never_escalates_by_elapsed_cycles(self):
|
||||
decision = self.engine.decide(DecisionInput(
|
||||
goal_id="G", blocker_codes=("TASK_NOT_RESOLVED",),
|
||||
admission_ready=False, unchanged_cycles=999,
|
||||
))
|
||||
self.assertEqual(decision.classification, BlockerClassification.EXTERNAL_DEPENDENCY)
|
||||
self.assertEqual(decision.next_valid_action, NextAction.WAIT_DEPENDENCY)
|
||||
self.assertFalse(decision.owner_boundary)
|
||||
|
||||
def test_t2_owner_boundary_is_declared_as_owner_boundary(self):
|
||||
decision = self.engine.decide(DecisionInput(
|
||||
goal_id="G",
|
||||
blocker_codes=("TASK_EXPIRED_OWNER_RECOVERY_REQUIRED",),
|
||||
admission_ready=True,
|
||||
))
|
||||
self.assertEqual(decision.classification, BlockerClassification.OWNER_BOUNDARY)
|
||||
self.assertEqual(decision.next_valid_action, NextAction.OWNER_BOUNDARY)
|
||||
self.assertTrue(decision.owner_boundary)
|
||||
self.assertEqual(decision.producer_id, "agent-task-terminal-recovery")
|
||||
|
||||
|
||||
class T2HypothesisLedgerTests(_TempDirTestCase):
|
||||
def test_t2_probe_requires_two_open_hypotheses(self):
|
||||
ledger = HypothesisLedger(self.tmp / "hypotheses.db")
|
||||
ledger.record(hypothesis_id="h1", claim="transport flake")
|
||||
ledger.record(hypothesis_id="h2", claim="stale manifest")
|
||||
ledger.assert_probe_discriminates(["h1", "h2"])
|
||||
|
||||
ledger.set_status("h2", HypothesisStatus.REJECTED)
|
||||
with self.assertRaises(GovernanceControlError):
|
||||
ledger.assert_probe_discriminates(["h1", "h2"])
|
||||
|
||||
|
||||
class T2MutationGuardTests(_TempDirTestCase):
|
||||
def test_t2_single_writer_and_stale_manifest_guard(self):
|
||||
manifest_store = ManifestStore(self.tmp / "manifest.db")
|
||||
ledger = ActionLedger(self.tmp / "actions.db")
|
||||
guard = MutationGuard(manifest_store, ledger)
|
||||
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(observation("W1"))
|
||||
manifest_id = manifest_store.freeze(barrier.freeze())
|
||||
|
||||
with self.assertRaises(StaleManifest):
|
||||
guard.begin_mutation(
|
||||
manifest_id=manifest_id, runtime_fingerprint="stale-runtime",
|
||||
process_start_identity="pid:100:start:1", idempotency_key="x",
|
||||
phase="S4", kind=ActionKind.GENERIC_MUTATION, target="x",
|
||||
precondition_hash="p",
|
||||
)
|
||||
|
||||
ticket = guard.begin_mutation(
|
||||
manifest_id=manifest_id, runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1", idempotency_key="x1",
|
||||
phase="S4", kind=ActionKind.GENERIC_MUTATION, target="x",
|
||||
precondition_hash="p",
|
||||
)
|
||||
with self.assertRaises(ConcurrentWriterDenied):
|
||||
guard.begin_mutation(
|
||||
manifest_id=manifest_id, runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1", idempotency_key="x2",
|
||||
phase="S4", kind=ActionKind.GENERIC_MUTATION, target="y",
|
||||
precondition_hash="p2",
|
||||
)
|
||||
guard.finish_success(ticket, result={"ok": True})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3:A1/A2/A3 恢复链路的生产入口
|
||||
# ---------------------------------------------------------------------------
|
||||
class T3ResolverTests(_TempDirTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.query = ResolutionQuery(
|
||||
repository="aios/aios", base_oid="base", artifact_sha256="artifact",
|
||||
)
|
||||
|
||||
def test_t3_exact_match_resolves_without_guessing(self):
|
||||
resolver = ExactTaskResolver(_RecordSource([task_record("t1")]))
|
||||
result = resolver.resolve(self.query, actor_identity="caller")
|
||||
|
||||
self.assertIs(ResolutionState.RESOLVED, result.state)
|
||||
self.assertIsNotNone(result.resolved)
|
||||
self.assertEqual(result.resolved.task_id, "t1")
|
||||
self.assertEqual(result.resolved.mission_id, "mission-t1")
|
||||
self.assertIn("artifact_sha256", result.resolved.matched_selectors)
|
||||
self.assertIn("repository", result.resolved.matched_selectors)
|
||||
|
||||
def test_t3_multiple_exact_matches_are_ambiguous(self):
|
||||
resolver = ExactTaskResolver(
|
||||
_RecordSource([task_record("t1"), task_record("t2")])
|
||||
)
|
||||
result = resolver.resolve(self.query, actor_identity="caller")
|
||||
|
||||
self.assertIs(ResolutionState.AMBIGUOUS, result.state)
|
||||
self.assertIsNone(result.resolved)
|
||||
self.assertEqual(result.candidate_task_ids, ("t1", "t2"))
|
||||
|
||||
def test_t3_missing_candidate_is_not_found(self):
|
||||
resolver = ExactTaskResolver(
|
||||
_RecordSource([task_record("t1", artifact_sha256="other")])
|
||||
)
|
||||
result = resolver.resolve(self.query, actor_identity="caller")
|
||||
|
||||
self.assertIs(ResolutionState.NOT_FOUND, result.state)
|
||||
self.assertIsNone(result.resolved)
|
||||
|
||||
def test_t3_weak_query_is_invalid(self):
|
||||
resolver = ExactTaskResolver(_RecordSource([task_record("t1")]))
|
||||
weak = ResolutionQuery(repository="aios/aios", base_oid="base")
|
||||
result = resolver.resolve(weak, actor_identity="caller")
|
||||
|
||||
self.assertIs(ResolutionState.INVALID_QUERY, result.state)
|
||||
self.assertTrue(result.detail)
|
||||
|
||||
|
||||
class T3RecoveryControllerTests(_TempDirTestCase):
|
||||
def _controller(self, *, records, facts, executor, budget=None, store_path=None):
|
||||
return GovernanceRecoveryController(
|
||||
goal_id="goal-1",
|
||||
query=ResolutionQuery(
|
||||
repository="aios/aios", base_oid="base",
|
||||
artifact_sha256="artifact",
|
||||
),
|
||||
actor_identity="caller",
|
||||
resolver=ExactTaskResolver(_RecordSource(records)),
|
||||
facts_provider=_FactsProvider(facts),
|
||||
executor=executor,
|
||||
checkpoint_store=JsonCheckpointStore(
|
||||
store_path or (self.tmp / "checkpoint.json")
|
||||
),
|
||||
retry_budget=budget or RetryBudget(),
|
||||
)
|
||||
|
||||
def _event(self, revision: str) -> GovernanceEvent:
|
||||
return GovernanceEvent(
|
||||
kind="WRITE_PLANE_CHANGED",
|
||||
revision=revision,
|
||||
occurred_at="2026-09-17T00:00:00Z",
|
||||
)
|
||||
|
||||
def test_t3_event_recovers_exact_existing_child_through_governed_path(self):
|
||||
executor = _RecoveryExecutor()
|
||||
checkpoint_path = self.tmp / "checkpoint.json"
|
||||
controller = self._controller(
|
||||
records=[task_record("t1")],
|
||||
facts=ready_facts(),
|
||||
executor=executor,
|
||||
store_path=checkpoint_path,
|
||||
)
|
||||
|
||||
result = controller.on_event(self._event("1"))
|
||||
|
||||
self.assertEqual(result.status, "RECOVERED")
|
||||
self.assertEqual(result.next_legal_action, "ROOT_REVIEW")
|
||||
self.assertEqual(result.task_id, "t1")
|
||||
self.assertEqual(result.receipt_ref, "receipt-1")
|
||||
self.assertIsNone(result.blocker_code)
|
||||
self.assertFalse(result.mechanism_repair_required)
|
||||
|
||||
self.assertEqual(len(executor.calls), 1)
|
||||
self.assertEqual(executor.calls[0]["task_id"], "t1")
|
||||
self.assertEqual(executor.calls[0]["actor_identity"], "caller")
|
||||
self.assertTrue(executor.calls[0]["idempotency_key"])
|
||||
|
||||
# checkpoint 是真实落盘的 observation cache,可被下一次 reconcile 读回
|
||||
self.assertTrue(checkpoint_path.exists())
|
||||
persisted = controller.checkpoints.load()
|
||||
self.assertIsNotNone(persisted)
|
||||
self.assertEqual(persisted.task_id, "t1")
|
||||
self.assertEqual(persisted.next_legal_action, "ROOT_REVIEW")
|
||||
self.assertEqual(persisted.recovery_receipt_ref, "receipt-1")
|
||||
self.assertEqual(
|
||||
persisted.recovery_idempotency_key,
|
||||
executor.calls[0]["idempotency_key"],
|
||||
)
|
||||
|
||||
def test_t3_unresolved_task_never_calls_executor(self):
|
||||
executor = _RecoveryExecutor()
|
||||
controller = self._controller(
|
||||
records=[], facts=ready_facts(), executor=executor,
|
||||
)
|
||||
|
||||
result = controller.on_event(self._event("1"))
|
||||
|
||||
self.assertEqual(result.status, "BLOCKED")
|
||||
self.assertEqual(result.blocker_code, "TASK_NOT_RESOLVED")
|
||||
self.assertEqual(result.next_legal_action, "WAIT_DEPENDENCY")
|
||||
self.assertFalse(result.mechanism_repair_required)
|
||||
self.assertEqual(executor.calls, [])
|
||||
|
||||
def test_t3_unknown_result_keeps_same_idempotency_key_on_next_reconcile(self):
|
||||
executor = _RecoveryExecutor(fail_once=True)
|
||||
controller = self._controller(
|
||||
records=[task_record("t1")], facts=ready_facts(), executor=executor,
|
||||
)
|
||||
|
||||
first = controller.on_event(self._event("1"))
|
||||
self.assertEqual(first.status, "RESULT_UNKNOWN")
|
||||
self.assertEqual(first.blocker_code, "RECOVERY_RESULT_UNKNOWN")
|
||||
key_after_first = controller.checkpoints.load().recovery_idempotency_key
|
||||
self.assertTrue(key_after_first)
|
||||
|
||||
second = controller.on_event(self._event("2"))
|
||||
self.assertEqual(second.status, "RECOVERED")
|
||||
self.assertEqual(len(executor.calls), 2)
|
||||
self.assertEqual(executor.calls[1]["idempotency_key"], key_after_first)
|
||||
|
||||
def test_t3_three_unchanged_blocker_cycles_escalate_to_mechanism_repair(self):
|
||||
executor = _RecoveryExecutor()
|
||||
controller = self._controller(
|
||||
records=[], facts=ready_facts(), executor=executor,
|
||||
budget=RetryBudget(unchanged_threshold=3),
|
||||
)
|
||||
|
||||
first = controller.on_event(self._event("1"))
|
||||
second = controller.on_event(self._event("2"))
|
||||
third = controller.on_event(self._event("3"))
|
||||
|
||||
self.assertEqual(first.next_legal_action, "WAIT_DEPENDENCY")
|
||||
self.assertFalse(first.mechanism_repair_required)
|
||||
self.assertEqual(second.next_legal_action, "WAIT_DEPENDENCY")
|
||||
self.assertFalse(second.mechanism_repair_required)
|
||||
self.assertEqual(third.next_legal_action, "REPAIR_MECHANISM")
|
||||
self.assertTrue(third.mechanism_repair_required)
|
||||
self.assertEqual(executor.calls, [])
|
||||
|
||||
def test_t3_poll_fallback_uses_the_same_reconcile_path(self):
|
||||
executor = _RecoveryExecutor()
|
||||
controller = self._controller(
|
||||
records=[task_record("t1")], facts=ready_facts(), executor=executor,
|
||||
)
|
||||
|
||||
result = controller.poll_once(
|
||||
revision="poll-1", occurred_at="2026-09-17T00:01:00Z",
|
||||
)
|
||||
|
||||
self.assertEqual(result.status, "RECOVERED")
|
||||
self.assertEqual(len(executor.calls), 1)
|
||||
|
||||
def test_t3_live_event_adapter_drives_the_recovery_controller(self):
|
||||
executor = _RecoveryExecutor()
|
||||
controller = self._controller(
|
||||
records=[task_record("t1")], facts=ready_facts(), executor=executor,
|
||||
)
|
||||
subscriber = _Subscriber()
|
||||
adapter = LiveEventSubscriptionAdapter(controller.on_event, subscriber)
|
||||
|
||||
self.assertTrue(adapter.live)
|
||||
adapter.start()
|
||||
self.assertIsNotNone(subscriber.handler)
|
||||
|
||||
result = subscriber.handler(LiveGovernanceEvent(
|
||||
kind="WRITE_PLANE_CHANGED",
|
||||
revision="live-1",
|
||||
occurred_at="2026-09-17T00:02:00Z",
|
||||
))
|
||||
|
||||
self.assertEqual(result.status, "RECOVERED")
|
||||
self.assertEqual(result.task_id, "t1")
|
||||
self.assertEqual(len(executor.calls), 1)
|
||||
|
||||
adapter.close()
|
||||
self.assertTrue(subscriber.closed)
|
||||
|
||||
def test_t3_shadow_recovery_stays_blocked_and_never_writes(self):
|
||||
executor = _RecoveryExecutor()
|
||||
controller = self._controller(
|
||||
records=[task_record("t1")],
|
||||
facts=ready_facts(
|
||||
recovery_execution_enabled=False, recovery_shadow_mode=True,
|
||||
),
|
||||
executor=executor,
|
||||
)
|
||||
|
||||
result = controller.on_event(self._event("1"))
|
||||
|
||||
self.assertEqual(result.status, "BLOCKED")
|
||||
self.assertEqual(result.next_legal_action, "WAIT_DEPENDENCY")
|
||||
self.assertFalse(result.mechanism_repair_required)
|
||||
self.assertEqual(executor.calls, [])
|
||||
|
||||
|
||||
class T3AdmissionPreflightTests(_TempDirTestCase):
|
||||
"""A1 生产准入闸门:普通/全局写状态不构成前置条件,治理路径才是。"""
|
||||
|
||||
def _resolution(self, records):
|
||||
return ExactTaskResolver(_RecordSource(records)).resolve(
|
||||
ResolutionQuery(
|
||||
repository="aios/aios", base_oid="base",
|
||||
artifact_sha256="artifact",
|
||||
),
|
||||
actor_identity="caller",
|
||||
)
|
||||
|
||||
def test_t3_governed_recovery_is_ready_when_all_authority_present(self):
|
||||
preflight = AdmissionPreflight()
|
||||
result = preflight.evaluate(
|
||||
resolution=self._resolution([task_record("t1")]),
|
||||
facts=ready_facts(),
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
self.assertTrue(result.ready)
|
||||
self.assertEqual(result.blockers, ())
|
||||
|
||||
def test_t3_missing_authority_blocks_with_explicit_codes(self):
|
||||
preflight = AdmissionPreflight()
|
||||
result = preflight.evaluate(
|
||||
resolution=self._resolution([]),
|
||||
facts=ready_facts(
|
||||
governed_writes_enabled=False,
|
||||
task_lease_id=None,
|
||||
acceptance_revision=None,
|
||||
),
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD,
|
||||
)
|
||||
self.assertFalse(result.ready)
|
||||
codes = {blocker.code for blocker in result.blockers}
|
||||
self.assertEqual(
|
||||
codes,
|
||||
{
|
||||
"TASK_NOT_RESOLVED",
|
||||
"GOVERNED_WRITES_DISABLED",
|
||||
"TASK_LEASE_MISSING",
|
||||
"ACCEPTANCE_REVISION_MISSING",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,120 @@
|
||||
|
||||
from dataclasses import dataclass
|
||||
from app.task_plane.governance_admission import GovernanceFacts
|
||||
from app.task_plane.governance_checkpoint import RetryBudget
|
||||
from app.task_plane.governance_recovery_controller import (
|
||||
GovernanceEvent, GovernanceRecoveryController, RecoveryReceipt
|
||||
)
|
||||
from app.task_plane.governance_resolver import ExactTaskResolver, ResolutionQuery
|
||||
|
||||
class MemoryCheckpoint:
|
||||
def __init__(self): self.value=None
|
||||
def load(self): return self.value
|
||||
def save(self,value): self.value=value
|
||||
|
||||
class Source:
|
||||
def __init__(self,records): self.records=records
|
||||
def get_task(self,task_id,*,actor_identity):
|
||||
return next((r for r in self.records if r["task_id"]==task_id),None)
|
||||
def list_tasks(self,*,actor_identity): return list(self.records)
|
||||
|
||||
def record():
|
||||
return {
|
||||
"task_id":"t1","mission_id":"m1","root_task_id":"r1",
|
||||
"parent_task_id":"r1","owner_principal_id":"owner",
|
||||
"plan":{"metadata":{
|
||||
"repository":"aios/aios","base_oid":"base",
|
||||
"artifact_sha256":"artifact"
|
||||
}}
|
||||
}
|
||||
|
||||
def goodfacts(**kw):
|
||||
body=dict(
|
||||
governed_writes_enabled=True,task_principal_writes_enabled=True,
|
||||
recovery_execution_enabled=True,recovery_shadow_mode=False,
|
||||
authenticated_principal_id="principal",target_branch="agent/x",
|
||||
branch_protection_known=True,expected_branch_oid="base",generation=2,
|
||||
task_lease_id="tl",task_principal_id="tp",writer_lease_id="wl",
|
||||
accepted_file_allowlist=("a.py",),acceptance_revision="rev1",
|
||||
)
|
||||
body.update(kw); return GovernanceFacts(**body)
|
||||
|
||||
class Facts:
|
||||
def __init__(self,value): self.value=value
|
||||
def snapshot(self,**kwargs): return self.value
|
||||
|
||||
class Executor:
|
||||
def __init__(self,fail_once=False):
|
||||
self.calls=[]; self.fail_once=fail_once
|
||||
def recover_existing_child(self,**kwargs):
|
||||
self.calls.append(kwargs)
|
||||
if self.fail_once:
|
||||
self.fail_once=False
|
||||
raise ConnectionError("response lost")
|
||||
return RecoveryReceipt("receipt-1",kwargs["task_id"],True)
|
||||
|
||||
def controller(records,facts,executor,store=None,budget=RetryBudget()):
|
||||
return GovernanceRecoveryController(
|
||||
goal_id="g",
|
||||
query=ResolutionQuery(
|
||||
repository="aios/aios",base_oid="base",artifact_sha256="artifact"),
|
||||
actor_identity="caller",
|
||||
resolver=ExactTaskResolver(Source(records)),
|
||||
facts_provider=Facts(facts),
|
||||
executor=executor,
|
||||
checkpoint_store=store or MemoryCheckpoint(),
|
||||
retry_budget=budget,
|
||||
)
|
||||
|
||||
def event(rev="1"):
|
||||
return GovernanceEvent("WRITE_PLANE_CHANGED",rev,"2026-09-16T00:00:00Z")
|
||||
|
||||
def test_not_resolved_never_calls_executor():
|
||||
ex=Executor(); c=controller([],goodfacts(),ex)
|
||||
out=c.on_event(event())
|
||||
assert out.next_legal_action=="WAIT_DEPENDENCY"
|
||||
assert out.blocker_code=="TASK_NOT_RESOLVED"
|
||||
assert ex.calls==[]
|
||||
|
||||
def test_event_immediately_recovers_when_all_authority_is_ready():
|
||||
ex=Executor(); store=MemoryCheckpoint()
|
||||
c=controller([record()],goodfacts(),ex,store)
|
||||
out=c.on_event(event())
|
||||
assert out.status=="RECOVERED"
|
||||
assert out.next_legal_action=="ROOT_REVIEW"
|
||||
assert len(ex.calls)==1
|
||||
assert store.value.recovery_receipt_ref=="receipt-1"
|
||||
|
||||
def test_shadow_mode_remains_wait_dependency():
|
||||
ex=Executor()
|
||||
c=controller([record()],goodfacts(recovery_execution_enabled=False,recovery_shadow_mode=True),ex)
|
||||
out=c.on_event(event())
|
||||
assert out.status=="BLOCKED"
|
||||
assert out.next_legal_action=="WAIT_DEPENDENCY"
|
||||
assert ex.calls==[]
|
||||
|
||||
def test_response_loss_reuses_same_idempotency_key_on_next_reconcile():
|
||||
ex=Executor(fail_once=True); store=MemoryCheckpoint()
|
||||
c=controller([record()],goodfacts(),ex,store)
|
||||
first=c.on_event(event("1"))
|
||||
assert first.status=="RESULT_UNKNOWN"
|
||||
key1=store.value.recovery_idempotency_key
|
||||
second=c.on_event(event("2"))
|
||||
assert second.status=="RECOVERED"
|
||||
key2=ex.calls[1]["idempotency_key"]
|
||||
assert key1==key2
|
||||
assert len(ex.calls)==2
|
||||
|
||||
def test_poll_fallback_uses_same_reconcile_path():
|
||||
ex=Executor(); c=controller([record()],goodfacts(),ex)
|
||||
out=c.poll_once(revision="poll-1",occurred_at="2026-09-16T00:01:00Z")
|
||||
assert out.status=="RECOVERED"
|
||||
|
||||
def test_three_unchanged_resolver_failures_switch_to_mechanism_repair():
|
||||
ex=Executor(); store=MemoryCheckpoint()
|
||||
c=controller([],goodfacts(),ex,store,RetryBudget(unchanged_threshold=3))
|
||||
assert c.on_event(event("1")).next_legal_action=="WAIT_DEPENDENCY"
|
||||
assert c.on_event(event("2")).next_legal_action=="WAIT_DEPENDENCY"
|
||||
third=c.on_event(event("3"))
|
||||
assert third.next_legal_action=="REPAIR_MECHANISM"
|
||||
assert third.mechanism_repair_required is True
|
||||
@@ -0,0 +1,60 @@
|
||||
|
||||
from app.task_plane.governance_resolver import (
|
||||
ExactTaskResolver, ResolutionQuery, ResolutionState
|
||||
)
|
||||
|
||||
class Source:
|
||||
def __init__(self, records): self.records=records
|
||||
def get_task(self,task_id,*,actor_identity):
|
||||
return next((r for r in self.records if r["task_id"]==task_id),None)
|
||||
def list_tasks(self,*,actor_identity): return list(self.records)
|
||||
|
||||
def record(task_id, *, candidate="cand-a", mission="m1", root="r1"):
|
||||
return {
|
||||
"task_id":task_id,
|
||||
"mission_id":mission,
|
||||
"root_task_id":root,
|
||||
"parent_task_id":root,
|
||||
"owner_principal_id":"owner",
|
||||
"plan":{"metadata":{
|
||||
"repository":"aios/aios",
|
||||
"base_oid":"base",
|
||||
"candidate_oid":candidate,
|
||||
"artifact_sha256":"artifact",
|
||||
}}
|
||||
}
|
||||
|
||||
def query(**kw):
|
||||
body=dict(repository="aios/aios",base_oid="base",artifact_sha256="artifact")
|
||||
body.update(kw)
|
||||
return ResolutionQuery(**body)
|
||||
|
||||
def test_exact_match_resolves_without_guessing():
|
||||
result=ExactTaskResolver(Source([record("t1")])).resolve(query(),actor_identity="caller")
|
||||
assert result.state is ResolutionState.RESOLVED
|
||||
assert result.resolved.task_id=="t1"
|
||||
assert "artifact_sha256" in result.resolved.matched_selectors
|
||||
|
||||
def test_multiple_exact_matches_are_ambiguous():
|
||||
result=ExactTaskResolver(Source([record("t1"),record("t2")])).resolve(query(),actor_identity="caller")
|
||||
assert result.state is ResolutionState.AMBIGUOUS
|
||||
assert result.candidate_task_ids==("t1","t2")
|
||||
|
||||
def test_missing_selector_is_not_partial_match():
|
||||
r=record("t1")
|
||||
del r["plan"]["metadata"]["artifact_sha256"]
|
||||
result=ExactTaskResolver(Source([r])).resolve(query(),actor_identity="caller")
|
||||
assert result.state is ResolutionState.NOT_FOUND
|
||||
|
||||
def test_exact_task_id_still_revalidates_all_selectors():
|
||||
q=query(task_id="t1")
|
||||
result=ExactTaskResolver(Source([record("t1")])).resolve(q,actor_identity="caller")
|
||||
assert result.state is ResolutionState.RESOLVED
|
||||
bad=query(task_id="t1",candidate_oid="other")
|
||||
result=ExactTaskResolver(Source([record("t1")])).resolve(bad,actor_identity="caller")
|
||||
assert result.state is ResolutionState.NOT_FOUND
|
||||
|
||||
def test_weak_query_rejected():
|
||||
q=ResolutionQuery(repository="aios/aios",base_oid="base")
|
||||
result=ExactTaskResolver(Source([])).resolve(q,actor_identity="caller")
|
||||
assert result.state is ResolutionState.INVALID_QUERY
|
||||
@@ -0,0 +1,166 @@
|
||||
from dataclasses import dataclass
|
||||
import pytest
|
||||
|
||||
from app.task_plane.governance_admission import AdmissionOperation, GovernanceFacts
|
||||
from app.task_plane.governance_checkpoint import JsonCheckpointStore, RetryBudget
|
||||
from app.task_plane.governance_live_driver import GovernanceLiveDriver
|
||||
from app.task_plane.governance_recovery_controller import (
|
||||
GovernanceEvent,
|
||||
GovernanceRecoveryController,
|
||||
RecoveryReceipt,
|
||||
)
|
||||
from app.task_plane.governance_resolver import ResolutionQuery, ResolutionState
|
||||
from app.task_plane.site_adapters import (
|
||||
GovernedAuthoritySnapshot,
|
||||
LiveBindingViolation,
|
||||
LiveEventSubscriptionAdapter,
|
||||
LiveGovernanceEvent,
|
||||
LiveGovernanceFactsAdapter,
|
||||
LiveSurfaceUnavailable,
|
||||
LiveTaskResolverAdapter,
|
||||
)
|
||||
|
||||
REPO="aios/aios"
|
||||
BASE="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3"
|
||||
ART="7bd5e0c057b0ddf98a7dc81b0694599cbeda298179036788dec02a24922f4d74"
|
||||
ACTOR="principal:recovery"
|
||||
QUERY=ResolutionQuery(repository=REPO,base_oid=BASE,artifact_sha256=ART)
|
||||
|
||||
def facts(**kw):
|
||||
body=dict(
|
||||
governed_writes_enabled=True,
|
||||
task_principal_writes_enabled=True,
|
||||
recovery_execution_enabled=True,
|
||||
recovery_shadow_mode=False,
|
||||
authenticated_principal_id=ACTOR,
|
||||
target_branch="agent/v41",
|
||||
branch_protection_known=True,
|
||||
expected_branch_oid=BASE,
|
||||
generation=1,
|
||||
task_lease_id="tl",
|
||||
task_principal_id="tp",
|
||||
writer_lease_id="wl",
|
||||
accepted_file_allowlist=("a.py",),
|
||||
acceptance_revision="rev1",
|
||||
)
|
||||
body.update(kw)
|
||||
return GovernanceFacts(**body)
|
||||
|
||||
class ReadyTasks:
|
||||
def get_task(self,task_id,*,actor_identity):
|
||||
return self.list_tasks(actor_identity=actor_identity)[0] if task_id=="task-v41" else None
|
||||
def list_tasks(self,*,actor_identity):
|
||||
return [{
|
||||
"task_id":"task-v41",
|
||||
"mission_id":"m",
|
||||
"root_task_id":"r",
|
||||
"parent_task_id":"p",
|
||||
"owner_principal_id":"owner",
|
||||
"plan":{"metadata":{
|
||||
"repository":REPO,
|
||||
"base_oid":BASE,
|
||||
"artifact_sha256":ART,
|
||||
}}
|
||||
}]
|
||||
|
||||
class AtomicFacts:
|
||||
def __init__(self, value=None):
|
||||
self.value=value or facts()
|
||||
self.calls=[]
|
||||
def read_snapshot(self,*,task_id,actor_identity,operation):
|
||||
self.calls.append((task_id,actor_identity,operation))
|
||||
return GovernedAuthoritySnapshot(
|
||||
task_id=task_id,
|
||||
actor_identity=actor_identity,
|
||||
operation=operation,
|
||||
snapshot_revision="snap-1",
|
||||
facts=self.value,
|
||||
)
|
||||
|
||||
def test_a1_rejects_controller_actor_mismatch():
|
||||
adapter=LiveTaskResolverAdapter(
|
||||
client=ReadyTasks(),actor_identity=ACTOR,bound_repository=REPO)
|
||||
with pytest.raises(LiveBindingViolation,match="actor_identity"):
|
||||
adapter.resolve(QUERY,actor_identity="principal:other")
|
||||
|
||||
def test_a1_reuses_exact_resolver_and_resolves_only_exact_candidate():
|
||||
adapter=LiveTaskResolverAdapter(
|
||||
client=ReadyTasks(),actor_identity=ACTOR,bound_repository=REPO)
|
||||
out=adapter.resolve(QUERY,actor_identity=ACTOR)
|
||||
assert out.state is ResolutionState.RESOLVED
|
||||
assert out.resolved.task_id=="task-v41"
|
||||
|
||||
def test_a2_reads_one_candidate_specific_revision_consistent_snapshot():
|
||||
client=AtomicFacts()
|
||||
adapter=LiveGovernanceFactsAdapter(client=client,actor_identity=ACTOR)
|
||||
out=adapter.snapshot(
|
||||
task_id="task-v41",actor_identity=ACTOR,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD)
|
||||
assert out.writer_lease_id=="wl"
|
||||
assert client.calls==[
|
||||
("task-v41",ACTOR,AdmissionOperation.RECOVER_EXISTING_CHILD)
|
||||
]
|
||||
|
||||
def test_a2_rejects_snapshot_binding_mismatch():
|
||||
class Bad(AtomicFacts):
|
||||
def read_snapshot(self,**kw):
|
||||
snap=super().read_snapshot(**kw)
|
||||
return GovernedAuthoritySnapshot(
|
||||
task_id="other",
|
||||
actor_identity=snap.actor_identity,
|
||||
operation=snap.operation,
|
||||
snapshot_revision=snap.snapshot_revision,
|
||||
facts=snap.facts,
|
||||
)
|
||||
adapter=LiveGovernanceFactsAdapter(client=Bad(),actor_identity=ACTOR)
|
||||
with pytest.raises(LiveBindingViolation,match="task mismatch"):
|
||||
adapter.snapshot(
|
||||
task_id="task-v41",actor_identity=ACTOR,
|
||||
operation=AdmissionOperation.RECOVER_EXISTING_CHILD)
|
||||
|
||||
def test_a3_translates_live_event_to_core_event():
|
||||
seen=[]
|
||||
class Sub:
|
||||
def subscribe(self,callback):
|
||||
callback(LiveGovernanceEvent(
|
||||
kind="LEASE_CHANGED",revision="42",occurred_at="now"))
|
||||
def close(self): pass
|
||||
adapter=LiveEventSubscriptionAdapter(lambda event: seen.append(event),Sub())
|
||||
adapter.start()
|
||||
assert len(seen)==1
|
||||
assert isinstance(seen[0],GovernanceEvent)
|
||||
assert (seen[0].kind,seen[0].revision)==("LEASE_CHANGED","42")
|
||||
|
||||
def test_live_driver_persists_surface_unavailable_without_escalation(tmp_path):
|
||||
store=JsonCheckpointStore(tmp_path/"ck.json")
|
||||
resolver=LiveTaskResolverAdapter()
|
||||
controller=GovernanceRecoveryController(
|
||||
goal_id="g",query=QUERY,actor_identity=ACTOR,
|
||||
resolver=resolver,
|
||||
facts_provider=LiveGovernanceFactsAdapter(),
|
||||
executor=None,
|
||||
checkpoint_store=store,
|
||||
retry_budget=RetryBudget(unchanged_threshold=3),
|
||||
)
|
||||
driver=GovernanceLiveDriver(controller)
|
||||
out=driver.on_event(GovernanceEvent("CHANGE","1","now"))
|
||||
assert out.blocker_code=="LIVE_SURFACE_UNAVAILABLE"
|
||||
assert out.next_legal_action=="WAIT_DEPENDENCY"
|
||||
assert store.load().mechanism_repair_required is False
|
||||
|
||||
def test_live_driver_binding_violation_is_mechanism_repair(tmp_path):
|
||||
store=JsonCheckpointStore(tmp_path/"ck.json")
|
||||
resolver=LiveTaskResolverAdapter(
|
||||
client=ReadyTasks(),actor_identity=ACTOR,bound_repository=REPO)
|
||||
controller=GovernanceRecoveryController(
|
||||
goal_id="g",query=QUERY,actor_identity="principal:wrong",
|
||||
resolver=resolver,
|
||||
facts_provider=LiveGovernanceFactsAdapter(),
|
||||
executor=None,
|
||||
checkpoint_store=store,
|
||||
)
|
||||
out=GovernanceLiveDriver(controller).on_event(
|
||||
GovernanceEvent("CHANGE","1","now"))
|
||||
assert out.blocker_code=="LIVE_BINDING_VIOLATION"
|
||||
assert out.next_legal_action=="REPAIR_MECHANISM"
|
||||
assert store.load().mechanism_repair_required is True
|
||||
@@ -0,0 +1,77 @@
|
||||
from app.task_plane.governance_admission import GovernanceFacts
|
||||
from app.task_plane.governance_checkpoint import JsonCheckpointStore
|
||||
from app.task_plane.governance_recovery_controller import (
|
||||
GovernanceEvent,GovernanceRecoveryController,RecoveryReceipt
|
||||
)
|
||||
from app.task_plane.governance_resolver import ExactTaskResolver,ResolutionQuery
|
||||
|
||||
REPO="aios/aios"; BASE="base"; ART="artifact"; ACTOR="principal"
|
||||
QUERY=ResolutionQuery(repository=REPO,base_oid=BASE,artifact_sha256=ART)
|
||||
|
||||
class Tasks:
|
||||
def get_task(self,task_id,*,actor_identity): return self.list_tasks(actor_identity=actor_identity)[0]
|
||||
def list_tasks(self,*,actor_identity):
|
||||
return [{"task_id":"t","mission_id":"m","root_task_id":"r","parent_task_id":"p",
|
||||
"plan":{"metadata":{"repository":REPO,"base_oid":BASE,"artifact_sha256":ART}}}]
|
||||
|
||||
def facts(gen=1,writer="wl"):
|
||||
return GovernanceFacts(
|
||||
True,True,True,False,ACTOR,"agent/x",True,BASE,gen,
|
||||
"tl","tp",writer,("a.py",),"rev")
|
||||
|
||||
class MutableFacts:
|
||||
def __init__(self,value): self.value=value
|
||||
def snapshot(self,**kw): return self.value
|
||||
|
||||
class ReconcileExecutor:
|
||||
def __init__(self):
|
||||
self.recover_calls=[]; self.reconcile_calls=[]; self.fail_first=True
|
||||
def recover_existing_child(self,**kw):
|
||||
self.recover_calls.append(kw)
|
||||
if self.fail_first:
|
||||
self.fail_first=False
|
||||
raise ConnectionError("lost")
|
||||
return RecoveryReceipt("unexpected",kw["task_id"],True)
|
||||
def reconcile_recovery_result(self,**kw):
|
||||
self.reconcile_calls.append(kw)
|
||||
return RecoveryReceipt("receipt-from-reconcile",kw["task_id"],True)
|
||||
|
||||
class NoReconcileExecutor:
|
||||
def __init__(self): self.recover_calls=[]
|
||||
def recover_existing_child(self,**kw):
|
||||
self.recover_calls.append(kw)
|
||||
raise ConnectionError("lost")
|
||||
|
||||
def event(rev): return GovernanceEvent("CHANGE",rev,"now")
|
||||
|
||||
def test_unknown_result_reconciles_prior_key_before_reexecution_even_if_facts_drift(tmp_path):
|
||||
fp=MutableFacts(facts())
|
||||
ex=ReconcileExecutor()
|
||||
ctl=GovernanceRecoveryController(
|
||||
goal_id="g",query=QUERY,actor_identity=ACTOR,
|
||||
resolver=ExactTaskResolver(Tasks()),facts_provider=fp,executor=ex,
|
||||
checkpoint_store=JsonCheckpointStore(tmp_path/"ck.json"))
|
||||
first=ctl.on_event(event("1"))
|
||||
assert first.status=="RESULT_UNKNOWN"
|
||||
prior=ctl.checkpoints.load().recovery_idempotency_key
|
||||
fp.value=facts(gen=2,writer="wl2")
|
||||
second=ctl.on_event(event("2"))
|
||||
assert second.status=="RECOVERED"
|
||||
assert len(ex.recover_calls)==1
|
||||
assert ex.reconcile_calls[0]["idempotency_key"]==prior
|
||||
|
||||
def test_unknown_result_with_context_drift_and_no_reconcile_does_not_second_mutate(tmp_path):
|
||||
fp=MutableFacts(facts())
|
||||
ex=NoReconcileExecutor()
|
||||
ctl=GovernanceRecoveryController(
|
||||
goal_id="g",query=QUERY,actor_identity=ACTOR,
|
||||
resolver=ExactTaskResolver(Tasks()),facts_provider=fp,executor=ex,
|
||||
checkpoint_store=JsonCheckpointStore(tmp_path/"ck.json"))
|
||||
first=ctl.on_event(event("1"))
|
||||
assert first.status=="RESULT_UNKNOWN"
|
||||
key=ctl.checkpoints.load().recovery_idempotency_key
|
||||
fp.value=facts(gen=9,writer="new")
|
||||
second=ctl.on_event(event("2"))
|
||||
assert second.status=="RESULT_UNKNOWN"
|
||||
assert len(ex.recover_calls)==1
|
||||
assert ctl.checkpoints.load().recovery_idempotency_key==key
|
||||
@@ -0,0 +1,415 @@
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger, ConsistencyBarrier, DiscoveryObservation, ManifestStore, StaleManifest
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
BranchReadback,
|
||||
ClaimReceipt,
|
||||
EpochStatus,
|
||||
GovernedWriteReadiness,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterUnavailable,
|
||||
JobBindingReceipt,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
WriterLeaseReceipt,
|
||||
VerifiedOwnerAuthorization,
|
||||
)
|
||||
|
||||
|
||||
def obs(worker="W1"):
|
||||
return DiscoveryObservation.build(
|
||||
worker=worker,
|
||||
runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1",
|
||||
activation_digest="activation-A",
|
||||
epoch_id="epoch-old",
|
||||
policy_digest="policy-A",
|
||||
plan_provider_root="/plans/A",
|
||||
task_id="task-A",
|
||||
grant_id="grant-A",
|
||||
facts={},
|
||||
observed_at=100.0,
|
||||
)
|
||||
|
||||
|
||||
def make_manifest_store(td):
|
||||
store=ManifestStore(Path(td)/"manifest.db")
|
||||
b=ConsistencyBarrier(["W1"]); b.submit(obs()); m=b.freeze(); mid=store.freeze(m)
|
||||
return store,m,mid
|
||||
|
||||
|
||||
def owner_policy(now=100.0):
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id="owner-policy-1",
|
||||
subject="agent-1",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/",),
|
||||
allowed_capabilities=frozenset({
|
||||
"code.read","code.write","test.execute",
|
||||
"governed.write.file","governed.write.branch",
|
||||
"task.principal.issue","writer.lease","git.read","git.test",
|
||||
}),
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=0,
|
||||
not_before=0.0,
|
||||
expires_at=None,
|
||||
)
|
||||
|
||||
|
||||
def writer_request(mid):
|
||||
return GovernedWriterRequest(
|
||||
subject="agent-1",
|
||||
task_id="task-A",
|
||||
repository="aios/aios",
|
||||
target_branch="agent/governance-kernel",
|
||||
risk="medium",
|
||||
generation=1,
|
||||
base_oid="base-oid",
|
||||
required_capabilities=frozenset({
|
||||
"code.read","code.write","governed.write.file",
|
||||
"governed.write.branch","task.principal.issue","writer.lease",
|
||||
}),
|
||||
file_allowlist=("active-runtime/app/task_plane/governed_writer_automation.py",),
|
||||
plan_digest="plan-1",
|
||||
manifest_id=mid,
|
||||
runtime_fingerprint="runtime-A",
|
||||
process_start_identity="pid:100:start:1",
|
||||
)
|
||||
|
||||
|
||||
class FakeOwnerAuthority:
|
||||
def __init__(self, policy=None):
|
||||
self.policy=policy or owner_policy()
|
||||
self.calls=0
|
||||
def verify(self, *, authorization_ref, request):
|
||||
self.calls+=1
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=self.policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt="owner-verify-receipt-1",
|
||||
verified_at=100.0,
|
||||
)
|
||||
|
||||
|
||||
class FakeSigner:
|
||||
def __init__(self):
|
||||
self.calls=0
|
||||
def reissue_epoch(self, *, owner_policy, request, prior_epoch, minimum_remaining_ttl, idempotency_key):
|
||||
self.calls+=1
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-{self.calls}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=100.0+minimum_remaining_ttl+3600,
|
||||
policy_digest="policy-A",
|
||||
)
|
||||
|
||||
|
||||
class FakeExecution:
|
||||
def __init__(self, status="QUEUED"):
|
||||
self.task=TaskState("task-A",status,1,None)
|
||||
self.recoveries=0; self.claims=0; self.heartbeats=0; self.leases=0
|
||||
def read_task(self, task_id):
|
||||
return self.task
|
||||
def apply_recovery(self, *, task, authorization, idempotency_key):
|
||||
self.recoveries+=1
|
||||
self.task=TaskState(task.task_id,"QUEUED",task.revision+1,None)
|
||||
return self.task
|
||||
def claim(self, *, task, actor, idempotency_key):
|
||||
if task.status!="QUEUED":
|
||||
raise RuntimeError("not claimable")
|
||||
self.claims+=1
|
||||
self.task=TaskState(task.task_id,"CLAIMED",task.revision+1,actor)
|
||||
return ClaimReceipt("claim-1",task.task_id,actor,7)
|
||||
def heartbeat(self, *, claim):
|
||||
self.heartbeats+=1
|
||||
def issue_task_lease(self, *, claim, idempotency_key):
|
||||
self.leases+=1
|
||||
return TaskLeaseReceipt("tl-1",claim.task_id,"secret-ref-not-token",5000.0,claim.fencing)
|
||||
|
||||
|
||||
class FakePrincipalIssuer:
|
||||
def __init__(self, drop_capability=None):
|
||||
self.calls=0; self.drop=drop_capability
|
||||
def issue(self, *, lease, requested_capabilities, idempotency_key):
|
||||
self.calls+=1
|
||||
caps=set(requested_capabilities)
|
||||
if self.drop:
|
||||
caps.discard(self.drop)
|
||||
return TaskPrincipalReceipt("principal-1",lease.task_id,frozenset(caps),5000.0)
|
||||
|
||||
|
||||
class FakeJobs:
|
||||
def __init__(self):
|
||||
self.calls=0
|
||||
def read_or_reconcile(self, *, task_id, principal, idempotency_key):
|
||||
self.calls+=1
|
||||
return JobBindingReceipt("job-1",task_id,"aios/aios","agent/governance-kernel",1)
|
||||
|
||||
|
||||
class FakeGit:
|
||||
def __init__(self, exists=False, head=None):
|
||||
self.exists=exists; self.head=head; self.calls=0
|
||||
def read_branch(self, *, repository, target_branch):
|
||||
self.calls+=1
|
||||
return BranchReadback(self.exists,self.head)
|
||||
|
||||
|
||||
class FakeWriterLease:
|
||||
def __init__(self):
|
||||
self.calls=0; self.last=None
|
||||
def acquire(self, **kwargs):
|
||||
self.calls+=1; self.last=kwargs
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id="wl-1",
|
||||
task_id=kwargs["task_id"],
|
||||
job_id=kwargs["job"].job_id,
|
||||
repository=kwargs["repository"],
|
||||
target_branch=kwargs["target_branch"],
|
||||
branch_mode=kwargs["branch_mode"],
|
||||
expected_branch_oid=kwargs["expected_branch_oid"],
|
||||
generation=kwargs["generation"],
|
||||
expires_at=5000.0,
|
||||
fencing=9,
|
||||
)
|
||||
|
||||
|
||||
def adapters(execution=None, git=None, principal=None, owner_authority=None):
|
||||
return GovernedWriterAdapters(
|
||||
signer=FakeSigner(),
|
||||
execution=execution or FakeExecution(),
|
||||
principal_issuer=principal or FakePrincipalIssuer(),
|
||||
jobs=FakeJobs(),
|
||||
git=git or FakeGit(),
|
||||
writer_lease=FakeWriterLease(),
|
||||
owner_authority=owner_authority,
|
||||
)
|
||||
|
||||
|
||||
class ReadinessTests(unittest.TestCase):
|
||||
def test_legacy_global_false_is_required_invariant_not_blocker(self):
|
||||
r=GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=False,
|
||||
task_principal_writes_enabled=True,
|
||||
task_principal_verifier_ready=True,
|
||||
)
|
||||
self.assertTrue(r.governed_write_path_open)
|
||||
self.assertEqual(r.blockers,())
|
||||
|
||||
def test_ambiguous_writes_enabled_false_is_ignored(self):
|
||||
r=GovernedWriteReadiness.from_runtime_status({
|
||||
"writes_enabled":False,
|
||||
"AIOS_GITOPS_WRITES_ENABLED":False,
|
||||
"legacy_global_writes_enabled":False,
|
||||
"task_principal_writes_enabled":True,
|
||||
"task_principal_readiness":{"ready":True},
|
||||
"governed_write_blockers":[],
|
||||
})
|
||||
self.assertTrue(r.governed_write_path_open)
|
||||
|
||||
def test_legacy_global_true_blocks_governed_path(self):
|
||||
r=GovernedWriteReadiness(
|
||||
legacy_global_writes_enabled=True,
|
||||
task_principal_writes_enabled=True,
|
||||
task_principal_verifier_ready=True,
|
||||
)
|
||||
self.assertFalse(r.governed_write_path_open)
|
||||
self.assertIn("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED",r.blockers)
|
||||
|
||||
|
||||
class OwnerPolicyTests(unittest.TestCase):
|
||||
def test_owner_policy_rejects_scope_expansion(self):
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
_,_,mid=make_manifest_store(td)
|
||||
req=writer_request(mid)
|
||||
bad=OwnerAuthorizationPolicy(
|
||||
authorization_id="bad",subject="agent-1",repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/",),
|
||||
allowed_capabilities=frozenset({"code.read"}),
|
||||
risk_ceiling="medium",trust_domain="aios.owner",
|
||||
revocation_epoch=0,not_before=0.0
|
||||
)
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
bad.validate(req,now=100.0)
|
||||
|
||||
|
||||
class AutomationTests(unittest.TestCase):
|
||||
def _setup(self,status="QUEUED",git=None,principal=None):
|
||||
td=tempfile.TemporaryDirectory()
|
||||
ms,_,mid=make_manifest_store(td.name)
|
||||
al=ActionLedger(Path(td.name)/"actions.db")
|
||||
ex=FakeExecution(status)
|
||||
ad=adapters(execution=ex,git=git,principal=principal)
|
||||
auto=GovernedWriterAutomation(manifest_store=ms,action_ledger=al)
|
||||
req=writer_request(mid)
|
||||
ready=GovernedWriteReadiness(False,True,True,())
|
||||
return td,ms,al,ex,ad,auto,req,ready
|
||||
|
||||
def test_owner_authorized_agent_acquires_writer_create_new(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
session=auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(session.writer_lease_id,"wl-1")
|
||||
self.assertEqual(session.branch_mode,"CREATE_NEW")
|
||||
self.assertEqual(session.expected_branch_oid,"base-oid")
|
||||
self.assertEqual(ex.claims,1); self.assertEqual(ex.heartbeats,1); self.assertEqual(ex.leases,1)
|
||||
self.assertEqual(ad.signer.calls,1)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_existing_branch_uses_live_head_for_cas(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup(git=FakeGit(True,"live-head"))
|
||||
try:
|
||||
session=auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(session.branch_mode,"CONTINUE_EXISTING")
|
||||
self.assertEqual(session.expected_branch_oid,"live-head")
|
||||
self.assertEqual(ad.writer_lease.last["expected_branch_oid"],"live-head")
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_valid_epoch_is_reused_without_signer_call(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
epoch=EpochStatus("e",req.scope_fingerprint,5000.0,"policy-A")
|
||||
auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=epoch,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ad.signer.calls,0)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_expired_epoch_same_scope_is_auto_reissued(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
epoch=EpochStatus("old",req.scope_fingerprint,101.0,"policy-A")
|
||||
session=auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=epoch,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ad.signer.calls,1)
|
||||
self.assertEqual(session.epoch_id,"epoch-1")
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_changed_epoch_scope_requires_owner(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
epoch=EpochStatus("old","sha256:different",5000.0,"policy-A")
|
||||
with self.assertRaises(OwnerActionRequired):
|
||||
auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=epoch,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_expired_task_without_signed_recovery_requires_owner(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup(status="EXPIRED")
|
||||
try:
|
||||
with self.assertRaises(OwnerActionRequired):
|
||||
auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ex.claims,0)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_expired_task_with_preapproved_recovery_is_automatic(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup(status="EXPIRED")
|
||||
try:
|
||||
auth=RecoveryAuthorization("owner-signed-recovery-1","task-A","TERMINAL_RECOVERY",5000.0)
|
||||
session=auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,recovery_authorization=auth,
|
||||
now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ex.recoveries,1)
|
||||
self.assertEqual(ex.claims,1)
|
||||
self.assertEqual(session.writer_lease_id,"wl-1")
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_stale_manifest_blocks_before_any_side_effect(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
bad=GovernedWriterRequest(**{
|
||||
**req.__dict__,"runtime_fingerprint":"runtime-B"
|
||||
})
|
||||
with self.assertRaises(StaleManifest):
|
||||
auto.acquire(
|
||||
request=bad,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ad.signer.calls,0); self.assertEqual(ex.claims,0)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_principal_capability_shortfall_blocks_writer(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup(
|
||||
principal=FakePrincipalIssuer(drop_capability="writer.lease")
|
||||
)
|
||||
try:
|
||||
with self.assertRaises(GovernedWriterUnavailable):
|
||||
auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ad.writer_lease.calls,0)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
|
||||
def test_production_entry_requires_verified_owner_authorization(self):
|
||||
td,ms,al,ex,ad,auto,req,ready=self._setup()
|
||||
try:
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
auto.acquire_verified(
|
||||
request=req,authorization_ref="owner-ref-1",readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
oa=FakeOwnerAuthority()
|
||||
ad.owner_authority=oa
|
||||
session=auto.acquire_verified(
|
||||
request=req,authorization_ref="owner-ref-1",readiness=ready,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(oa.calls,1)
|
||||
self.assertEqual(session.writer_lease_id,"wl-1")
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
def test_readiness_blocker_stops_before_signer(self):
|
||||
td,ms,al,ex,ad,auto,req,_=self._setup()
|
||||
try:
|
||||
blocked=GovernedWriteReadiness(False,False,True,())
|
||||
with self.assertRaises(GovernedWriterUnavailable):
|
||||
auto.acquire(
|
||||
request=req,owner_policy=owner_policy(),readiness=blocked,
|
||||
epoch=None,adapters=ad,now=100.0,minimum_remaining_ttl=600,
|
||||
)
|
||||
self.assertEqual(ad.signer.calls,0)
|
||||
finally:
|
||||
td.cleanup()
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,624 @@
|
||||
"""生产入口(production entrypoint)集成测试:T2/T3 GovernedWriterAutomation。
|
||||
|
||||
与 V2 单元测试(test_governed_writer_automation.py,15 项)的区别:这里断言的是
|
||||
**真实生产入口** `GovernedWriterAutomation.acquire_verified()` 的端到端行为,符号
|
||||
全部从生产模块直接导入并真实执行——Owner 授权经正式 `OwnerAuthorizationAuthority`
|
||||
验证后,Agent 在 `legacy_global_writes_enabled=false` 前提下自动走完
|
||||
claim → TaskLease → TaskPrincipal → job → live branch CAS → WriterLease。
|
||||
|
||||
覆盖 V2 需求书强调的语义(不得因 `AIOS_GITOPS_WRITES_ENABLED=false` 宣布 writer
|
||||
blocked):
|
||||
- T2 生产入口:`acquire_verified()` 必须经正式 authority 返回 verification receipt;
|
||||
未配置 authority → fail-closed;未经授权本地 policy 不构成 proof。
|
||||
- T3 协议路径:legacy/global writes=false 不阻塞;ambiguous writes_enabled=false
|
||||
被忽略;scope 扩张 fail-closed→Owner;同 scope 过期 epoch 经 signer JIT 重签;
|
||||
QUEUED task 全自动达到 WriterLease;EXPIRED 无预签 recovery→Owner;
|
||||
EXPIRED 有预签 recovery→自动恢复;live branch HEAD 决定 CAS;
|
||||
能力不足阻断 WriterLease;stale manifest 在任何副作用前阻断。
|
||||
|
||||
Windows 说明:本文件不使用 `with tempfile.TemporaryDirectory()`,而是
|
||||
`tempfile.mkdtemp()` + tearDown 中 `gc.collect()` 再 `rmtree(ignore_errors=True)`,
|
||||
规避清理期 WinError 32。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import gc
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.governance_control_kernel import (
|
||||
ActionLedger,
|
||||
ConsistencyBarrier,
|
||||
ManifestStore,
|
||||
DiscoveryObservation,
|
||||
)
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
AuthorizationDenied,
|
||||
EpochStatus,
|
||||
GovernedWriteReadiness,
|
||||
GovernedWriterAdapters,
|
||||
GovernedWriterAutomation,
|
||||
GovernedWriterRequest,
|
||||
GovernedWriterSession,
|
||||
GovernedWriterState,
|
||||
GovernedWriterUnavailable,
|
||||
JobBindingReceipt,
|
||||
OwnerActionRequired,
|
||||
OwnerAuthorizationPolicy,
|
||||
RecoveryAuthorization,
|
||||
TaskLeaseReceipt,
|
||||
TaskPrincipalReceipt,
|
||||
TaskState,
|
||||
VerifiedOwnerAuthorization,
|
||||
WriterLeaseReceipt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 固定身份(与 V2 / S6 关联,仅测试用)
|
||||
# ---------------------------------------------------------------------------
|
||||
SUBJECT = "aios-autonomous-admin"
|
||||
REPO = "aios/aios"
|
||||
BRANCH = "agent/autonomy-v2-governed-writer-verify-20260917"
|
||||
TASK = "task-gw-verify-0001"
|
||||
BASE_OID = "5cf20128d8be2d10aef87b09f8f7a2aecb2000f3"
|
||||
GENERATION = 1
|
||||
RUNTIME = "runtime-gw-verify"
|
||||
PROCESS_START = "pid:gw:start:1"
|
||||
PLAN_DIGEST = "sha256:plan-gw-verify"
|
||||
CAPS = frozenset(
|
||||
{
|
||||
"code.write",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"test.execute",
|
||||
"writer.lease",
|
||||
"task.principal.issue",
|
||||
}
|
||||
)
|
||||
FILES = (
|
||||
"active-runtime/app/task_plane/governance_control_kernel.py",
|
||||
"active-runtime/app/task_plane/governed_writer_automation.py",
|
||||
)
|
||||
|
||||
|
||||
def make_request(**overrides) -> GovernedWriterRequest:
|
||||
body = dict(
|
||||
subject=SUBJECT,
|
||||
task_id=TASK,
|
||||
repository=REPO,
|
||||
target_branch=BRANCH,
|
||||
risk="medium",
|
||||
generation=GENERATION,
|
||||
base_oid=BASE_OID,
|
||||
required_capabilities=CAPS,
|
||||
file_allowlist=FILES,
|
||||
plan_digest=PLAN_DIGEST,
|
||||
manifest_id="m",
|
||||
runtime_fingerprint=RUNTIME,
|
||||
process_start_identity=PROCESS_START,
|
||||
)
|
||||
body.update(overrides)
|
||||
return GovernedWriterRequest(**body)
|
||||
|
||||
|
||||
def make_policy(**overrides) -> OwnerAuthorizationPolicy:
|
||||
body = dict(
|
||||
authorization_id="azn-gw-verify",
|
||||
subject=SUBJECT,
|
||||
repository=REPO,
|
||||
allowed_branch_prefixes=("agent/",),
|
||||
allowed_capabilities=CAPS,
|
||||
risk_ceiling="high",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=None,
|
||||
)
|
||||
body.update(overrides)
|
||||
return OwnerAuthorizationPolicy(**body)
|
||||
|
||||
|
||||
def make_readiness(**overrides) -> GovernedWriteReadiness:
|
||||
body = dict(
|
||||
legacy_global_writes_enabled=False,
|
||||
task_principal_writes_enabled=True,
|
||||
task_principal_verifier_ready=True,
|
||||
governed_write_blockers=(),
|
||||
)
|
||||
body.update(overrides)
|
||||
return GovernedWriteReadiness(**body)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 生产适配器的忠实实现(非 mock 内部,而是模拟真实协议端点的行为)
|
||||
# ---------------------------------------------------------------------------
|
||||
class _VerifyingAuthority:
|
||||
"""OwnerAuthorizationAuthority 生产实现:解析 authorization_ref → 验证 → receipt。"""
|
||||
|
||||
def __init__(self, policy: OwnerAuthorizationPolicy, *, verify: bool = True):
|
||||
self.policy = policy
|
||||
self.verify_ok = verify
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def verify(self, *, authorization_ref: str, request: GovernedWriterRequest):
|
||||
self.calls.append({"ref": authorization_ref, "task": request.task_id})
|
||||
# 授权引用必须是已登记的正式引用;本地 policy 本身不构成 proof。
|
||||
if authorization_ref != self.policy.authorization_id:
|
||||
raise AuthorizationDenied("unknown authorization reference")
|
||||
if not self.verify_ok:
|
||||
raise AuthorizationDenied("authority declined verification")
|
||||
return VerifiedOwnerAuthorization(
|
||||
policy=self.policy,
|
||||
authorization_ref=authorization_ref,
|
||||
verification_receipt="receipt:gwa-verified:" + authorization_ref,
|
||||
verified_at=time.time(),
|
||||
)
|
||||
|
||||
|
||||
class _Signer:
|
||||
"""TrustedSignerAdapter 生产实现:JIT 重签同 scope epoch。"""
|
||||
|
||||
def __init__(self, *, fail: bool = False):
|
||||
self.calls: list[dict] = []
|
||||
self.fail = fail
|
||||
self.counter = 0
|
||||
|
||||
def reissue_epoch(
|
||||
self, *, owner_policy, request, prior_epoch, minimum_remaining_ttl, idempotency_key
|
||||
):
|
||||
self.calls.append(
|
||||
{
|
||||
"scope": request.scope_fingerprint,
|
||||
"prior": None if prior_epoch is None else prior_epoch.epoch_id,
|
||||
"key": idempotency_key,
|
||||
}
|
||||
)
|
||||
if self.fail:
|
||||
raise RuntimeError("signer unavailable")
|
||||
self.counter += 1
|
||||
return EpochStatus(
|
||||
epoch_id=f"epoch-jit-{self.counter}",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() + 7200,
|
||||
policy_digest=owner_policy.policy_fingerprint,
|
||||
)
|
||||
|
||||
|
||||
class _ExecutionPlane:
|
||||
"""ExecutionPlane 生产实现:QUEUED 自动入链,可模拟 EXPIRED + recovery。"""
|
||||
|
||||
def __init__(self, initial: TaskState):
|
||||
self.task = initial
|
||||
self.recovery_applied = False
|
||||
self.claims: list = []
|
||||
self.heartbeats: list = []
|
||||
|
||||
def read_task(self, task_id):
|
||||
return self.task
|
||||
|
||||
def apply_recovery(self, *, task, authorization, idempotency_key):
|
||||
self.recovery_applied = True
|
||||
self.task = TaskState(task_id=task.task_id, status="QUEUED", revision=task.revision + 1)
|
||||
return self.task
|
||||
|
||||
def claim(self, *, task, actor, idempotency_key):
|
||||
self.claims.append({"actor": actor, "key": idempotency_key})
|
||||
# 实现 ClaimReceipt 的 shape,但这里按生产真实契约返回带 fencing 的凭证
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(claim_id="claim-1", task_id=task.task_id, actor=actor, fencing=1)
|
||||
|
||||
def heartbeat(self, *, claim):
|
||||
self.heartbeats.append(claim)
|
||||
|
||||
def issue_task_lease(self, *, claim, idempotency_key):
|
||||
return TaskLeaseReceipt(
|
||||
task_lease_id="tl-1",
|
||||
task_id=claim.task_id,
|
||||
token_ref="token-ref-1",
|
||||
expires_at=time.time() + 3600,
|
||||
fencing=claim.fencing,
|
||||
)
|
||||
|
||||
|
||||
class _PrincipalIssuer:
|
||||
def __init__(self, *, shortfall: bool = False):
|
||||
self.calls: list = []
|
||||
self.shortfall = shortfall
|
||||
|
||||
def issue(self, *, lease, requested_capabilities, idempotency_key):
|
||||
self.calls.append({"lease": lease.task_lease_id, "caps": sorted(requested_capabilities)})
|
||||
granted = requested_capabilities
|
||||
if self.shortfall:
|
||||
granted = frozenset(c for c in requested_capabilities if not c.startswith("governed"))
|
||||
return TaskPrincipalReceipt(
|
||||
principal_id="tp-1",
|
||||
task_id=lease.task_id,
|
||||
capabilities=frozenset(granted),
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
|
||||
|
||||
class _Jobs:
|
||||
def __init__(self, *, mismatch: bool = False):
|
||||
self.calls: list = []
|
||||
self.mismatch = mismatch
|
||||
|
||||
def read_or_reconcile(self, *, task_id, principal, idempotency_key):
|
||||
self.calls.append({"task": task_id, "principal": principal.principal_id})
|
||||
return JobBindingReceipt(
|
||||
job_id="job-1",
|
||||
task_id=task_id,
|
||||
repository=("other/repo" if self.mismatch else REPO),
|
||||
target_branch=(BRANCH if not self.mismatch else "agent/other"),
|
||||
generation=GENERATION,
|
||||
)
|
||||
|
||||
|
||||
class _Git:
|
||||
def __init__(self, *, exists: bool = False, head: str | None = None):
|
||||
self.exists = exists
|
||||
self.head = head
|
||||
|
||||
def read_branch(self, *, repository, target_branch):
|
||||
from types import SimpleNamespace
|
||||
|
||||
return SimpleNamespace(
|
||||
exists=self.exists and self.head is not None,
|
||||
head_oid=self.head,
|
||||
)
|
||||
|
||||
|
||||
class _WriterLease:
|
||||
def __init__(self, *, cas_mismatch: bool = False):
|
||||
self.calls: list = []
|
||||
self.cas_mismatch = cas_mismatch
|
||||
|
||||
def acquire(
|
||||
self, *, task_id, job, principal, repository, target_branch, branch_mode,
|
||||
base_oid, expected_branch_oid, generation, idempotency_key,
|
||||
):
|
||||
self.calls.append(
|
||||
{"branch_mode": branch_mode, "expected": expected_branch_oid, "base": base_oid}
|
||||
)
|
||||
eff = ("other" if self.cas_mismatch else expected_branch_oid)
|
||||
return WriterLeaseReceipt(
|
||||
writer_lease_id="wl-1",
|
||||
task_id=task_id,
|
||||
job_id=job.job_id,
|
||||
repository=repository,
|
||||
target_branch=target_branch,
|
||||
branch_mode=branch_mode,
|
||||
expected_branch_oid=eff,
|
||||
generation=generation,
|
||||
expires_at=time.time() + 3600,
|
||||
fencing=1,
|
||||
)
|
||||
|
||||
|
||||
class _TempDirTestCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(tempfile.mkdtemp(prefix="aios-gov-gw-entry-"))
|
||||
|
||||
def tearDown(self):
|
||||
gc.collect()
|
||||
shutil.rmtree(self.tmp, ignore_errors=True)
|
||||
|
||||
def _automation(self):
|
||||
store = ManifestStore(self.tmp / "manifest.db")
|
||||
ledger = ActionLedger(self.tmp / "actions.db")
|
||||
return GovernedWriterAutomation(
|
||||
manifest_store=store, action_ledger=ledger,
|
||||
)
|
||||
|
||||
def _frozen_manifest_id(self, store: ManifestStore) -> str:
|
||||
barrier = ConsistencyBarrier(["W1"])
|
||||
barrier.submit(_gw_observation())
|
||||
return store.freeze(barrier.freeze())
|
||||
|
||||
|
||||
def _gw_observation(worker: str = "W1", **overrides) -> DiscoveryObservation:
|
||||
data = dict(
|
||||
worker=worker,
|
||||
runtime_fingerprint=RUNTIME,
|
||||
process_start_identity=PROCESS_START,
|
||||
activation_digest="activation-gw",
|
||||
epoch_id="epoch-gw",
|
||||
policy_digest="policy-gw",
|
||||
plan_provider_root="/plans/gw",
|
||||
task_id=TASK,
|
||||
grant_id="grant-gw",
|
||||
facts={},
|
||||
evidence_refs=(),
|
||||
observed_at=100.0,
|
||||
)
|
||||
data.update(overrides)
|
||||
return DiscoveryObservation.build(**data)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T2:生产入口 acquire_verified() —— 必须经正式 Owner 授权 authority 验证
|
||||
# ---------------------------------------------------------------------------
|
||||
class T2OwnerAuthorizationEntryTests(_TempDirTestCase):
|
||||
def test_t2_production_entry_requires_verified_owner_authorization(self):
|
||||
"""未配置 OwnerAuthorizationAuthority → fail-closed,本地 policy 不构成 proof。"""
|
||||
repo = make_policy()
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
|
||||
adapters_no_authority = GovernedWriterAdapters(
|
||||
signer=_Signer(), execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=None,
|
||||
)
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters_no_authority,
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
# 即使本地 policy 有效,也必须经 authority 验证并返回 receipt,否则拒绝
|
||||
adapters_ref_missing = GovernedWriterAdapters(
|
||||
signer=_Signer(), execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=_VerifyingAuthority(repo),
|
||||
)
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="unknown-ref",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters_ref_missing,
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
def test_t2_verified_owner_authorization_drives_full_writer_chain(self):
|
||||
"""Owner 授权一次验证通过后,Agent 自动走完 claim→…→WriterLease。"""
|
||||
repo = make_policy()
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
|
||||
signer = _Signer()
|
||||
authority = _VerifyingAuthority(repo)
|
||||
branch = _Git() # branch 不存在 → CREATE_NEW,expected = base_oid
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=signer, execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=branch,
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
|
||||
session: GovernedWriterSession = automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(), minimum_remaining_ttl=1800.0,
|
||||
)
|
||||
|
||||
self.assertEqual(session.state, GovernedWriterState.WRITER_LEASED)
|
||||
self.assertEqual(session.branch_mode, "CREATE_NEW")
|
||||
self.assertEqual(session.expected_branch_oid, BASE_OID)
|
||||
self.assertEqual(session.authorization_id, "azn-gw-verify")
|
||||
self.assertEqual(session.task_id, TASK)
|
||||
self.assertTrue(session.epoch_id.startswith("epoch-jit-"))
|
||||
self.assertEqual(len(authority.calls), 1)
|
||||
# Epoch 由 trusted signer JIT 重签(scope 未变,Agent 自主)
|
||||
self.assertEqual(len(signer.calls), 1)
|
||||
self.assertEqual(signer.calls[0]["scope"], request.scope_fingerprint)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# T3:协议路径语义 —— legacy/global writes 与 governed writer 解耦
|
||||
# ---------------------------------------------------------------------------
|
||||
class T3GovernedWriteReadinessSemanticsTests(_TempDirTestCase):
|
||||
def test_t3_legacy_global_false_is_invariant_not_blocker(self):
|
||||
"""legacy_global_writes_enabled=false 不阻断 governed writer,反而是不变式。"""
|
||||
readiness = make_readiness()
|
||||
self.assertTrue(readiness.governed_write_path_open)
|
||||
self.assertEqual(readiness.blockers, ())
|
||||
|
||||
def test_t3_ambiguous_writes_enabled_is_ignored(self):
|
||||
"""from_runtime_status 忽略 ambiguous writes_enabled / AIOS_GITOPS_WRITES_ENABLED。"""
|
||||
r = GovernedWriteReadiness.from_runtime_status(
|
||||
{
|
||||
"AIOS_GITOPS_WRITES_ENABLED": False,
|
||||
"writes_enabled": False,
|
||||
"legacy_global_writes_enabled": False,
|
||||
"task_principal_writes_enabled": True,
|
||||
"task_principal_readiness": {"ready": True},
|
||||
"blockers": [],
|
||||
}
|
||||
)
|
||||
self.assertTrue(r.governed_write_path_open)
|
||||
self.assertFalse(r.legacy_global_writes_enabled)
|
||||
self.assertEqual(r.blockers, ())
|
||||
|
||||
def test_t3_legacy_global_true_blocks(self):
|
||||
r = make_readiness(legacy_global_writes_enabled=True)
|
||||
self.assertFalse(r.governed_write_path_open)
|
||||
self.assertIn("LEGACY_GLOBAL_WRITES_MUST_REMAIN_DISABLED", r.blockers)
|
||||
|
||||
|
||||
class T3EpochAndTaskDecisionTests(_TempDirTestCase):
|
||||
def _adapters(self, *, task: TaskState | None = None, with_recovery=None):
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
return authority, GovernedWriterAdapters(
|
||||
signer=_Signer(),
|
||||
execution=_ExecutionPlane(task or TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
|
||||
def test_t3_changed_epoch_scope_requires_owner(self):
|
||||
"""同 scope 过期 epoch 才可 JIT;scope 改变不可静默继承旧授权。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
authority, adapters = self._adapters()
|
||||
|
||||
stale_scope = EpochStatus(
|
||||
epoch_id="epoch-old",
|
||||
scope_fingerprint="sha256:old-different-scope",
|
||||
expires_at=time.time() + 7200,
|
||||
policy_digest="p",
|
||||
)
|
||||
with self.assertRaises(OwnerActionRequired):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=stale_scope, adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
def test_t3_expired_epoch_same_scope_is_auto_reissued(self):
|
||||
"""同 scope epoch 过期 → 经 trusted signer 自动 JIT 重签(非 Owner 边界)。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
signer = _Signer()
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=signer, execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
expired = EpochStatus(
|
||||
epoch_id="epoch-expired",
|
||||
scope_fingerprint=request.scope_fingerprint,
|
||||
expires_at=time.time() - 100,
|
||||
policy_digest="p",
|
||||
)
|
||||
session = automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=expired, adapters=adapters,
|
||||
now=time.time(), minimum_remaining_ttl=1800.0,
|
||||
)
|
||||
self.assertEqual(session.state, GovernedWriterState.WRITER_LEASED)
|
||||
self.assertNotEqual(session.epoch_id, "epoch-expired")
|
||||
self.assertEqual(len(signer.calls), 1)
|
||||
|
||||
def test_t3_expired_task_without_signed_recovery_requires_owner(self):
|
||||
"""EXPIRED terminal task 无预签 recovery → OWNER_BOUNDARY(区别于 epoch)。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
authority, adapters = self._adapters(
|
||||
task=TaskState(TASK, "EXPIRED", 2),
|
||||
)
|
||||
with self.assertRaises(OwnerActionRequired):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(), recovery_authorization=None,
|
||||
)
|
||||
|
||||
def test_t3_expired_task_with_preapproved_recovery_is_automatic(self):
|
||||
"""EXPIRED + 预签 RecoveryAuthorization → 自动恢复为 QUEUED 并继续。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
plane = _ExecutionPlane(TaskState(TASK, "EXPIRED", 2))
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_Signer(), execution=plane,
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
recovery = RecoveryAuthorization(
|
||||
authorization_ref="recovery-azn-1",
|
||||
task_id=TASK,
|
||||
action="TERMINAL_RECOVERY",
|
||||
expires_at=time.time() + 3600,
|
||||
)
|
||||
session = automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(), recovery_authorization=recovery,
|
||||
)
|
||||
self.assertEqual(session.state, GovernedWriterState.WRITER_LEASED)
|
||||
self.assertTrue(plane.recovery_applied)
|
||||
|
||||
|
||||
class T3BranchCasAndGuardTests(_TempDirTestCase):
|
||||
def test_t3_existing_branch_uses_live_head_for_cas(self):
|
||||
"""branch 已存在 → CONTINUE_EXISTING,live HEAD 决定 expected_branch_oid。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
live_head = "abcd1234livehead"
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
writer = _WriterLease()
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_Signer(), execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(),
|
||||
git=_Git(exists=True, head=live_head), writer_lease=writer,
|
||||
owner_authority=authority,
|
||||
)
|
||||
session = automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
self.assertEqual(session.branch_mode, "CONTINUE_EXISTING")
|
||||
self.assertEqual(session.expected_branch_oid, live_head)
|
||||
self.assertEqual(writer.calls[0]["expected"], live_head)
|
||||
|
||||
def test_t3_principal_capability_shortfall_blocks_writer(self):
|
||||
"""TaskPrincipal 能力不足 → 阻断 WriterLease。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
request = make_request(manifest_id=manifest_id)
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=_Signer(), execution=_ExecutionPlane(TaskState(TASK, "QUEUED", 0)),
|
||||
principal_issuer=_PrincipalIssuer(shortfall=True), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
with self.assertRaises(GovernedWriterUnavailable):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
|
||||
def test_t3_stale_manifest_blocks_before_any_side_effect(self):
|
||||
"""stale runtime manifest → 在任何副作用前阻断。"""
|
||||
automation = self._automation()
|
||||
store = automation.manifest_store
|
||||
manifest_id = self._frozen_manifest_id(store)
|
||||
# 请求用错误 runtime_fingerprint → assert_current 抛 StaleManifest
|
||||
request = make_request(manifest_id=manifest_id, runtime_fingerprint="stale-runtime")
|
||||
from app.task_plane.governance_control_kernel import StaleManifest
|
||||
|
||||
authority = _VerifyingAuthority(make_policy())
|
||||
signer = _Signer()
|
||||
plane = _ExecutionPlane(TaskState(TASK, "QUEUED", 0))
|
||||
adapters = GovernedWriterAdapters(
|
||||
signer=signer, execution=plane,
|
||||
principal_issuer=_PrincipalIssuer(), jobs=_Jobs(), git=_Git(),
|
||||
writer_lease=_WriterLease(), owner_authority=authority,
|
||||
)
|
||||
with self.assertRaises(StaleManifest):
|
||||
automation.acquire_verified(
|
||||
request=request, authorization_ref="azn-gw-verify",
|
||||
readiness=make_readiness(), epoch=None, adapters=adapters,
|
||||
now=time.time(),
|
||||
)
|
||||
# 既无 claim、无心跳、无 epoch 副作用
|
||||
self.assertEqual(signer.calls, [])
|
||||
self.assertEqual(plane.claims, [])
|
||||
self.assertEqual(plane.heartbeats, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -13,15 +13,19 @@ import pytest
|
||||
|
||||
# Exercise the overlay exactly as deployment will see it: changed modules from
|
||||
# this artifact plus unchanged dependencies from the checked-out repository.
|
||||
_WORK = Path(__file__).resolve().parents[4]
|
||||
_OVERLAY_APP = Path(__file__).resolve().parents[2] / "source" / "active-runtime" / "app"
|
||||
_REPO_APP = _WORK / "repo" / "active-runtime" / "app"
|
||||
_APP_DIR = Path(__file__).resolve().parents[2] / "active-runtime" / "app"
|
||||
# Production resolves ``app`` via the checkout-root symlink ``app -> active-runtime/app``
|
||||
# (and tests/conftest.py places active-runtime/app on sys.path). Keep the overlay
|
||||
# intent (changed modules + unchanged dependencies from the checked-out repo) by
|
||||
# pointing the ``app``/``app.task_plane`` namespace at the REAL active-runtime/app,
|
||||
# never at non-existent source//repo/ dirs (which poisoned sys.modules and broke
|
||||
# collection of the other app.*-qualified tests in the full suite).
|
||||
_app = types.ModuleType("app")
|
||||
_app.__path__ = [str(_OVERLAY_APP), str(_REPO_APP)]
|
||||
_app.__path__ = [str(_APP_DIR)]
|
||||
_app.__package__ = "app"
|
||||
sys.modules["app"] = _app
|
||||
_task_plane = types.ModuleType("app.task_plane")
|
||||
_task_plane.__path__ = [str(_OVERLAY_APP / "task_plane"), str(_REPO_APP / "task_plane")]
|
||||
_task_plane.__path__ = [str(_APP_DIR / "task_plane")]
|
||||
_task_plane.__package__ = "app.task_plane"
|
||||
sys.modules["app.task_plane"] = _task_plane
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ TASK="legacy-task"
|
||||
|
||||
|
||||
def _install_stubs():
|
||||
app=types.ModuleType("app"); app.__path__=[]; sys.modules["app"]=app
|
||||
app=types.ModuleType("app"); app.__path__=[str(ROOT / "active-runtime" / "app")]; sys.modules["app"]=app
|
||||
identity=types.ModuleType("app.aios_gitops_job_identity")
|
||||
identity.legacy_task_storage_key=lambda value:value
|
||||
identity.task_storage_key=lambda value:value
|
||||
@@ -34,7 +34,7 @@ def _install_stubs():
|
||||
auth.SignerRegistry=type("SignerRegistry",(),{})
|
||||
sys.modules[auth.__name__]=auth
|
||||
|
||||
task_plane=types.ModuleType("app.task_plane"); task_plane.__path__=[]
|
||||
task_plane=types.ModuleType("app.task_plane"); task_plane.__path__=[str(ROOT / "active-runtime" / "app" / "task_plane")]
|
||||
sys.modules["app.task_plane"]=task_plane
|
||||
la=types.ModuleType("app.task_plane.legacy_attachment")
|
||||
la.ATTACH_EVIDENCE_SCHEMA="evidence"
|
||||
@@ -46,14 +46,36 @@ def _install_stubs():
|
||||
sys.modules[la.__name__]=la
|
||||
|
||||
|
||||
# These stubs are only needed at import time of the runtime module. Pytest runs
|
||||
# ``importlib`` mode in a single interpreter, so we must not permanently replace
|
||||
# the real ``app.*`` namespace in ``sys.modules`` -- doing so breaks every later
|
||||
# ``app.*`` import in the same suite (e.g. app.task_plane.schedulability). Snapshot
|
||||
# and restore the affected entries around the module load.
|
||||
_STUB_NAMES = [
|
||||
"app",
|
||||
"app.aios_gitops_job_identity",
|
||||
"app.aios_single_host_authority",
|
||||
"app.task_plane",
|
||||
"app.task_plane.legacy_attachment",
|
||||
]
|
||||
|
||||
|
||||
def _load():
|
||||
_install_stubs()
|
||||
name="app.aios_legacy_attachment_runtime"
|
||||
spec=importlib.util.spec_from_file_location(name,MODULE_PATH)
|
||||
mod=importlib.util.module_from_spec(spec)
|
||||
sys.modules[name]=mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
saved={name: sys.modules.get(name) for name in _STUB_NAMES}
|
||||
try:
|
||||
_install_stubs()
|
||||
name="app.aios_legacy_attachment_runtime"
|
||||
spec=importlib.util.spec_from_file_location(name,MODULE_PATH)
|
||||
mod=importlib.util.module_from_spec(spec)
|
||||
sys.modules[name]=mod
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
finally:
|
||||
for n in _STUB_NAMES:
|
||||
if saved[n] is None:
|
||||
sys.modules.pop(n, None)
|
||||
else:
|
||||
sys.modules[n]=saved[n]
|
||||
|
||||
|
||||
M=_load()
|
||||
|
||||
@@ -0,0 +1,477 @@
|
||||
import hashlib
|
||||
import tempfile
|
||||
import time
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from app.task_plane.governed_writer_automation import (
|
||||
GovernedWriterRequest,
|
||||
OwnerAuthorizationPolicy,
|
||||
)
|
||||
from app.task_plane.live_governed_writer_adapters import (
|
||||
HttpResult,
|
||||
LiveAgentTaskClient,
|
||||
LiveBridgeClient,
|
||||
LiveGovernedWriterExecutor,
|
||||
LiveProtocolConfig,
|
||||
LiveProtocolConflict,
|
||||
LiveProtocolError,
|
||||
LiveTaskPrincipalClient,
|
||||
SecretHandleStore,
|
||||
)
|
||||
|
||||
|
||||
class RecordingTransport:
|
||||
def __init__(self):
|
||||
self.calls=[]
|
||||
self.task_reads=0
|
||||
self.submitted_result=None
|
||||
def request(self,method,url,*,body=None,headers=None,timeout=20.0):
|
||||
self.calls.append({
|
||||
"method":method,"url":url,"body":body or {},
|
||||
"headers":headers or {},"timeout":timeout,
|
||||
})
|
||||
path=url.split("127.0.0.1")[-1]
|
||||
if method=="GET" and path.endswith(":18892/v2/runtime"):
|
||||
return HttpResult(200,{
|
||||
"legacy_global_writes_enabled":False,
|
||||
"task_principal_writes_enabled":True,
|
||||
"task_principal_readiness":{"ready":True},
|
||||
"governed_write_blockers":[],
|
||||
})
|
||||
if method=="POST" and path.endswith(":18940/v1/agents/register"):
|
||||
return HttpResult(201,{"agent_token":"agent-token-secret-12345"})
|
||||
if method=="GET" and ":18940/v1/tasks/" in path:
|
||||
self.task_reads+=1
|
||||
status="QUEUED" if self.task_reads==1 else "RUNNING"
|
||||
return HttpResult(200,{
|
||||
"task_id":"task-live-1",
|
||||
"status":status,
|
||||
"revision":4+self.task_reads,
|
||||
"deadline":time.time()+1200,
|
||||
"lease_expires_at":time.time()+900,
|
||||
})
|
||||
if method=="POST" and path.endswith("/claim"):
|
||||
return HttpResult(200,{
|
||||
"status":"CLAIMED",
|
||||
"claim_id":"claim-live-1",
|
||||
"lease_id":"lease-live-1",
|
||||
"lease_token":"lease-token-secret-12345",
|
||||
"fencing_generation":9,
|
||||
"receipt_revision":7,
|
||||
"lease_expires_at":time.time()+900,
|
||||
})
|
||||
if method=="POST" and path.endswith("/heartbeat"):
|
||||
return HttpResult(200,{"status":"RUNNING"})
|
||||
if method=="POST" and path.endswith("/result"):
|
||||
self.submitted_result=dict((body or {})["result"])
|
||||
return HttpResult(200,{
|
||||
"task_id":"task-live-1", "status":"VERIFYING",
|
||||
"claimed_by":"agent-live", "revision":8,
|
||||
"result":dict(self.submitted_result),
|
||||
})
|
||||
if method=="POST" and path.endswith(":18946/v1/task-principal/assertions"):
|
||||
operation=(body or {}).get("operation")
|
||||
return HttpResult(201,{"assertion":f"assertion-for-{operation}"})
|
||||
if method=="POST" and path.endswith(":18892/v2/writer-lease"):
|
||||
return HttpResult(200,{
|
||||
"schema":"aios.task-writer-lease.v2",
|
||||
"task_id":"task-live-1",
|
||||
"repository":(body or {}).get("repository"),
|
||||
"branch_mode":"CONTINUE_EXISTING",
|
||||
"base_oid":(body or {}).get("base_oid"),
|
||||
"expected_branch_oid":(body or {}).get("expected_branch_oid"),
|
||||
"target_branch":(body or {}).get("target_branch"),
|
||||
"generation":(body or {}).get("generation"),
|
||||
"lease_seconds":(body or {}).get("lease_seconds"),
|
||||
"create_target_branch":False,
|
||||
"worktree_mode":"detached_at_expected_head",
|
||||
"cas_ref_update_required":True,
|
||||
})
|
||||
if method=="POST" and path.endswith(":18892/v2/commit-files"):
|
||||
return HttpResult(201,{
|
||||
"status":"COMMITTED",
|
||||
"task_id":"task-live-1",
|
||||
"commit_oid":"new-head-oid",
|
||||
})
|
||||
raise AssertionError(f"unexpected call: {method} {url} {body}")
|
||||
|
||||
|
||||
class FakeGit:
|
||||
def __init__(self,head=None):
|
||||
self.head=head
|
||||
self.sha={}
|
||||
self.reads=0
|
||||
def branch_head(self,branch):
|
||||
self.reads+=1
|
||||
return self.head
|
||||
def file_sha256(self,oid,path):
|
||||
return self.sha[path]
|
||||
|
||||
|
||||
def policy():
|
||||
return OwnerAuthorizationPolicy(
|
||||
authorization_id="azn-owner-gw-v1",
|
||||
subject="aios-autonomous-admin",
|
||||
repository="aios/aios",
|
||||
allowed_branch_prefixes=("agent/autonomy-",),
|
||||
allowed_capabilities=frozenset({
|
||||
"code.write","governed.write.branch","governed.write.file",
|
||||
"test.execute","writer.lease","task.principal.issue",
|
||||
}),
|
||||
risk_ceiling="medium",
|
||||
trust_domain="aios.owner",
|
||||
revocation_epoch=1,
|
||||
not_before=0.0,
|
||||
expires_at=None,
|
||||
revoked=False,
|
||||
)
|
||||
|
||||
|
||||
def request():
|
||||
return GovernedWriterRequest(
|
||||
subject="aios-autonomous-admin",
|
||||
task_id="task-live-1",
|
||||
repository="aios/aios",
|
||||
target_branch="agent/autonomy-live-protocol",
|
||||
risk="medium",
|
||||
generation=1,
|
||||
base_oid="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
required_capabilities=frozenset({
|
||||
"code.write","governed.write.branch","governed.write.file",
|
||||
"test.execute","writer.lease","task.principal.issue",
|
||||
}),
|
||||
file_allowlist=("active-runtime/app/task_plane/x.py",),
|
||||
plan_digest="sha256:plan-live",
|
||||
manifest_id="sha256:manifest-live",
|
||||
runtime_fingerprint="runtime-live",
|
||||
process_start_identity="pid:live:start:1",
|
||||
)
|
||||
|
||||
|
||||
class LiveAgentTaskContractTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory()
|
||||
self.control=Path(self.tmp.name)/"control.token"
|
||||
self.control.write_text("control-token-secret-12345",encoding="utf-8")
|
||||
self.transport=RecordingTransport()
|
||||
self.secrets=SecretHandleStore()
|
||||
self.cfg=LiveProtocolConfig(control_token_file=str(self.control))
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_claim_and_heartbeat_use_live_contract(self):
|
||||
client=LiveAgentTaskClient(
|
||||
self.cfg,transport=self.transport,secrets_store=self.secrets
|
||||
)
|
||||
task=client.read_task("task-live-1")
|
||||
self.assertEqual(task.status,"QUEUED")
|
||||
agent_id,agent_handle=client.register_agent(
|
||||
task_id="task-live-1",
|
||||
capabilities=["governed.write.branch"],
|
||||
route="test",
|
||||
)
|
||||
claim=client.claim(
|
||||
task_id="task-live-1",agent_token_handle=agent_handle
|
||||
)
|
||||
self.assertEqual(claim.claim_ref,"claim-live-1")
|
||||
self.assertEqual(claim.fencing_generation,9)
|
||||
self.assertNotIn("lease_token", claim.public_receipt)
|
||||
running=client.heartbeat(
|
||||
claim=claim,agent_token_handle=agent_handle,phase="test"
|
||||
)
|
||||
self.assertEqual(running.status,"RUNNING")
|
||||
claim_call=[c for c in self.transport.calls if c["url"].endswith("/claim")][0]
|
||||
self.assertEqual(claim_call["body"],{"lease_seconds":900})
|
||||
self.assertTrue(claim_call["headers"]["Authorization"].startswith("Bearer "))
|
||||
hb=[c for c in self.transport.calls if c["url"].endswith("/heartbeat")][0]
|
||||
self.assertEqual(hb["body"]["lease_token"],"lease-token-secret-12345")
|
||||
|
||||
def test_principal_assertion_is_operation_scoped(self):
|
||||
self.secrets.put("dummy","ignore")
|
||||
handle=self.secrets.put("task-lease","lease-token-secret-12345")
|
||||
principal=LiveTaskPrincipalClient(
|
||||
self.cfg,transport=self.transport,secrets_store=self.secrets
|
||||
)
|
||||
assertion=principal.issue_assertion(
|
||||
task_id="task-live-1",
|
||||
lease_handle=handle,
|
||||
operation="acquireWriterLease",
|
||||
bridge_request={"schema":"aios.task-writer-lease.v2"},
|
||||
request_id="req-1",
|
||||
)
|
||||
self.assertEqual(assertion,"assertion-for-acquireWriterLease")
|
||||
self.assertEqual(principal.public_receipts[0]["assertion_sha256"],
|
||||
hashlib.sha256(assertion.encode()).hexdigest())
|
||||
self.assertNotIn(assertion, str(principal.public_receipts))
|
||||
call=self.transport.calls[-1]
|
||||
self.assertEqual(call["body"]["schema"],"aios.task-principal-issue.v1")
|
||||
self.assertEqual(call["body"]["operation"],"acquireWriterLease")
|
||||
self.assertEqual(
|
||||
call["headers"]["Authorization"],
|
||||
"TaskLease lease-token-secret-12345",
|
||||
)
|
||||
|
||||
|
||||
class LiveBridgeContinuationTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.transport=RecordingTransport()
|
||||
self.secrets=SecretHandleStore()
|
||||
self.cfg=LiveProtocolConfig()
|
||||
self.lease_handle=self.secrets.put(
|
||||
"task-lease","lease-token-secret-12345"
|
||||
)
|
||||
self.principal=LiveTaskPrincipalClient(
|
||||
self.cfg,transport=self.transport,secrets_store=self.secrets
|
||||
)
|
||||
self.bridge=LiveBridgeClient(
|
||||
self.cfg,transport=self.transport,principal_client=self.principal
|
||||
)
|
||||
|
||||
def test_writerlease_and_commit_use_same_continuation_key(self):
|
||||
wl=self.bridge.acquire_writer_lease(
|
||||
task_id="task-live-1",
|
||||
job_id="job-live-1",
|
||||
lease_handle=self.lease_handle,
|
||||
repository="aios/aios",
|
||||
target_branch="agent/autonomy-live-protocol",
|
||||
generation=1,
|
||||
base_oid="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
expected_branch_oid="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
branch_mode="CREATE_NEW",
|
||||
continuation_key="gw-cont-fixed",
|
||||
)
|
||||
from app.task_plane.live_governed_writer_adapters import LiveWriterSession
|
||||
session=LiveWriterSession(
|
||||
authorization_id="azn-owner-gw-v1",
|
||||
task_id="task-live-1",
|
||||
agent_id="agent-live",
|
||||
claim_ref="claim-live-1",
|
||||
job_id="job-live-1",
|
||||
branch_mode="CREATE_NEW",
|
||||
expected_branch_oid=wl.expected_branch_oid,
|
||||
writer_lease_id=wl.writer_lease_id,
|
||||
continuation_key=wl.continuation_key,
|
||||
principal_issue_schema=self.cfg.issue_schema,
|
||||
agent_token_handle=self.secrets.put(
|
||||
"agent-token", "agent-token-secret-12345"
|
||||
),
|
||||
lease_handle=self.lease_handle,
|
||||
)
|
||||
self.bridge.commit_files(
|
||||
session=session,
|
||||
repository="aios/aios",
|
||||
target_branch="agent/autonomy-live-protocol",
|
||||
generation=1,
|
||||
files={"x.py":b"x=1\n"},
|
||||
message="test",
|
||||
)
|
||||
wl_call=[c for c in self.transport.calls if c["url"].endswith("/v2/writer-lease")][0]
|
||||
cm_call=[c for c in self.transport.calls if c["url"].endswith("/v2/commit-files")][0]
|
||||
self.assertEqual(wl_call["body"]["idempotency_key"],"gw-cont-fixed")
|
||||
self.assertEqual(cm_call["body"]["idempotency_key"],"gw-cont-fixed")
|
||||
issue_ops=[
|
||||
c["body"]["operation"] for c in self.transport.calls
|
||||
if c["url"].endswith("/v1/task-principal/assertions")
|
||||
]
|
||||
self.assertEqual(issue_ops,["acquireWriterLease","commitFilesGoverned"])
|
||||
|
||||
def test_binding_projection_is_not_presented_as_durable_writer_lease(self):
|
||||
writer = self.bridge.acquire_writer_lease(
|
||||
task_id="task-live-1",
|
||||
job_id="job-live-1",
|
||||
lease_handle=self.lease_handle,
|
||||
repository="aios/aios",
|
||||
target_branch="agent/autonomy-live-protocol",
|
||||
generation=1,
|
||||
base_oid="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
expected_branch_oid="5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
branch_mode="CREATE_NEW",
|
||||
continuation_key="gw-cont-projection",
|
||||
)
|
||||
self.assertIsNone(writer.writer_lease_id)
|
||||
self.assertIsNone(writer.expires_at)
|
||||
self.assertEqual(writer.branch_mode, "CONTINUE_EXISTING")
|
||||
|
||||
from app.task_plane.live_governed_writer_adapters import LiveWriterSession
|
||||
session = LiveWriterSession(
|
||||
authorization_id="azn-owner-gw-v1",
|
||||
task_id="task-live-1",
|
||||
agent_id="agent-live",
|
||||
claim_ref="claim-live-1",
|
||||
job_id="job-live-1",
|
||||
branch_mode=writer.branch_mode,
|
||||
expected_branch_oid=writer.expected_branch_oid,
|
||||
writer_lease_id=writer.writer_lease_id,
|
||||
continuation_key=writer.continuation_key,
|
||||
principal_issue_schema=self.cfg.issue_schema,
|
||||
agent_token_handle=self.secrets.put("agent-token", "agent-token-secret-12345"),
|
||||
lease_handle=self.lease_handle,
|
||||
writer_lease_binding=writer.raw,
|
||||
)
|
||||
public = session.public_dict()
|
||||
self.assertNotIn("writer_lease_id", public)
|
||||
self.assertEqual(public["writer_lease_binding"]["generation"], 1)
|
||||
|
||||
|
||||
class LiveExecutorTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp=tempfile.TemporaryDirectory()
|
||||
self.control=Path(self.tmp.name)/"control.token"
|
||||
self.control.write_text("control-token-secret-12345",encoding="utf-8")
|
||||
self.transport=RecordingTransport()
|
||||
self.cfg=LiveProtocolConfig(control_token_file=str(self.control))
|
||||
def tearDown(self):
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_full_post_admission_chain_create_new(self):
|
||||
ex=LiveGovernedWriterExecutor(
|
||||
config=self.cfg,
|
||||
owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1",
|
||||
transport=self.transport,
|
||||
)
|
||||
ex.git=FakeGit(head=None)
|
||||
session=ex.acquire(request=request(),job_id="job-live-1")
|
||||
self.assertEqual(session.branch_mode,"CONTINUE_EXISTING")
|
||||
self.assertEqual(
|
||||
session.expected_branch_oid,
|
||||
"5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
)
|
||||
self.assertIsNone(session.writer_lease_id)
|
||||
self.assertNotIn("writer_lease_id", session.public_dict())
|
||||
self.assertEqual(
|
||||
session.public_dict()["writer_lease_binding"]["target_branch"],
|
||||
request().target_branch,
|
||||
)
|
||||
|
||||
def test_scope_enrollment_occurs_after_claim_before_assertion(self):
|
||||
calls = []
|
||||
def enroll(**kwargs):
|
||||
calls.append(kwargs)
|
||||
self.assertTrue(any(c["url"].endswith("/claim") for c in self.transport.calls))
|
||||
self.assertFalse(any(c["url"].endswith("/assertions") for c in self.transport.calls))
|
||||
return {"status":"ENROLLED", "task_id":"task-live-1",
|
||||
"agent_id":"c5-fixed-agent", "job_id":"job-live-1"}
|
||||
ex=LiveGovernedWriterExecutor(config=self.cfg, owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1", transport=self.transport,
|
||||
agent_id="c5-fixed-agent", scope_enrollment=enroll)
|
||||
ex.git=FakeGit()
|
||||
session=ex.acquire(request=request(),job_id="job-live-1")
|
||||
self.assertEqual(session.agent_id,"c5-fixed-agent")
|
||||
self.assertEqual(len(calls),1)
|
||||
|
||||
def test_scope_enrollment_failure_stops_before_assertion(self):
|
||||
ex=LiveGovernedWriterExecutor(config=self.cfg, owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1", transport=self.transport,
|
||||
agent_id="c5-fixed-agent", scope_enrollment=lambda **_: {"status":"DENIED"})
|
||||
ex.git=FakeGit()
|
||||
with self.assertRaises(LiveProtocolConflict):
|
||||
ex.acquire(request=request(),job_id="job-live-1")
|
||||
self.assertFalse(any(c["url"].endswith("/assertions") for c in self.transport.calls))
|
||||
|
||||
def test_engineering_risk_remains_above_medium_ceiling(self):
|
||||
from dataclasses import replace
|
||||
from app.task_plane.governed_writer_automation import AuthorizationDenied
|
||||
engineering = replace(request(), risk="engineering")
|
||||
with self.assertRaises(AuthorizationDenied):
|
||||
policy().validate(engineering)
|
||||
replace(policy(), risk_ceiling="engineering").validate(engineering)
|
||||
|
||||
def test_nonqueued_task_fails_before_agent_registration(self):
|
||||
class ExpiredTransport(RecordingTransport):
|
||||
def request(self,method,url,*,body=None,headers=None,timeout=20.0):
|
||||
if method=="GET" and ":18940/v1/tasks/" in url:
|
||||
self.calls.append({"method":method,"url":url,"body":body or {},"headers":headers or {}})
|
||||
return HttpResult(200,{
|
||||
"task_id":"task-live-1","status":"EXPIRED",
|
||||
"revision":2,"deadline":time.time()-10,
|
||||
})
|
||||
return super().request(method,url,body=body,headers=headers,timeout=timeout)
|
||||
tr=ExpiredTransport()
|
||||
ex=LiveGovernedWriterExecutor(
|
||||
config=self.cfg,owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1",transport=tr,
|
||||
)
|
||||
ex.git=FakeGit()
|
||||
with self.assertRaises(LiveProtocolConflict):
|
||||
ex.acquire(request=request(),job_id="job-live-1")
|
||||
self.assertFalse(any(c["url"].endswith("/v1/agents/register") for c in tr.calls))
|
||||
|
||||
def test_commit_byte_gate_and_readback(self):
|
||||
ex=LiveGovernedWriterExecutor(
|
||||
config=self.cfg,owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1",transport=self.transport,
|
||||
)
|
||||
fg=FakeGit(head=None)
|
||||
ex.git=fg
|
||||
session=ex.acquire(request=request(),job_id="job-live-1")
|
||||
data=b"x=1\n"
|
||||
sha=hashlib.sha256(data).hexdigest()
|
||||
fg.head="0123456789abcdef0123456789abcdef01234567"
|
||||
fg.sha={"active-runtime/app/task_plane/x.py":sha}
|
||||
result=ex.commit_and_readback(
|
||||
session=session,
|
||||
request=request(),
|
||||
files={"active-runtime/app/task_plane/x.py":data},
|
||||
expected_sha256={"active-runtime/app/task_plane/x.py":sha},
|
||||
message="test commit",
|
||||
)
|
||||
self.assertEqual(
|
||||
result["branch_head"],
|
||||
"0123456789abcdef0123456789abcdef01234567",
|
||||
)
|
||||
|
||||
def test_commit_byte_gate_rejects_mismatch_before_bridge(self):
|
||||
ex=LiveGovernedWriterExecutor(
|
||||
config=self.cfg,owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1",transport=self.transport,
|
||||
)
|
||||
ex.git=FakeGit(head=None)
|
||||
session=ex.acquire(request=request(),job_id="job-live-1")
|
||||
count_before=len(self.transport.calls)
|
||||
with self.assertRaises(LiveProtocolConflict):
|
||||
ex.commit_and_readback(
|
||||
session=session,
|
||||
request=request(),
|
||||
files={"active-runtime/app/task_plane/x.py":b"bad"},
|
||||
expected_sha256={
|
||||
"active-runtime/app/task_plane/x.py":"0"*64
|
||||
},
|
||||
message="test",
|
||||
)
|
||||
commit_calls=[
|
||||
c for c in self.transport.calls[count_before:]
|
||||
if c["url"].endswith("/v2/commit-files")
|
||||
]
|
||||
self.assertEqual(commit_calls,[])
|
||||
|
||||
def test_result_uses_same_session_without_reregister(self):
|
||||
ex=LiveGovernedWriterExecutor(
|
||||
config=self.cfg,owner_policy=policy(),
|
||||
authorization_ref="azn-owner-gw-v1",transport=self.transport,
|
||||
agent_id="agent-live",
|
||||
)
|
||||
ex.git=FakeGit(head=None)
|
||||
session=ex.acquire(request=request(),job_id="job-live-1")
|
||||
result_value={"schema":"aios.governed-source-publication-result.v1",
|
||||
"task_id":"task-live-1", "published_source_oid":"a"*40}
|
||||
submission=ex.submit_task_result(
|
||||
session=session,result=result_value,
|
||||
idempotency_key="governed-source-result-"+"b"*64,
|
||||
)
|
||||
self.assertEqual(submission["status"],"VERIFYING")
|
||||
result_call=[c for c in self.transport.calls if c["url"].endswith("/result")][0]
|
||||
self.assertEqual(result_call["body"]["lease_token"],"lease-token-secret-12345")
|
||||
self.assertEqual(
|
||||
result_call["headers"]["Authorization"],
|
||||
"Bearer agent-token-secret-12345",
|
||||
)
|
||||
self.assertEqual(
|
||||
len([c for c in self.transport.calls if c["url"].endswith("/agents/register")]),
|
||||
1,
|
||||
)
|
||||
|
||||
|
||||
if __name__=="__main__":
|
||||
unittest.main()
|
||||
@@ -192,6 +192,8 @@ def _service(
|
||||
store,
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
evidence_attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "test-report:root-action",
|
||||
identity_capabilities={
|
||||
OWNER: {"task.creator", "task.read", "task.delegate"},
|
||||
WORKER: {"task.claim", "worker.self_register"},
|
||||
@@ -296,7 +298,7 @@ def _finish(service: TaskPlaneService, task_id: str):
|
||||
{
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER,
|
||||
"evidence": [{"kind": "test-report", "status": "passed"}],
|
||||
"evidence": [{"kind": "test-report", "status": "passed", "attestation_ref": "test-report:root-action"}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -105,6 +105,8 @@ def _service(db_path: Path, evaluator=_complete_when_proven) -> TaskPlaneService
|
||||
TaskPlaneStore(str(db_path)),
|
||||
policy=PolicyEngine(
|
||||
execution_enabled=True,
|
||||
evidence_attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "test-report:lineage",
|
||||
identity_capabilities=grants,
|
||||
),
|
||||
lineage_policy=_lineage_policy(evaluator),
|
||||
@@ -181,7 +183,7 @@ def _finish_task(service: TaskPlaneService, task_id: str) -> dict:
|
||||
"claim_id": claim["claim_id"],
|
||||
"actor_identity": WORKER_IDENTITY,
|
||||
"outputs": [{"kind": "summary", "status": "passed"}],
|
||||
"evidence": [{"kind": "test-report", "status": "passed"}],
|
||||
"evidence": [{"kind": "test-report", "status": "passed", "attestation_ref": "test-report:lineage"}],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ def _fixture(tmp_path):
|
||||
policy = PolicyEngine(
|
||||
identity_verifier=IdentityVerifier({OWNER, OTHER, WORKER}),
|
||||
execution_enabled=True,
|
||||
evidence_attestation_verifier=lambda envelope: envelope.attestation_ref
|
||||
== "bridge-receipt:test",
|
||||
identity_capabilities={
|
||||
OWNER: {"task.creator", "task.read", "task.delegate"},
|
||||
OTHER: {"task.creator", "task.read", "task.delegate"},
|
||||
@@ -147,7 +149,7 @@ def _fixture(tmp_path):
|
||||
"goal_slug": "terminal-proof-test",
|
||||
"original_acceptance": {
|
||||
"schema": "acceptance.v1",
|
||||
"conditions": [{"key": "C01"}, {"key": "C06"}],
|
||||
"conditions": [{"key": "C01", "purpose": "governed-candidate-readback", "required_evidence": ["acceptance-proof"]}, {"key": "C06", "purpose": "governed-candidate-readback", "required_evidence": ["acceptance-proof"]}],
|
||||
},
|
||||
},
|
||||
"mission-test",
|
||||
|
||||
@@ -0,0 +1,573 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
APP = ROOT / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
import c5_governed_principal_scope as scope # noqa: E402
|
||||
|
||||
|
||||
NOW = 2_000_000_000
|
||||
OWNER_KEY = "SHA256:" + "A" * 43
|
||||
TRUST = {
|
||||
"schema": "aios.task-principal-assertion.v1",
|
||||
"issuer_id": "issuer",
|
||||
"key_id": "key",
|
||||
"public_key": "public",
|
||||
"purpose": "TASK_PRINCIPAL",
|
||||
"bridge_id": "bridge",
|
||||
"scope_manifest_sha256": "f" * 64,
|
||||
}
|
||||
|
||||
|
||||
class StubSignatureVerifier:
|
||||
key_id = OWNER_KEY
|
||||
identity = "aios-task-grant-signer"
|
||||
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def verify(self, payload, signature):
|
||||
self.calls += 1
|
||||
return signature == b"valid-signature"
|
||||
|
||||
|
||||
class StubAdapter:
|
||||
def resolve_row(self, *, task, execution, trust, now):
|
||||
if task["status"] not in {"CLAIMED", "RUNNING"}:
|
||||
raise RuntimeError("TASK_NOT_CLAIMED")
|
||||
return {
|
||||
"scope_id": "source-scope",
|
||||
"task_id": scope.TASK_ID,
|
||||
"external_job_id": execution["external_job_id"],
|
||||
"repository": scope.REPOSITORY,
|
||||
"target_branch": scope.TARGET_BRANCH,
|
||||
"generation": scope.GENERATION,
|
||||
"execution_digest": scope.EXECUTION_DIGEST,
|
||||
"capabilities_json": scope.canonical_json(list(scope.SOURCE_CAPABILITIES)).decode(),
|
||||
"audiences_json": scope.canonical_json([
|
||||
"aios.gitops.bridge.v2/bridge/acquireWriterLease",
|
||||
"aios.gitops.bridge.v2/bridge/commitFilesGoverned",
|
||||
]).decode(),
|
||||
"trust_manifest_sha256": scope.sha256_json(dict(trust)),
|
||||
"grant_id": "grant-c5",
|
||||
"grant_expires_at": NOW + 3000,
|
||||
}
|
||||
|
||||
|
||||
def _canonical_payload(value):
|
||||
result = dict(value)
|
||||
result["canonical_payload_digest"] = scope.sha256_json(result)
|
||||
return result
|
||||
|
||||
|
||||
def _write(path: Path, value: bytes):
|
||||
path.write_bytes(value)
|
||||
if os.name == "posix":
|
||||
path.chmod(0o600)
|
||||
|
||||
|
||||
def _owner_material(tmp_path: Path, signatures: StubSignatureVerifier):
|
||||
policy = _canonical_payload({
|
||||
"schema": scope.POLICY_SCHEMA,
|
||||
"policy_id": "C5_SOURCE_PUBLICATION_ONLY",
|
||||
"owner_identity": "aios-task-grant-signer",
|
||||
"repository": scope.REPOSITORY,
|
||||
"trust_domain": "aios.owner",
|
||||
"policy_digest": "1" * 64,
|
||||
"authority_base_oid": "2" * 40,
|
||||
"source_oid": "3" * 40,
|
||||
"source_tree_oid": "4" * 40,
|
||||
"target_agent": "agent/codex-c5-release",
|
||||
"target_branch": scope.TARGET_BRANCH,
|
||||
"generation": scope.GENERATION,
|
||||
"execution_digest": scope.EXECUTION_DIGEST,
|
||||
"exact_files": ["active-runtime/app/example.py"],
|
||||
"capabilities": list(scope.SOURCE_CAPABILITIES),
|
||||
"risk": "engineering",
|
||||
"production_impact": "none",
|
||||
"renewal": "none",
|
||||
"nonce": "nonce-c5",
|
||||
"issued_at": NOW - 10,
|
||||
"not_before": NOW - 10,
|
||||
"expires_at": NOW + 1000,
|
||||
"status": "ACTIVE",
|
||||
"signature_namespace": scope.OWNER_NAMESPACE,
|
||||
"signature_key_id": OWNER_KEY,
|
||||
})
|
||||
epoch = _canonical_payload({
|
||||
"epoch_id": "epoch-c5",
|
||||
"owner_identity": "aios-task-grant-signer",
|
||||
"trust_domain": "aios.owner",
|
||||
"repository": scope.REPOSITORY,
|
||||
"allowed_branch_prefixes": [scope.TARGET_BRANCH],
|
||||
"allowed_capabilities": list(scope.SOURCE_CAPABILITIES),
|
||||
"allowed_file_prefixes": ["active-runtime/app/"],
|
||||
"risk_ceiling": "engineering",
|
||||
"issued_at": NOW - 10,
|
||||
"not_before": NOW - 10,
|
||||
"expires_at": NOW + 1000,
|
||||
"revocation_epoch": 0,
|
||||
"policy_digest": "1" * 64,
|
||||
"schema": scope.EPOCH_SCHEMA,
|
||||
"business_goal": "publish C5 source",
|
||||
"production_scope": "none",
|
||||
"destructive_policy": "forbidden",
|
||||
"signature_namespace": scope.OWNER_NAMESPACE,
|
||||
"signature_key_id": OWNER_KEY,
|
||||
"status": "ACTIVE",
|
||||
})
|
||||
epoch_revocations = {
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": [],
|
||||
}
|
||||
policy_revocations = {
|
||||
"schema": "aios.source-publication-revocations.v1",
|
||||
"revoked_nonces": [],
|
||||
}
|
||||
epoch_root = tmp_path / "epoch"
|
||||
epoch_root.mkdir()
|
||||
paths = {
|
||||
"policy": tmp_path / "policy.json",
|
||||
"policy_revocations": tmp_path / "policy-revocations.json",
|
||||
"epoch_root": epoch_root,
|
||||
"epoch_revocations": tmp_path / "epoch-revocations.json",
|
||||
}
|
||||
_write(paths["policy"], scope.canonical_json({
|
||||
"payload": policy, "signature": "valid-signature",
|
||||
"key_id": OWNER_KEY, "namespace": scope.OWNER_NAMESPACE,
|
||||
}))
|
||||
_write(
|
||||
epoch_root / "owner-delegation-epoch.canonical.json",
|
||||
scope.canonical_json(epoch),
|
||||
)
|
||||
_write(epoch_root / "owner-delegation-epoch.sig", b"valid-signature")
|
||||
_write(epoch_root / "owner-delegation-epoch.key-id", (OWNER_KEY + "\n").encode())
|
||||
_write(paths["epoch_revocations"], scope.canonical_json(epoch_revocations))
|
||||
_write(paths["policy_revocations"], scope.canonical_json(policy_revocations))
|
||||
verifier = scope.FreshOwnerMaterialVerifier(
|
||||
signature_verifier=signatures,
|
||||
policy_artifact=paths["policy"],
|
||||
policy_revocation_file=paths["policy_revocations"],
|
||||
epoch_root=paths["epoch_root"],
|
||||
epoch_revocation_file=paths["epoch_revocations"],
|
||||
)
|
||||
return verifier, paths
|
||||
|
||||
|
||||
def _service(tmp_path: Path):
|
||||
signatures = StubSignatureVerifier()
|
||||
owner, paths = _owner_material(tmp_path, signatures)
|
||||
store = scope.AppendOnlyScopeEnrollmentStore(tmp_path / "enrollments.sqlite3")
|
||||
store.initialize()
|
||||
service = scope.C5ScopeEnrollmentService(
|
||||
deriver=scope.GovernedScopeDeriver(StubAdapter()),
|
||||
owner=owner,
|
||||
store=store,
|
||||
enrollment_signature_verifier=signatures,
|
||||
)
|
||||
return service, signatures, paths, store
|
||||
|
||||
|
||||
def _task(claimed_by="agent/codex-c5-release"):
|
||||
return {"task_id": scope.TASK_ID, "status": "CLAIMED", "claimed_by": claimed_by}
|
||||
|
||||
|
||||
def _execution():
|
||||
return {
|
||||
"external_job_id": "job-c5-real",
|
||||
"repository": scope.REPOSITORY,
|
||||
"target_branch": scope.TARGET_BRANCH,
|
||||
"generation": scope.GENERATION,
|
||||
"execution_digest": scope.EXECUTION_DIGEST,
|
||||
}
|
||||
|
||||
|
||||
def test_claim_then_signed_enrollment_then_runtime_resolution(tmp_path):
|
||||
service, signatures, _paths, store = _service(tmp_path)
|
||||
contract = service.prepare(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
assert contract["claimed_by"] == "agent/codex-c5-release"
|
||||
assert contract["capability_derivation"] == {
|
||||
"governed.write.files": "governed.write.file",
|
||||
"writer.lease": "governed.write.branch",
|
||||
}
|
||||
assert contract["audiences"] == [
|
||||
"aios.gitops.bridge.v2/bridge/acquireWriterLease",
|
||||
"aios.gitops.bridge.v2/bridge/commitFilesGoverned",
|
||||
]
|
||||
assert service.enroll(
|
||||
contract=contract, signature=b"valid-signature", task=_task(),
|
||||
execution=_execution(), trust=TRUST, now=NOW,
|
||||
) is False
|
||||
assert service.enroll(
|
||||
contract=contract, signature=b"valid-signature", task=_task(),
|
||||
execution=_execution(), trust=TRUST, now=NOW,
|
||||
) is True
|
||||
resolved = service.resolve(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW,
|
||||
)
|
||||
assert json.loads(resolved["capabilities_json"]) == list(scope.PRINCIPAL_CAPABILITIES)
|
||||
assert resolved["scope_id"] == contract["scope_id"]
|
||||
assert signatures.calls >= 9 # policy + epoch + contract are rechecked per use
|
||||
assert store.get(scope.TASK_ID)[0] == contract
|
||||
|
||||
|
||||
def test_enrollment_requires_claimed_agent_bound_by_owner_policy(tmp_path):
|
||||
service, _signatures, _paths, _store = _service(tmp_path)
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_TASK_CLAIM_IDENTITY_MISMATCH"):
|
||||
service.prepare(
|
||||
task=_task("governed-writer-random"), execution=_execution(),
|
||||
trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
|
||||
|
||||
def test_execution_job_alias_is_canonical_and_conflicts_are_denied(tmp_path):
|
||||
service, _signatures, _paths, _store = _service(tmp_path)
|
||||
aliased = dict(_execution())
|
||||
aliased["job_id"] = aliased.pop("external_job_id")
|
||||
contract = service.prepare(
|
||||
task=_task(), execution=aliased, trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
assert contract["external_job_id"] == "job-c5-real"
|
||||
conflicted = dict(aliased, external_job_id="job-other")
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_EXECUTION_JOB_ID_INVALID"):
|
||||
service.prepare(
|
||||
task=_task(), execution=conflicted, trust=TRUST,
|
||||
now=NOW, ttl_seconds=600,
|
||||
)
|
||||
|
||||
|
||||
def test_revocation_is_rechecked_after_append(tmp_path):
|
||||
service, _signatures, paths, _store = _service(tmp_path)
|
||||
contract = service.prepare(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
service.enroll(
|
||||
contract=contract, signature=b"valid-signature", task=_task(),
|
||||
execution=_execution(), trust=TRUST, now=NOW,
|
||||
)
|
||||
revoked = {
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": ["epoch-c5"],
|
||||
}
|
||||
_write(paths["epoch_revocations"], scope.canonical_json(revoked))
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_OWNER_EPOCH_REVOKED"):
|
||||
service.resolve(task=_task(), execution=_execution(), trust=TRUST, now=NOW)
|
||||
|
||||
|
||||
def test_trust_or_execution_drift_cannot_reuse_enrollment(tmp_path):
|
||||
service, _signatures, _paths, _store = _service(tmp_path)
|
||||
contract = service.prepare(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
service.enroll(
|
||||
contract=contract, signature=b"valid-signature", task=_task(),
|
||||
execution=_execution(), trust=TRUST, now=NOW,
|
||||
)
|
||||
drifted = dict(TRUST, key_id="other")
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_ENROLLMENT_SCOPE_INVALID"):
|
||||
service.resolve(task=_task(), execution=_execution(), trust=drifted, now=NOW)
|
||||
bad_execution = dict(_execution(), external_job_id="job-other")
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_ENROLLMENT_BINDING_MISMATCH"):
|
||||
service.resolve(task=_task(), execution=bad_execution, trust=TRUST, now=NOW)
|
||||
|
||||
|
||||
def test_append_only_store_rejects_update_and_conflict(tmp_path):
|
||||
service, _signatures, _paths, store = _service(tmp_path)
|
||||
contract = service.prepare(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW, ttl_seconds=600,
|
||||
)
|
||||
service.enroll(
|
||||
contract=contract, signature=b"valid-signature", task=_task(),
|
||||
execution=_execution(), trust=TRUST, now=NOW,
|
||||
)
|
||||
with sqlite3.connect(store.path) as con:
|
||||
with pytest.raises(sqlite3.IntegrityError, match="C5_PRINCIPAL_SCOPE_APPEND_ONLY"):
|
||||
con.execute(
|
||||
"UPDATE c5_principal_scope_enrollments SET scope_id='other' WHERE task_id=?",
|
||||
(scope.TASK_ID,),
|
||||
)
|
||||
changed = dict(contract, external_job_id="job-other")
|
||||
changed_without_digest = dict(changed)
|
||||
changed_without_digest.pop("canonical_payload_digest")
|
||||
changed["canonical_payload_digest"] = scope.sha256_json(changed_without_digest)
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_ENROLLMENT_CONFLICT"):
|
||||
store.append(changed, b"valid-signature", NOW)
|
||||
|
||||
|
||||
def test_not_yet_enrolled_does_not_break_base_readiness(monkeypatch):
|
||||
class Base:
|
||||
trust = TRUST
|
||||
|
||||
def readiness(self):
|
||||
return {"ready": True, "schema_ready": True}
|
||||
|
||||
def activation_scope_task_ids(self):
|
||||
return ("task-c4",)
|
||||
|
||||
class Runtime:
|
||||
def readiness(self, trust, now):
|
||||
return {"ready": False, "c5_scope_enrolled": False, "error": "C5_ENROLLMENT_REQUIRED"}
|
||||
|
||||
monkeypatch.setattr(
|
||||
scope.RuntimeConfig, "from_environment",
|
||||
classmethod(lambda cls: SimpleNamespace()),
|
||||
)
|
||||
monkeypatch.setattr(scope, "_runtime_service", lambda: Runtime())
|
||||
composite = scope.wrap_principal_runtime_from_environment(Base(), role="verifier")
|
||||
readiness = composite.readiness()
|
||||
assert readiness["ready"] is True
|
||||
assert readiness["c5_scope_enrolled"] is False
|
||||
assert readiness["c5_scope_error"] == "C5_ENROLLMENT_REQUIRED"
|
||||
# The upstream Bridge storage readiness contract is the original static
|
||||
# C4 singleton. C5 is admitted later through the exact signed enrollment.
|
||||
assert composite.activation_scope_task_ids() == ("task-c4",)
|
||||
|
||||
|
||||
def test_issuer_readiness_reports_actual_loaded_server_source(monkeypatch, tmp_path):
|
||||
source = tmp_path / "aios_task_principal_issuer_server.py"
|
||||
source.write_bytes(b"# exact issuer fixture\n")
|
||||
|
||||
class Base:
|
||||
def readiness(self):
|
||||
return {"ready": True}
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "aios_task_principal_issuer_server",
|
||||
SimpleNamespace(__file__=str(source)),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
scope.RuntimeConfig, "from_environment", classmethod(lambda cls: None),
|
||||
)
|
||||
status = scope.wrap_principal_runtime_from_environment(
|
||||
Base(), role="issuer",
|
||||
).readiness()
|
||||
assert status["ready"] is True
|
||||
assert status["source_module_path"] == str(source.resolve())
|
||||
assert status["source_module_sha256"] == hashlib.sha256(
|
||||
source.read_bytes()
|
||||
).hexdigest()
|
||||
|
||||
|
||||
def test_issuer_readiness_accepts_only_exact_python_m_main_module(monkeypatch, tmp_path):
|
||||
source = tmp_path / "aios_task_principal_issuer_server.py"
|
||||
source.write_bytes(b"# python -m issuer fixture\n")
|
||||
|
||||
class Base:
|
||||
def readiness(self):
|
||||
return {"ready": True}
|
||||
|
||||
monkeypatch.delitem(sys.modules, "aios_task_principal_issuer_server", raising=False)
|
||||
main = SimpleNamespace(
|
||||
__file__=str(source),
|
||||
__spec__=SimpleNamespace(name="aios_task_principal_issuer_server"),
|
||||
)
|
||||
monkeypatch.setitem(sys.modules, "__main__", main)
|
||||
monkeypatch.setattr(
|
||||
scope.RuntimeConfig, "from_environment", classmethod(lambda cls: None),
|
||||
)
|
||||
composite = scope.wrap_principal_runtime_from_environment(Base(), role="issuer")
|
||||
status = composite.readiness()
|
||||
assert status["ready"] is True
|
||||
assert status["source_module_path"] == str(source.resolve())
|
||||
|
||||
main.__spec__ = SimpleNamespace(name="untrusted_other_module")
|
||||
status = composite.readiness()
|
||||
assert status["ready"] is False
|
||||
assert status["error"] == "C5_ISSUER_SOURCE_MODULE_MISSING"
|
||||
|
||||
|
||||
def test_single_activation_config_is_exact_and_old_env_does_not_enable(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("AIOS_C5_PRINCIPAL_SCOPE_CONFIG", raising=False)
|
||||
monkeypatch.setenv("AIOS_C5_PRINCIPAL_SCOPE_ENABLED", "true")
|
||||
assert scope.RuntimeConfig.from_environment() is None
|
||||
config = {
|
||||
"schema": "aios.c5-task-principal-activation.v1",
|
||||
"task_id": scope.TASK_ID,
|
||||
"agent_id": scope.AGENT_ID,
|
||||
"execution_digest": scope.EXECUTION_DIGEST,
|
||||
"store_path": "/var/lib/aios-task-principal-c5/scope-enrollment.sqlite3",
|
||||
"grant_db": "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3",
|
||||
"plan_dir": "/var/lib/aios-gitops/bridge-v2-staging/releases/c5/plans",
|
||||
"producer_state_root": "/var/lib/aios-gitops/bridge-v2-staging",
|
||||
"policy_artifact": "/var/lib/aios-gitops/bridge-v2-staging/releases/c5/policy.json",
|
||||
"policy_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/c5/policy-revocations.json",
|
||||
"epoch_root": "/var/lib/aios-gitops/bridge-v2-staging/releases/c5/epoch",
|
||||
"epoch_revocations": "/var/lib/aios-gitops/bridge-v2-staging/releases/c5/epoch-revocations.json",
|
||||
"owner_registry": "/etc/aios/single-host-signer-registry.json",
|
||||
"owner_signer_id": "local-owner-authority",
|
||||
"registered_signer_command": "/usr/local/libexec/aios-grant-signer-adapter",
|
||||
"require_root_plan_owner": True,
|
||||
"grant_db_allowed_group_gid": None,
|
||||
}
|
||||
path = tmp_path / "principal-activation.json"
|
||||
_write(path, scope.canonical_json(config))
|
||||
monkeypatch.setenv("AIOS_C5_PRINCIPAL_SCOPE_CONFIG", str(path.resolve()))
|
||||
parsed = scope.RuntimeConfig.from_environment()
|
||||
assert parsed is not None
|
||||
assert str(parsed.owner_registry).replace("\\", "/") == "/etc/aios/single-host-signer-registry.json"
|
||||
config["unexpected"] = True
|
||||
_write(path, scope.canonical_json(config))
|
||||
with pytest.raises(scope.C5PrincipalScopeError, match="C5_CONFIG_FIELDS_INVALID"):
|
||||
scope.RuntimeConfig.from_environment()
|
||||
|
||||
|
||||
def test_typed_issuer_signs_enrolls_and_replays_without_raw_artifact(monkeypatch, tmp_path):
|
||||
service, _signatures, _paths, _store = _service(tmp_path)
|
||||
calls = []
|
||||
|
||||
class RegistryVerifier:
|
||||
def __init__(self, *args, **kwargs):
|
||||
calls.append(("verifier", args, kwargs))
|
||||
|
||||
class Signer:
|
||||
def __init__(self, *args, **kwargs):
|
||||
calls.append(("signer", args, kwargs))
|
||||
|
||||
def _sign_canonical_payload(self, namespace, payload):
|
||||
calls.append(("sign", namespace, payload))
|
||||
return "valid-signature"
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "aios_single_host_authority",
|
||||
SimpleNamespace(RegistryOwnerApprovalVerifier=RegistryVerifier),
|
||||
)
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "registered_owner_signer_adapter",
|
||||
SimpleNamespace(RegisteredOwnerSignerAdapter=Signer),
|
||||
)
|
||||
config = scope.RuntimeConfig(
|
||||
store_path=tmp_path / "enrollments.sqlite3",
|
||||
grant_db=tmp_path / "grant.sqlite3", plan_dir=tmp_path / "plans",
|
||||
producer_state_root=tmp_path, policy_artifact=tmp_path / "policy.json",
|
||||
policy_revocation_file=tmp_path / "policy-revocations.json",
|
||||
epoch_root=tmp_path / "epoch",
|
||||
epoch_revocation_file=tmp_path / "epoch-revocations.json",
|
||||
owner_registry=Path("/etc/aios/single-host-signer-registry.json"),
|
||||
owner_signer_id="local-owner-authority",
|
||||
registered_signer_command=Path("/usr/local/libexec/aios-grant-signer-adapter"),
|
||||
require_root_plan_owner=True, allowed_group_gid=None,
|
||||
)
|
||||
issuer = scope.C5ScopeEnrollmentIssuer(service, config)
|
||||
receipt = issuer.issue(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
assert receipt["status"] == "ENROLLED"
|
||||
assert receipt["task_id"] == scope.TASK_ID
|
||||
assert receipt["agent_id"] == scope.AGENT_ID
|
||||
assert receipt["job_id"] == "job-c5-real"
|
||||
assert "signature" not in receipt and "contract" not in receipt
|
||||
replay = issuer.issue(
|
||||
task=_task(), execution=_execution(), trust=TRUST, now=NOW,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
assert replay["replayed"] is True
|
||||
assert len([item for item in calls if item[0] == "sign"]) == 1
|
||||
|
||||
|
||||
def test_overlay_builder_patches_one_shared_admission_module(tmp_path, monkeypatch):
|
||||
builder_path = ROOT / "scripts" / "build_c5_live_principal_overlay.py"
|
||||
spec = importlib.util.spec_from_file_location("c5_overlay_builder_test", builder_path)
|
||||
assert spec is not None and spec.loader is not None
|
||||
builder = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(builder)
|
||||
principal = tmp_path / "principal"
|
||||
adapter = tmp_path / "adapter"
|
||||
principal.mkdir()
|
||||
adapter.mkdir()
|
||||
admission_payload = b"# effective governed admission fixture\n"
|
||||
(principal / "aios_task_principal_admission.py").write_bytes(admission_payload)
|
||||
issuer_payload = (
|
||||
b"class Base:\n"
|
||||
b" _read_public_manifest = staticmethod(lambda path: path)\n"
|
||||
b"_BASE = Base()\n"
|
||||
b"MAX_BODY = 4 * 1024 * 1024\n"
|
||||
b"def main():\n"
|
||||
b" return None\n"
|
||||
+ builder.ISSUER_MAIN_ANCHOR.encode()
|
||||
)
|
||||
(principal / "aios_task_principal_issuer_server.py").write_bytes(issuer_payload)
|
||||
adapter_payloads = {
|
||||
"aios_task_principal_governed_adapter.py": b"# governed adapter fixture\n",
|
||||
"governed_runtime_scope.py": b"# governed scope fixture\n",
|
||||
}
|
||||
for name, payload in adapter_payloads.items():
|
||||
(adapter / name).write_bytes(payload)
|
||||
builder.PINNED = {
|
||||
"aios_task_principal_admission.py": hashlib.sha256(
|
||||
admission_payload
|
||||
).hexdigest(),
|
||||
"aios_task_principal_issuer_server.py": hashlib.sha256(
|
||||
issuer_payload
|
||||
).hexdigest(),
|
||||
**{
|
||||
name: hashlib.sha256(payload).hexdigest()
|
||||
for name, payload in adapter_payloads.items()
|
||||
},
|
||||
}
|
||||
output = tmp_path / "overlay"
|
||||
monkeypatch.setattr(sys, "argv", [
|
||||
str(builder_path), "--principal-source-root", str(principal),
|
||||
"--adapter-source-root", str(adapter), "--output-root", str(output),
|
||||
])
|
||||
assert builder.main() == 0
|
||||
manifest = json.loads((output / "overlay-manifest.json").read_text())
|
||||
assert manifest["activation"]["same_admission_overlay_for_both_units"] is True
|
||||
assert manifest["entrypoints"]["issuer"] == "python3 -B -m aios_task_principal_issuer_server"
|
||||
issuer_output = output / "app" / "aios_task_principal_issuer_server.py"
|
||||
issuer_result = issuer_output.read_text()
|
||||
assert issuer_result.startswith(issuer_payload.decode().split(
|
||||
builder.ISSUER_MAIN_ANCHOR, 1
|
||||
)[0])
|
||||
assert "_read_public_manifest = _BASE._read_public_manifest" in issuer_result
|
||||
assert issuer_result.count(builder.ISSUER_MAIN_ANCHOR) == 1
|
||||
namespace = {"__name__": "issuer_fixture_import"}
|
||||
exec(compile(issuer_result, str(issuer_output), "exec"), namespace)
|
||||
assert namespace["_read_public_manifest"]("manifest.json") == "manifest.json"
|
||||
assert namespace["MAX_BODY"] == 4 * 1024 * 1024
|
||||
assert manifest["files"][issuer_output.name]["base_sha256"] == hashlib.sha256(
|
||||
issuer_payload
|
||||
).hexdigest()
|
||||
assert manifest["files"][issuer_output.name]["result_sha256"] == hashlib.sha256(
|
||||
issuer_output.read_bytes()
|
||||
).hexdigest()
|
||||
assert manifest["files"][issuer_output.name]["bridge_private_helper"] == (
|
||||
"_read_public_manifest"
|
||||
)
|
||||
patched = (output / "app" / "aios_task_principal_admission.py").read_text()
|
||||
assert builder.PINNED["aios_task_principal_admission.py"] == hashlib.sha256(
|
||||
admission_payload
|
||||
).hexdigest()
|
||||
assert "_ORIGINAL_SCOPE_AND_TRUST = _legacy._scope_and_trust" in patched
|
||||
assert 'if str(task["task_id"]) != C5_TASK_ID:' in patched
|
||||
assert "return _ORIGINAL_SCOPE_AND_TRUST(" in patched
|
||||
assert "active_digest, active_scope = _legacy._active_scope_chain(con)" in patched
|
||||
assert "if active_digest != digest:" in patched
|
||||
assert 'event["scope_id"] != active_scope["scope_id"]' in patched
|
||||
assert "_legacy._check_revocations(con, task, active_scope, trust, None)" in patched
|
||||
assert "wrap_principal_runtime_from_environment" in patched
|
||||
assert manifest["files"]["aios_task_principal_admission.py"][
|
||||
"runtime_base_source"
|
||||
] == builder.EFFECTIVE_ADMISSION_SOURCE
|
||||
assert hashlib.sha256(
|
||||
(output / "app" / "aios_task_principal_governed_adapter.py").read_bytes()
|
||||
).hexdigest() == scope.sha256_bytes((adapter / "aios_task_principal_governed_adapter.py").read_bytes())
|
||||
assert "sys.dont_write_bytecode = True" in (
|
||||
output / "bin" / "aios-c5-principal-scope"
|
||||
).read_text()
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tarfile
|
||||
|
||||
import pytest
|
||||
|
||||
from scripts.build_c5_staging_artifact import BuildError, build_artifact
|
||||
|
||||
|
||||
def _sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def _fixture(tmp_path: Path) -> tuple[Path, str, Path, Path, Path]:
|
||||
repo = Path(__file__).parents[1]
|
||||
support = subprocess.check_output(
|
||||
["git", "-C", str(repo), "rev-parse", "HEAD"], text=True
|
||||
).strip()
|
||||
live = repo.parent / "live-release"
|
||||
overlay = tmp_path / "staging-overlay" / "app"
|
||||
snapshot = overlay.parent.parent / "staging-source-snapshot"
|
||||
overlay.mkdir(parents=True)
|
||||
snapshot.mkdir(parents=True)
|
||||
for name in (
|
||||
"aios_chatgpt_bridge_v2_server.py",
|
||||
"aios_task_admission_bootstrap.py",
|
||||
"aios_task_admission_live_adapters.py",
|
||||
):
|
||||
shutil.copy2(live / "staging-overlay" / "app" / name, overlay / name)
|
||||
shutil.copy2(live / "staging-source-snapshot" / name, snapshot / name)
|
||||
shutil.copy2(
|
||||
live / "staging-overlay" / "c5-epoch-v2-overlay.result.json",
|
||||
overlay.parent / "c5-epoch-v2-overlay.result.json",
|
||||
)
|
||||
authority = tmp_path / "authority"
|
||||
authority.mkdir()
|
||||
root = f"/var/lib/aios-gitops/bridge-v2-staging/releases/c5-source-{support}"
|
||||
policy = {"payload": {"source_oid": support}}
|
||||
policy_bytes = json.dumps(policy).encode()
|
||||
(authority / "policy.json").write_bytes(policy_bytes)
|
||||
source_activation = {
|
||||
"schema": "aios.source-publication-host-activation.v1",
|
||||
"policy_artifact": f"{root}/policy.json",
|
||||
"policy_sha256": _sha(policy_bytes),
|
||||
"policy_revocations": f"{root}/policy-revocations.json",
|
||||
"admission_activation": f"{root}/admission-activation.json",
|
||||
"expected_agent_id": "agent/codex-c5-release",
|
||||
}
|
||||
(authority / "source-activation.json").write_bytes(json.dumps(source_activation).encode())
|
||||
(authority / "policy-revocations.json").write_bytes(b"{}")
|
||||
(authority / "admission-activation.json").write_bytes(b"{}")
|
||||
(authority / "AUTHORITY_PREPARATION_RESULT.json").write_bytes(
|
||||
json.dumps({"source_commit": support, "support_commit": support}).encode()
|
||||
)
|
||||
old_module = "/opt/aios/current/app/aios_chatgpt_bridge_v2_server.py"
|
||||
snapshot_payload = {
|
||||
"unit_active": True,
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
"effective_config_sha256": "1" * 64,
|
||||
"dropin": {"exists": True, "sha256": "2" * 64},
|
||||
"pythonpath": ["/opt/aios/current", "/opt/aios/current/app"],
|
||||
"source_files": [{
|
||||
"path": old_module, "sha256": "3" * 64, "size": 12,
|
||||
"mode": 0o644, "kind": "existing_source",
|
||||
}],
|
||||
}
|
||||
snapshot = tmp_path / "snapshot.json"
|
||||
snapshot.write_bytes(json.dumps(snapshot_payload).encode())
|
||||
return repo, support, overlay, authority, snapshot
|
||||
|
||||
|
||||
def test_builds_regular_only_archive_and_typed_request(tmp_path: Path) -> None:
|
||||
repo, support, overlay, authority, snapshot = _fixture(tmp_path)
|
||||
result = build_artifact(
|
||||
repo, support, overlay, authority, snapshot, tmp_path / "out",
|
||||
nonce="c5-test-nonce-20260926", issued_at=1000, ttl_seconds=900,
|
||||
)
|
||||
artifact = Path(result["artifact"])
|
||||
request = json.loads(Path(result["request"]).read_text())
|
||||
assert result["status"] == "BUILT_UNSIGNED"
|
||||
assert result["signed"] is False and result["installed"] is False
|
||||
assert request["schema"] == "aios.c5-staging-installation-request.v2"
|
||||
assert request["target"] == "bridge-18892"
|
||||
paths = {record["path"] for record in request["files"]}
|
||||
assert any(path.endswith("/app/c5_source_authority.py") for path in paths)
|
||||
assert any(path.endswith("/app/aios_task_admission_live_adapters.py") for path in paths)
|
||||
with tarfile.open(artifact, "r:") as bundle:
|
||||
members = bundle.getmembers()
|
||||
assert members
|
||||
assert all(member.isfile() for member in members)
|
||||
assert "dropin.conf" in {member.name for member in members}
|
||||
assert "__pycache__" not in " ".join(member.name for member in members)
|
||||
|
||||
|
||||
def test_rejects_bytecode_in_overlay(tmp_path: Path) -> None:
|
||||
repo, support, overlay, authority, snapshot = _fixture(tmp_path)
|
||||
(overlay / "bad.pyc").write_bytes(b"bytecode")
|
||||
with pytest.raises(BuildError, match="OVERLAY_BYTECODE_FORBIDDEN"):
|
||||
build_artifact(repo, support, overlay, authority, snapshot, tmp_path / "out")
|
||||
|
||||
|
||||
def test_generated_request_and_archive_use_installer_producer_validators(tmp_path: Path) -> None:
|
||||
"""Exercise the real typed request and artifact producer checks when crypto is available."""
|
||||
repo, support, overlay, authority, snapshot = _fixture(tmp_path)
|
||||
result = build_artifact(
|
||||
repo, support, overlay, authority, snapshot, tmp_path / "out",
|
||||
nonce="c5-validator-nonce-20260926", issued_at=1000, ttl_seconds=900,
|
||||
)
|
||||
sys.path.insert(0, str((repo / "active-runtime" / "app").resolve()))
|
||||
c5 = pytest.importorskip("c5_staging_installation")
|
||||
request = c5.load_installation_request(Path(result["request"]), require_root_owner=False)
|
||||
layout = c5._Layout(test_only=True)
|
||||
payload = c5._request_payload(
|
||||
request, owner_key_id="SHA256:ephemeral-validator", issued_at=1000, layout=layout,
|
||||
)
|
||||
c5._validate_payload(payload, 1000, layout=layout)
|
||||
c5._verified_artifact(
|
||||
Path(result["artifact"]), payload, layout=layout, require_root_owner=False,
|
||||
)
|
||||
@@ -0,0 +1,994 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tarfile
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parents[1] / "active-runtime" / "app"))
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
sign_sshsig,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
import c5_staging_installation as c5
|
||||
from registered_owner_signer_adapter import RegisteredOwnerSignerAdapter
|
||||
|
||||
|
||||
NOW = 1050
|
||||
SOURCE = "536a2af0f07962495c88526061ce43fb27994c15"
|
||||
SUPPORT = "04a5e3cbfecf8156074f3a68cd72b678c06ace11"
|
||||
|
||||
|
||||
def sha(data: bytes) -> str:
|
||||
return hashlib.sha256(data).hexdigest()
|
||||
|
||||
|
||||
def file_record(path: str, data: bytes, kind: str, mode: int = 0o644) -> dict:
|
||||
return {
|
||||
"path": path,
|
||||
"sha256": sha(data),
|
||||
"size": len(data),
|
||||
"mode": mode,
|
||||
"kind": kind,
|
||||
}
|
||||
|
||||
|
||||
def principal_material(root: Path) -> tuple[bytes, dict[str, bytes]]:
|
||||
supporting = {
|
||||
"plans/task.json": b'{"plan":"c5"}',
|
||||
"policy.json": b'{"policy":"c5"}',
|
||||
"policy-revocations.json": b'{"revocations":[]}',
|
||||
"epoch/epoch.json": b'{"epoch":"c5"}',
|
||||
"epoch-revocations.json": b'{"revocations":[]}',
|
||||
}
|
||||
config = {
|
||||
"schema": "aios.c5-task-principal-activation.v1",
|
||||
"task_id": "TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
"agent_id": "agent/codex-c5-release",
|
||||
"execution_digest": "1ba534d551991e8c99014285eeea36afcbc1500686035cb8bcd7f9f4efd6215e",
|
||||
"store_path": "/var/lib/aios-gitops/c5-source-principal/enrollment.sqlite3",
|
||||
"grant_db": "/var/lib/aios-gitops/bridge-v2-staging/task-admission.sqlite3",
|
||||
"plan_dir": (root / "plans").as_posix(),
|
||||
"producer_state_root": "/var/lib/aios-gitops/bridge-v2-staging",
|
||||
"policy_artifact": (root / "policy.json").as_posix(),
|
||||
"policy_revocations": (root / "policy-revocations.json").as_posix(),
|
||||
"epoch_root": (root / "epoch").as_posix(),
|
||||
"epoch_revocations": (root / "epoch-revocations.json").as_posix(),
|
||||
"owner_registry": "/etc/aios/single-host-signer-registry.json",
|
||||
"owner_signer_id": "local-owner-authority",
|
||||
"registered_signer_command": "/usr/local/libexec/aios-grant-signer-adapter",
|
||||
"require_root_plan_owner": True,
|
||||
"grant_db_allowed_group_gid": None,
|
||||
}
|
||||
return c5.canonical_bytes(config), supporting
|
||||
|
||||
|
||||
def write_registry(path: Path, key: Ed25519PrivateKey) -> None:
|
||||
public = key.public_key()
|
||||
value = {
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [
|
||||
{
|
||||
"signer_id": "local-owner-authority",
|
||||
"role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service",
|
||||
"fingerprint": ssh_fingerprint(public),
|
||||
"public_key": ssh_public_line(public, "local-owner-authority"),
|
||||
"socket": "/run/aios/owner-authority.sock",
|
||||
"secret_ref": "aios-secret://single-host/local-owner-authority",
|
||||
"status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "",
|
||||
"parent_fingerprint": "",
|
||||
}
|
||||
],
|
||||
}
|
||||
path.write_text(json.dumps(value), encoding="utf-8")
|
||||
|
||||
|
||||
def write_tar(path: Path, members: list[tuple[str, bytes, int, str]]) -> None:
|
||||
with tarfile.open(path, "w") as bundle:
|
||||
for name, data, mode, kind in members:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.mode = mode
|
||||
if kind == "file":
|
||||
info.size = len(data)
|
||||
bundle.addfile(info, io.BytesIO(data))
|
||||
elif kind == "symlink":
|
||||
info.type = tarfile.SYMTYPE
|
||||
info.linkname = "target"
|
||||
bundle.addfile(info)
|
||||
else:
|
||||
raise AssertionError(kind)
|
||||
|
||||
|
||||
class FakeBackend:
|
||||
def __init__(self, case: "Case") -> None:
|
||||
self.case = case
|
||||
self.host = c5.HOST
|
||||
self.reloads = 0
|
||||
self.restarts = 0
|
||||
self.fail_new = False
|
||||
self.fail_old = False
|
||||
self.runtime_results = []
|
||||
self.runtime_calls = 0
|
||||
|
||||
def hostname(self) -> str:
|
||||
return self.host
|
||||
|
||||
def is_new(self) -> bool:
|
||||
dropin = self.case.profile.dropin(self.case.layout)
|
||||
return (
|
||||
dropin.exists()
|
||||
and dropin.read_bytes() == self.case.new_dropin
|
||||
)
|
||||
|
||||
def unit_snapshot(self, profile) -> c5.UnitSnapshot:
|
||||
assert profile == self.case.profile
|
||||
if self.is_new():
|
||||
return c5.UnitSnapshot(
|
||||
True,
|
||||
sha(b"new-effective-config"),
|
||||
self.case.new_pythonpath,
|
||||
self.case.activation_path,
|
||||
self.case.principal_config_path,
|
||||
)
|
||||
return c5.UnitSnapshot(
|
||||
True,
|
||||
self.case.old_config_hash,
|
||||
self.case.old_pythonpath,
|
||||
None,
|
||||
None,
|
||||
)
|
||||
|
||||
def daemon_reload(self) -> None:
|
||||
self.reloads += 1
|
||||
|
||||
def restart(self, profile) -> None:
|
||||
self.restarts += 1
|
||||
|
||||
def runtime(self, *, url: str, timeout_seconds: int):
|
||||
assert url == self.case.runtime_url
|
||||
assert timeout_seconds == 3
|
||||
self.runtime_calls += 1
|
||||
if self.runtime_results:
|
||||
result = self.runtime_results.pop(0)
|
||||
if isinstance(result, Exception):
|
||||
raise result
|
||||
return result
|
||||
if self.is_new():
|
||||
return {"status": "bad"} if self.fail_new else dict(self.case.new_runtime)
|
||||
return {"status": "bad"} if self.fail_old else dict(self.case.old_runtime)
|
||||
|
||||
|
||||
class Case:
|
||||
pass
|
||||
|
||||
|
||||
def make_case(tmp_path: Path) -> Case:
|
||||
case = Case()
|
||||
case.profile = c5._BRIDGE_PROFILE
|
||||
case.layout = c5._Layout(
|
||||
release_base=tmp_path / "var" / "releases",
|
||||
dropin=tmp_path / "etc" / f"{c5.UNIT}.d" / c5.DROPIN.name,
|
||||
issuer_dropin=tmp_path / "etc" / f"{c5.ISSUER_UNIT}.d" / c5.ISSUER_DROPIN.name,
|
||||
state_root=tmp_path / "var" / "state",
|
||||
test_only=True,
|
||||
)
|
||||
case.layout.release_base.mkdir(parents=True)
|
||||
case.layout.dropin.parent.mkdir(parents=True)
|
||||
case.layout.issuer_dropin.parent.mkdir(parents=True)
|
||||
case.old_dropin = b"[Service]\nEnvironment=EXISTING_G0=preserved\n"
|
||||
case.layout.dropin.write_bytes(case.old_dropin)
|
||||
old_root = tmp_path / "old-runtime"
|
||||
old_app = old_root / "app"
|
||||
old_agent = old_root / "runtime-agent-os"
|
||||
old_app.mkdir(parents=True)
|
||||
old_agent.mkdir()
|
||||
case.old_module = old_app / "aios_chatgpt_bridge_v2_server.py"
|
||||
case.old_module_bytes = b"old bridge source\n"
|
||||
case.old_module.write_bytes(case.old_module_bytes)
|
||||
case.old_pythonpath = (
|
||||
old_root.as_posix(),
|
||||
old_app.as_posix(),
|
||||
old_agent.as_posix(),
|
||||
)
|
||||
case.old_config_hash = sha(b"old-effective-config")
|
||||
|
||||
case.root = case.layout.release_base / f"c5-source-{SUPPORT}"
|
||||
case.new_module = case.root / "app" / "aios_chatgpt_bridge_v2_server.py"
|
||||
case.new_module_bytes = b"new bridge source with runtime proof\n"
|
||||
case.activation = case.root / "source-activation.json"
|
||||
case.activation_bytes = b'{"schema":"aios.source-activation.v1"}\n'
|
||||
case.activation_path = case.activation.as_posix()
|
||||
case.principal_config_path = None
|
||||
case.runtime_url = c5.RUNTIME_URL
|
||||
case.new_pythonpath = ((case.root / "app").as_posix(), *case.old_pythonpath)
|
||||
case.new_dropin = c5._dropin_bytes(case.root, case.old_pythonpath)
|
||||
|
||||
case.old_runtime = {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
"deployment_state": "STAGING",
|
||||
"repository": c5.REPOSITORY,
|
||||
"control_plane_target_oid": "1" * 40,
|
||||
}
|
||||
case.new_runtime = {
|
||||
"status": "active",
|
||||
"health": 200,
|
||||
"deployment_state": "STAGING",
|
||||
"repository": c5.REPOSITORY,
|
||||
"governed_writes_enabled": True,
|
||||
"source_module_path": case.new_module.as_posix(),
|
||||
"source_module_sha256": sha(case.new_module_bytes),
|
||||
}
|
||||
case.files = [
|
||||
file_record(case.new_module.as_posix(), case.new_module_bytes, "release_file"),
|
||||
file_record(case.activation.as_posix(), case.activation_bytes, "release_file"),
|
||||
file_record(case.layout.dropin.as_posix(), case.new_dropin, "systemd_dropin"),
|
||||
]
|
||||
case.payload = {
|
||||
"schema": c5.SCHEMA,
|
||||
"target": c5.BRIDGE_TARGET,
|
||||
"contract_id": "c5-installation-01",
|
||||
"owner_signer_id": "local-owner-authority",
|
||||
"owner_key_id": "",
|
||||
"owner_namespace": OWNER_NAMESPACE,
|
||||
"host": c5.HOST,
|
||||
"repository": c5.REPOSITORY,
|
||||
"source_commit": SOURCE,
|
||||
"support_commit": SUPPORT,
|
||||
"release_root": case.root.as_posix(),
|
||||
"files": case.files,
|
||||
"unit": c5.UNIT,
|
||||
"listen": {"host": "127.0.0.1", "port": c5.PORT},
|
||||
"deployment_state": "STAGING",
|
||||
"effects": sorted(c5.EFFECTS),
|
||||
"forbidden_effects": sorted(c5.FORBIDDEN),
|
||||
"existing": {
|
||||
"unit_active": True,
|
||||
"effective_config_sha256": case.old_config_hash,
|
||||
"dropin": {"exists": True, "sha256": sha(case.old_dropin)},
|
||||
"pythonpath": list(case.old_pythonpath),
|
||||
"source_files": [
|
||||
file_record(
|
||||
case.old_module.as_posix(),
|
||||
case.old_module_bytes,
|
||||
"existing_source",
|
||||
)
|
||||
],
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
},
|
||||
"readiness": {
|
||||
"url": c5.RUNTIME_URL,
|
||||
"timeout_seconds": 3,
|
||||
"unit_active": True,
|
||||
"pythonpath": list(case.new_pythonpath),
|
||||
"activation_file": case.activation_path,
|
||||
"principal_scope_config": None,
|
||||
"module_path": case.new_module.as_posix(),
|
||||
"module_sha256": sha(case.new_module_bytes),
|
||||
"runtime_fields": case.new_runtime,
|
||||
},
|
||||
"rollback": {
|
||||
"strategy": "restore-exact-dropin-and-restart",
|
||||
"dropin_restore": "exact",
|
||||
"readiness": {
|
||||
"url": c5.RUNTIME_URL,
|
||||
"timeout_seconds": 3,
|
||||
"unit_active": True,
|
||||
"pythonpath": list(case.old_pythonpath),
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
"module_path": case.old_module.as_posix(),
|
||||
"module_sha256": sha(case.old_module_bytes),
|
||||
"runtime_fields": case.old_runtime,
|
||||
},
|
||||
},
|
||||
"issued_at": 1000,
|
||||
"expires_at": 1100,
|
||||
"nonce": "nonce-c5-installation-0001",
|
||||
}
|
||||
case.key = Ed25519PrivateKey.generate()
|
||||
case.payload["owner_key_id"] = ssh_fingerprint(case.key.public_key())
|
||||
case.registry = tmp_path / "registry.json"
|
||||
write_registry(case.registry, case.key)
|
||||
case.contract = tmp_path / "contract.json"
|
||||
case.artifact = tmp_path / "artifact.tar"
|
||||
resign(case)
|
||||
write_tar(
|
||||
case.artifact,
|
||||
[
|
||||
("app/aios_chatgpt_bridge_v2_server.py", case.new_module_bytes, 0o644, "file"),
|
||||
("source-activation.json", case.activation_bytes, 0o644, "file"),
|
||||
("dropin.conf", case.new_dropin, 0o644, "file"),
|
||||
],
|
||||
)
|
||||
case.backend = FakeBackend(case)
|
||||
case.installer = c5.Installer(
|
||||
case.registry,
|
||||
backend=case.backend,
|
||||
clock=lambda: NOW,
|
||||
sleeper=lambda _seconds: None,
|
||||
require_root_owner=False,
|
||||
_test_layout=case.layout,
|
||||
)
|
||||
return case
|
||||
|
||||
|
||||
def make_issuer_case(tmp_path: Path) -> Case:
|
||||
case = make_case(tmp_path)
|
||||
case.profile = c5._ISSUER_PROFILE
|
||||
case.runtime_url = c5.ISSUER_RUNTIME_URL
|
||||
case.layout.issuer_dropin.write_bytes(case.old_dropin)
|
||||
|
||||
old_issuer = case.old_module.parent / "aios_task_principal_issuer_server.py"
|
||||
old_issuer_bytes = b"old issuer source\n"
|
||||
old_issuer.write_bytes(old_issuer_bytes)
|
||||
case.old_module = old_issuer
|
||||
case.old_module_bytes = old_issuer_bytes
|
||||
|
||||
app = case.root / "app"
|
||||
app.mkdir(parents=True)
|
||||
case.new_module = app / "aios_task_principal_issuer_server.py"
|
||||
case.new_module_bytes = b"new issuer source with C5 health\n"
|
||||
case.new_module.write_bytes(case.new_module_bytes)
|
||||
bridge_module = app / "aios_chatgpt_bridge_v2_server.py"
|
||||
bridge_bytes = b"shared bridge module\n"
|
||||
bridge_module.write_bytes(bridge_bytes)
|
||||
case.activation.write_bytes(case.activation_bytes)
|
||||
principal_config = case.root / "principal-activation.json"
|
||||
principal_bytes, principal_supporting = principal_material(case.root)
|
||||
principal_config.write_bytes(principal_bytes)
|
||||
for relative, data in principal_supporting.items():
|
||||
path = case.root / relative
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
case.activation_path = None
|
||||
case.principal_config_path = principal_config.as_posix()
|
||||
case.new_pythonpath = ((case.root / "app").as_posix(), *case.old_pythonpath)
|
||||
case.new_dropin = c5._dropin_bytes(
|
||||
case.root, case.old_pythonpath,
|
||||
target=c5.ISSUER_TARGET, principal_config=True,
|
||||
)
|
||||
case.old_runtime = {
|
||||
"ready": True,
|
||||
"active_key_registered": True,
|
||||
"private_key_matches_trust": True,
|
||||
}
|
||||
case.new_runtime = {
|
||||
"ready": True,
|
||||
"c5_scope_enabled": True,
|
||||
"c5_scope_enrolled": False,
|
||||
"c5_scope_role": "ISSUER",
|
||||
"source_module_path": case.new_module.as_posix(),
|
||||
"source_module_sha256": sha(case.new_module_bytes),
|
||||
}
|
||||
case.files = [
|
||||
file_record(bridge_module.as_posix(), bridge_bytes, "release_file"),
|
||||
file_record(case.new_module.as_posix(), case.new_module_bytes, "release_file"),
|
||||
file_record(case.activation.as_posix(), case.activation_bytes, "release_file"),
|
||||
file_record(principal_config.as_posix(), principal_bytes, "release_file"),
|
||||
*[
|
||||
file_record((case.root / relative).as_posix(), data, "release_file")
|
||||
for relative, data in principal_supporting.items()
|
||||
],
|
||||
file_record(case.layout.issuer_dropin.as_posix(), case.new_dropin, "systemd_dropin"),
|
||||
]
|
||||
case.payload.update(
|
||||
target=c5.ISSUER_TARGET,
|
||||
contract_id="c5-issuer-installation-01",
|
||||
files=case.files,
|
||||
unit=c5.ISSUER_UNIT,
|
||||
listen={"host": "127.0.0.1", "port": c5.ISSUER_PORT},
|
||||
effects=sorted(c5.ISSUER_EFFECTS),
|
||||
nonce="nonce-c5-issuer-installation-0001",
|
||||
)
|
||||
case.payload["existing"] = {
|
||||
"unit_active": True,
|
||||
"effective_config_sha256": case.old_config_hash,
|
||||
"dropin": {"exists": True, "sha256": sha(case.old_dropin)},
|
||||
"pythonpath": list(case.old_pythonpath),
|
||||
"source_files": [
|
||||
file_record(old_issuer.as_posix(), old_issuer_bytes, "existing_source")
|
||||
],
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
}
|
||||
case.payload["readiness"] = {
|
||||
"url": c5.ISSUER_RUNTIME_URL,
|
||||
"timeout_seconds": 3,
|
||||
"unit_active": True,
|
||||
"pythonpath": list(case.new_pythonpath),
|
||||
"activation_file": None,
|
||||
"principal_scope_config": case.principal_config_path,
|
||||
"module_path": case.new_module.as_posix(),
|
||||
"module_sha256": sha(case.new_module_bytes),
|
||||
"runtime_fields": case.new_runtime,
|
||||
}
|
||||
case.payload["rollback"]["readiness"] = {
|
||||
"url": c5.ISSUER_RUNTIME_URL,
|
||||
"timeout_seconds": 3,
|
||||
"unit_active": True,
|
||||
"pythonpath": list(case.old_pythonpath),
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
"module_path": old_issuer.as_posix(),
|
||||
"module_sha256": sha(old_issuer_bytes),
|
||||
"runtime_fields": case.old_runtime,
|
||||
}
|
||||
resign(case)
|
||||
write_tar(
|
||||
case.artifact,
|
||||
[
|
||||
("app/aios_chatgpt_bridge_v2_server.py", bridge_bytes, 0o644, "file"),
|
||||
("app/aios_task_principal_issuer_server.py", case.new_module_bytes, 0o644, "file"),
|
||||
("source-activation.json", case.activation_bytes, 0o644, "file"),
|
||||
("principal-activation.json", principal_bytes, 0o644, "file"),
|
||||
*[
|
||||
(relative, data, 0o644, "file")
|
||||
for relative, data in principal_supporting.items()
|
||||
],
|
||||
("dropin.conf", case.new_dropin, 0o644, "file"),
|
||||
],
|
||||
)
|
||||
case.backend = FakeBackend(case)
|
||||
case.installer = c5.Installer(
|
||||
case.registry,
|
||||
backend=case.backend,
|
||||
clock=lambda: NOW,
|
||||
sleeper=lambda _seconds: None,
|
||||
require_root_owner=False,
|
||||
_test_layout=case.layout,
|
||||
)
|
||||
return case
|
||||
|
||||
|
||||
def resign(case: Case) -> None:
|
||||
signature = sign_sshsig(case.key, c5.canonical_bytes(case.payload), namespace=OWNER_NAMESPACE)
|
||||
case.contract.write_text(
|
||||
json.dumps({"payload": case.payload, "signature": signature}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def test_signature_registry_and_concrete_snapshot_preflight(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
result = case.installer.preflight(case.contract, case.artifact)
|
||||
assert result["status"] == "PREFLIGHT_PASS"
|
||||
assert result["release_root"] == case.root.as_posix()
|
||||
assert not case.root.exists()
|
||||
|
||||
|
||||
def test_tamper_is_rejected_by_independent_consumer(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
outer = json.loads(case.contract.read_text())
|
||||
outer["payload"]["source_commit"] = "2" * 40
|
||||
case.contract.write_text(json.dumps(outer))
|
||||
with pytest.raises(c5.InstallationError, match="INSTALL_SIGNATURE_INVALID"):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "error"),
|
||||
[
|
||||
("host", "other-host", "INSTALL_SCOPE_INVALID"),
|
||||
("unit", "other.service", "INSTALL_SCOPE_INVALID"),
|
||||
("listen", {"host": "0.0.0.0", "port": 18892}, "INSTALL_LISTEN_INVALID"),
|
||||
("expires_at", 1050, "INSTALL_TTL_OR_NONCE_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_scope_expiration_and_unit_are_rejected(
|
||||
tmp_path: Path, field: str, value, error: str
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.payload[field] = value
|
||||
resign(case)
|
||||
with pytest.raises(c5.InstallationError, match=error):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_actual_hostname_is_required(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.backend.host = "wrong-host"
|
||||
with pytest.raises(c5.InstallationError, match="INSTALL_HOST_INVALID"):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", [0o444, 0o640])
|
||||
def test_live_safe_existing_source_modes_are_accepted(
|
||||
tmp_path: Path, mode: int,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
os.chmod(case.old_module, mode)
|
||||
case.payload["existing"]["source_files"][0]["mode"] = mode
|
||||
resign(case)
|
||||
assert case.installer.preflight(case.contract, case.artifact)["status"] == "PREFLIGHT_PASS"
|
||||
|
||||
|
||||
def test_dropin_execstart_or_extra_environment_is_rejected(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
injected = case.new_dropin + b"ExecStart=/bin/sh -c evil\nEnvironment=AIOS_GITOPS_WRITES_ENABLED=true\n"
|
||||
for record in case.payload["files"]:
|
||||
if record["path"] == case.layout.dropin.as_posix():
|
||||
record.update(sha256=sha(injected), size=len(injected))
|
||||
resign(case)
|
||||
with pytest.raises(c5.InstallationError, match="DROPIN_CONTRACT_INVALID"):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_bridge_principal_config_is_byte_pinned_and_adds_only_exact_env(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
config = case.root / "principal-activation.json"
|
||||
config_bytes, principal_supporting = principal_material(case.root)
|
||||
case.files.insert(-1, file_record(config.as_posix(), config_bytes, "release_file"))
|
||||
for relative, data in principal_supporting.items():
|
||||
case.files.insert(
|
||||
-1,
|
||||
file_record((case.root / relative).as_posix(), data, "release_file"),
|
||||
)
|
||||
case.new_dropin = c5._dropin_bytes(
|
||||
case.root, case.old_pythonpath,
|
||||
target=c5.BRIDGE_TARGET, principal_config=True,
|
||||
)
|
||||
case.principal_config_path = config.as_posix()
|
||||
for record in case.files:
|
||||
if record["path"] == case.layout.dropin.as_posix():
|
||||
record.update(sha256=sha(case.new_dropin), size=len(case.new_dropin))
|
||||
case.payload["files"] = case.files
|
||||
case.payload["readiness"]["principal_scope_config"] = config.as_posix()
|
||||
resign(case)
|
||||
write_tar(
|
||||
case.artifact,
|
||||
[
|
||||
("app/aios_chatgpt_bridge_v2_server.py", case.new_module_bytes, 0o644, "file"),
|
||||
("source-activation.json", case.activation_bytes, 0o644, "file"),
|
||||
("principal-activation.json", config_bytes, 0o644, "file"),
|
||||
*[(relative, data, 0o644, "file") for relative, data in principal_supporting.items()],
|
||||
("dropin.conf", case.new_dropin, 0o644, "file"),
|
||||
],
|
||||
)
|
||||
assert case.installer.preflight(case.contract, case.artifact)["status"] == "PREFLIGHT_PASS"
|
||||
assert case.new_dropin.decode().splitlines() == [
|
||||
"[Service]",
|
||||
f"Environment=PYTHONPATH={':'.join(case.new_pythonpath)}",
|
||||
f"Environment=AIOS_SOURCE_PUBLICATION_ACTIVATION_FILE={case.activation_path}",
|
||||
f"Environment=AIOS_C5_PRINCIPAL_SCOPE_CONFIG={config.as_posix()}",
|
||||
]
|
||||
write_tar(
|
||||
case.artifact,
|
||||
[
|
||||
("app/aios_chatgpt_bridge_v2_server.py", case.new_module_bytes, 0o644, "file"),
|
||||
("source-activation.json", case.activation_bytes, 0o644, "file"),
|
||||
("principal-activation.json", b"X" * len(config_bytes), 0o644, "file"),
|
||||
*[(relative, data, 0o644, "file") for relative, data in principal_supporting.items()],
|
||||
("dropin.conf", case.new_dropin, 0o644, "file"),
|
||||
],
|
||||
)
|
||||
with pytest.raises(c5.InstallationError, match="ARTIFACT_HASH_MISMATCH"):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_issuer_contract_verifies_existing_tree_and_changes_only_issuer_unit(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_issuer_case(tmp_path)
|
||||
before = {path.relative_to(case.root): path.read_bytes() for path in case.root.rglob("*") if path.is_file()}
|
||||
result = case.installer.preflight(case.contract, case.artifact)
|
||||
assert result["unit"] == c5.ISSUER_UNIT
|
||||
assert result["port"] == c5.ISSUER_PORT
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "APPLIED"
|
||||
after = {path.relative_to(case.root): path.read_bytes() for path in case.root.rglob("*") if path.is_file()}
|
||||
assert after == before
|
||||
assert case.layout.issuer_dropin.read_bytes() == case.new_dropin
|
||||
assert case.layout.dropin.read_bytes() == case.old_dropin
|
||||
assert case.backend.reloads == 1
|
||||
assert case.backend.restarts == 1
|
||||
|
||||
|
||||
def test_issuer_rejects_nonexact_existing_release_tree(tmp_path: Path) -> None:
|
||||
case = make_issuer_case(tmp_path)
|
||||
(case.root / "unsigned-extra.py").write_text("extra", encoding="utf-8")
|
||||
with pytest.raises(c5.InstallationError, match="IMMUTABLE_RELEASE_TREE_MISMATCH"):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_issuer_readiness_failure_restores_dropin_without_deleting_shared_tree(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_issuer_case(tmp_path)
|
||||
case.backend.fail_new = True
|
||||
with pytest.raises(c5.InstallationError, match="ROLLBACK_VERIFIED"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
assert case.layout.issuer_dropin.read_bytes() == case.old_dropin
|
||||
assert case.root.is_dir()
|
||||
assert case.new_module.read_bytes() == case.new_module_bytes
|
||||
assert case.backend.reloads == 2
|
||||
assert case.backend.restarts == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("attack", ["duplicate", "extra", "traversal", "symlink"])
|
||||
def test_tar_rejects_duplicate_extra_traversal_and_links(
|
||||
tmp_path: Path, attack: str
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
members = [
|
||||
("app/aios_chatgpt_bridge_v2_server.py", case.new_module_bytes, 0o644, "file"),
|
||||
("source-activation.json", case.activation_bytes, 0o644, "file"),
|
||||
("dropin.conf", case.new_dropin, 0o644, "file"),
|
||||
]
|
||||
if attack == "duplicate":
|
||||
members.append(members[0])
|
||||
elif attack == "extra":
|
||||
members.append(("extra.py", b"extra", 0o644, "file"))
|
||||
elif attack == "traversal":
|
||||
members[0] = ("../escape.py", case.new_module_bytes, 0o644, "file")
|
||||
else:
|
||||
members[0] = (members[0][0], b"", 0o644, "symlink")
|
||||
write_tar(case.artifact, members)
|
||||
with pytest.raises(c5.InstallationError):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_apply_and_same_contract_replay_verify_bytes_and_runtime(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "APPLIED"
|
||||
assert case.layout.dropin.read_bytes() == case.new_dropin
|
||||
assert case.backend.reloads == 1
|
||||
assert case.backend.restarts == 1
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "IDEMPOTENT_REPLAY"
|
||||
case.new_module.write_bytes(b"tampered")
|
||||
with pytest.raises(c5.InstallationError, match="INSTALLED_FILE_MISMATCH"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_future_bridge_contract_requires_governed_writes_readiness(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.payload["readiness"]["runtime_fields"].pop("governed_writes_enabled")
|
||||
resign(case)
|
||||
with pytest.raises(
|
||||
c5.InstallationError,
|
||||
match="BRIDGE_GOVERNED_WRITES_READINESS_REQUIRED",
|
||||
):
|
||||
case.installer.preflight(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_applied_issuer_rollback_restores_old_runtime_and_keeps_shared_tree(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_issuer_case(tmp_path)
|
||||
case.layout.issuer_dropin.unlink()
|
||||
case.payload["existing"]["dropin"] = {"exists": False, "sha256": None}
|
||||
resign(case)
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "APPLIED"
|
||||
|
||||
result = case.installer.rollback_applied(case.contract, case.artifact)
|
||||
|
||||
assert result["status"] == "ROLLED_BACK"
|
||||
assert not case.layout.issuer_dropin.exists()
|
||||
assert case.root.is_dir()
|
||||
assert case.new_module.read_bytes() == case.new_module_bytes
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
nonce = json.loads(case.installer._nonce_path(case.payload).read_text())
|
||||
assert state == nonce
|
||||
assert state["status"] == "ROLLED_BACK"
|
||||
assert state["detail"] == "applied_rollback_verified"
|
||||
assert case.installer.rollback_applied(case.contract, case.artifact)["status"] == (
|
||||
"IDEMPOTENT_ROLLBACK"
|
||||
)
|
||||
|
||||
|
||||
def test_applied_bridge_rollback_requires_restored_issuer_proof(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.layout.dropin.unlink()
|
||||
case.payload["existing"]["dropin"] = {"exists": False, "sha256": None}
|
||||
resign(case)
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "APPLIED"
|
||||
with pytest.raises(
|
||||
c5.InstallationError, match="ISSUER_ROLLBACK_DEPENDENCY_REQUIRED"
|
||||
):
|
||||
case.installer.rollback_applied(case.contract, case.artifact)
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
assert state["status"] == "APPLIED"
|
||||
|
||||
|
||||
def test_different_nonce_or_digest_conflicts_after_install(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
case.payload["nonce"] = "nonce-c5-installation-0002"
|
||||
resign(case)
|
||||
with pytest.raises(c5.InstallationError, match="INSTALL_REPLAY_CONFLICT"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_existing_release_directory_is_never_overwritten(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.root.mkdir()
|
||||
sentinel = case.root / "sentinel"
|
||||
sentinel.write_text("keep")
|
||||
with pytest.raises(c5.InstallationError, match="IMMUTABLE_RELEASE_ROOT_EXISTS"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
assert sentinel.read_text() == "keep"
|
||||
|
||||
|
||||
def test_failed_new_readiness_restores_restarts_and_verifies_old_runtime(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.backend.fail_new = True
|
||||
with pytest.raises(c5.InstallationError, match="ROLLBACK_VERIFIED"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
assert case.layout.dropin.read_bytes() == case.old_dropin
|
||||
assert case.backend.reloads == 2
|
||||
assert case.backend.restarts == 2
|
||||
assert not case.root.exists()
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
assert state["status"] == "ROLLED_BACK"
|
||||
with pytest.raises(c5.InstallationError, match="INSTALL_PRIOR_ATTEMPT_NOT_APPLIED"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
|
||||
|
||||
def test_unverifiable_rollback_is_durable_unknown(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.backend.fail_new = True
|
||||
case.backend.fail_old = True
|
||||
with pytest.raises(c5.InstallationOutcomeUnknown, match="INSTALLATION_OUTCOME_UNKNOWN"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
assert state["status"] == "UNKNOWN"
|
||||
assert case.layout.dropin.read_bytes() == case.old_dropin
|
||||
|
||||
|
||||
def test_unknown_reconciliation_proves_old_runtime_and_removes_only_verified_tree(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.backend.fail_new = True
|
||||
case.backend.fail_old = True
|
||||
with pytest.raises(c5.InstallationOutcomeUnknown):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
assert case.root.exists()
|
||||
reloads = case.backend.reloads
|
||||
restarts = case.backend.restarts
|
||||
|
||||
case.backend.fail_old = False
|
||||
result = case.installer.reconcile_unknown(case.contract, case.artifact)
|
||||
assert result["status"] == "ROLLED_BACK"
|
||||
assert not case.root.exists()
|
||||
assert case.backend.reloads == reloads
|
||||
assert case.backend.restarts == restarts
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
nonce = json.loads(case.installer._nonce_path(case.payload).read_text())
|
||||
assert state == nonce
|
||||
assert state["status"] == "ROLLED_BACK"
|
||||
assert state["detail"] == "rollback_runtime_and_bytes_reconciled"
|
||||
assert case.installer.reconcile_unknown(case.contract, case.artifact)["status"] == (
|
||||
"IDEMPOTENT_RECONCILIATION"
|
||||
)
|
||||
|
||||
|
||||
def test_unknown_reconciliation_refuses_tampered_failed_tree(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
case.backend.fail_new = True
|
||||
case.backend.fail_old = True
|
||||
with pytest.raises(c5.InstallationOutcomeUnknown):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
case.backend.fail_old = False
|
||||
case.new_module.write_bytes(b"tampered")
|
||||
|
||||
with pytest.raises(c5.InstallationError, match="INSTALLED_FILE_MISMATCH"):
|
||||
case.installer.reconcile_unknown(case.contract, case.artifact)
|
||||
assert case.root.exists()
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
assert state["status"] == "UNKNOWN"
|
||||
|
||||
|
||||
def test_readiness_retries_transport_only_and_never_contract_mismatch(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
case = make_case(tmp_path)
|
||||
transport = c5.InstallationError("STAGING_READINESS_HTTP_FAILED")
|
||||
case.backend.runtime_results = [transport, transport, dict(case.new_runtime)]
|
||||
assert case.installer.apply(case.contract, case.artifact)["status"] == "APPLIED"
|
||||
assert case.backend.runtime_calls == 3
|
||||
|
||||
before = case.backend.runtime_calls
|
||||
case.backend.runtime_results = [{"status": "bad"}, dict(case.new_runtime)]
|
||||
with pytest.raises(c5.InstallationError, match="STAGING_RUNTIME_CONTRACT_MISMATCH"):
|
||||
case.installer._verify_readiness(case.payload["readiness"], case.profile)
|
||||
assert case.backend.runtime_calls == before + 1
|
||||
|
||||
|
||||
def test_interruption_before_mutation_leaves_durable_unknown(tmp_path: Path) -> None:
|
||||
case = make_case(tmp_path)
|
||||
original = case.installer._write_state
|
||||
calls = 0
|
||||
|
||||
def interrupt_second_state_write(path, value, *, create):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls == 2:
|
||||
raise RuntimeError("simulated process interruption")
|
||||
return original(path, value, create=create)
|
||||
|
||||
case.installer._write_state = interrupt_second_state_write
|
||||
with pytest.raises(RuntimeError, match="simulated process interruption"):
|
||||
case.installer.apply(case.contract, case.artifact)
|
||||
state = json.loads(case.installer._state_path(case.payload).read_text())
|
||||
assert state["status"] == "UNKNOWN"
|
||||
assert state["detail"] == "mutation_may_start"
|
||||
assert not case.root.exists()
|
||||
|
||||
|
||||
class SigningRunner:
|
||||
def __init__(self, key: Ed25519PrivateKey) -> None:
|
||||
self.key = key
|
||||
self.calls = 0
|
||||
|
||||
def __call__(self, command, **kwargs):
|
||||
self.calls += 1
|
||||
namespace = command[command.index("--namespace") + 1]
|
||||
payload_path = Path(command[command.index("--input") + 1])
|
||||
signature_path = Path(command[command.index("--signature-out") + 1])
|
||||
key_id_path = Path(command[command.index("--key-id-out") + 1])
|
||||
signature_path.write_bytes(
|
||||
sign_sshsig(
|
||||
self.key, payload_path.read_bytes(), namespace=namespace
|
||||
).encode("utf-8")
|
||||
)
|
||||
key_id_path.write_bytes(
|
||||
(ssh_fingerprint(self.key.public_key()) + "\n").encode("utf-8")
|
||||
)
|
||||
return SimpleNamespace(returncode=0)
|
||||
|
||||
|
||||
def producer_case(tmp_path: Path, ttl: int = 100):
|
||||
key = Ed25519PrivateKey.generate()
|
||||
runner = SigningRunner(key)
|
||||
registry = tmp_path / "producer-registry.json"
|
||||
write_registry(registry, key)
|
||||
verifier = c5.RegistryOwnerVerifier(
|
||||
registry,
|
||||
owner_signer_id="local-owner-authority",
|
||||
require_root_owner=False,
|
||||
)
|
||||
signer = RegisteredOwnerSignerAdapter(
|
||||
"test-registered-owner-signer",
|
||||
key_id=ssh_fingerprint(key.public_key()),
|
||||
owner_verifier=verifier,
|
||||
require_root=False,
|
||||
test_mode=True,
|
||||
runner=runner,
|
||||
)
|
||||
root = c5.RELEASE_BASE / f"c5-source-{SUPPORT}"
|
||||
module = root / "app" / "aios_chatgpt_bridge_v2_server.py"
|
||||
activation = root / "source-activation.json"
|
||||
old_module = Path("/opt/aios/current/app/aios_chatgpt_bridge_v2_server.py")
|
||||
module_bytes = b"new\n"
|
||||
activation_bytes = b"{}\n"
|
||||
old_bytes = b"old\n"
|
||||
old_paths = ("/opt/aios/current", "/opt/aios/current/app")
|
||||
dropin = c5._dropin_bytes(root, old_paths)
|
||||
new_runtime = {
|
||||
"status": "active", "health": 200, "deployment_state": "STAGING",
|
||||
"repository": c5.REPOSITORY, "source_module_path": module.as_posix(),
|
||||
"source_module_sha256": sha(module_bytes),
|
||||
}
|
||||
old_runtime = {
|
||||
"status": "active", "health": 200, "deployment_state": "STAGING",
|
||||
"repository": c5.REPOSITORY,
|
||||
}
|
||||
request = c5.InstallationRequest(
|
||||
target=c5.BRIDGE_TARGET,
|
||||
contract_id="c5-producer-01",
|
||||
owner_signer_id="local-owner-authority",
|
||||
source_commit=SOURCE,
|
||||
support_commit=SUPPORT,
|
||||
files=(
|
||||
c5.FileRecord(**file_record(module.as_posix(), module_bytes, "release_file")),
|
||||
c5.FileRecord(**file_record(activation.as_posix(), activation_bytes, "release_file")),
|
||||
c5.FileRecord(**file_record(c5.DROPIN.as_posix(), dropin, "systemd_dropin")),
|
||||
),
|
||||
existing={
|
||||
"unit_active": True,
|
||||
"effective_config_sha256": sha(b"effective"),
|
||||
"dropin": {"exists": True, "sha256": sha(b"old-dropin")},
|
||||
"pythonpath": list(old_paths),
|
||||
"source_files": [file_record(old_module.as_posix(), old_bytes, "existing_source")],
|
||||
"activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
},
|
||||
readiness={
|
||||
"url": c5.RUNTIME_URL, "timeout_seconds": 3, "unit_active": True,
|
||||
"pythonpath": [(root / "app").as_posix(), *old_paths],
|
||||
"activation_file": (root / "source-activation.json").as_posix(),
|
||||
"principal_scope_config": None,
|
||||
"module_path": module.as_posix(), "module_sha256": sha(module_bytes),
|
||||
"runtime_fields": new_runtime,
|
||||
},
|
||||
rollback_readiness={
|
||||
"url": c5.RUNTIME_URL, "timeout_seconds": 3, "unit_active": True,
|
||||
"pythonpath": list(old_paths), "activation_file": None,
|
||||
"principal_scope_config": None,
|
||||
"module_path": old_module.as_posix(), "module_sha256": sha(old_bytes),
|
||||
"runtime_fields": old_runtime,
|
||||
},
|
||||
nonce="nonce-producer-contract-0001",
|
||||
ttl_seconds=ttl,
|
||||
)
|
||||
artifact = tmp_path / "producer.tar"
|
||||
write_tar(
|
||||
artifact,
|
||||
[
|
||||
("app/aios_chatgpt_bridge_v2_server.py", module_bytes, 0o644, "file"),
|
||||
("source-activation.json", activation_bytes, 0o644, "file"),
|
||||
("dropin.conf", dropin, 0o644, "file"),
|
||||
],
|
||||
)
|
||||
return request, artifact, registry, signer, runner
|
||||
|
||||
|
||||
def test_typed_producer_validates_then_signs_and_consumer_verifies(tmp_path: Path) -> None:
|
||||
request, artifact, registry, signer, runner = producer_case(tmp_path)
|
||||
outer = c5.issue_signed_contract(
|
||||
request,
|
||||
artifact,
|
||||
signer,
|
||||
registry,
|
||||
clock=lambda: NOW,
|
||||
require_root_owner=False,
|
||||
)
|
||||
assert runner.calls == 1
|
||||
contract = tmp_path / "issued.json"
|
||||
contract.write_text(json.dumps(outer))
|
||||
payload, _ = c5.verify_signed_contract(
|
||||
contract,
|
||||
registry,
|
||||
now=NOW,
|
||||
require_root_owner=False,
|
||||
)
|
||||
assert payload["owner_key_id"] == signer.fingerprint
|
||||
|
||||
|
||||
def test_typed_producer_rejects_before_private_signer_call(tmp_path: Path) -> None:
|
||||
request, artifact, registry, signer, runner = producer_case(tmp_path, ttl=901)
|
||||
with pytest.raises(c5.InstallationError, match="INSTALL_TTL_OR_NONCE_INVALID"):
|
||||
c5.issue_signed_contract(
|
||||
request,
|
||||
artifact,
|
||||
signer,
|
||||
registry,
|
||||
clock=lambda: NOW,
|
||||
require_root_owner=False,
|
||||
)
|
||||
assert runner.calls == 0
|
||||
|
||||
|
||||
def test_production_cli_has_no_test_backend_or_arbitrary_root() -> None:
|
||||
source = (
|
||||
Path(__file__).parents[1] / "scripts" / "c5_staging_installation.py"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "--test-backend" not in source
|
||||
assert "--release-root" not in source
|
||||
assert c5.DROPIN == Path("/etc/systemd/system") / f"{c5.UNIT}.d" / (
|
||||
"~" * 32 + "-c5-source.conf"
|
||||
)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Use the real signed builder, durable release service and approval verifier."""
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from governed_unattended_release import GovernedUnattendedReleaseRunner
|
||||
from silent_release_hardening import CapabilityAuthorizationError
|
||||
from test_silent_release_execution_readiness import (
|
||||
COMMIT, trusted_service, signed_shadow_approval,
|
||||
)
|
||||
|
||||
|
||||
def bind_simulated_source(service):
|
||||
service.source_admission_resolver = lambda source: {
|
||||
"admission_id": "simulated-source-admission", "source_commit": source,
|
||||
"tree_oid": "3" * 40, "version": "V2R2C5",
|
||||
}
|
||||
|
||||
|
||||
def test_real_release_service_and_signed_owner_broker_are_composable(tmp_path):
|
||||
service, signer = trusted_service(tmp_path)
|
||||
bind_simulated_source(service)
|
||||
runner = GovernedUnattendedReleaseRunner(
|
||||
admission=object(), environment_mode="AUTHORITY", release_service=service,
|
||||
owner_approval_broker=lambda envelope: signed_shadow_approval(
|
||||
service, signer, envelope["candidate_id"]
|
||||
).to_dict(),
|
||||
)
|
||||
result = runner.postpublication_rc(
|
||||
event_id="isolated-integration", source_commit=COMMIT,
|
||||
source_readback={"authenticated": True, "source_commit": COMMIT, "tree_oid": "3" * 40},
|
||||
)
|
||||
assert result["phase"] == "APPROVED", result
|
||||
assert service.get_release_saga(result["candidate_id"])["state"] == "APPROVED"
|
||||
assert service.execution_enabled is False
|
||||
assert runner.deploy(result["candidate_id"])["phase"] == "BLOCKED"
|
||||
|
||||
|
||||
def test_real_verifier_rejects_broker_source_substitution(tmp_path):
|
||||
service, signer = trusted_service(tmp_path)
|
||||
bind_simulated_source(service)
|
||||
|
||||
broker_calls = []
|
||||
|
||||
def broker(envelope):
|
||||
broker_calls.append(envelope["candidate_id"])
|
||||
approval = signed_shadow_approval(service, signer, envelope["candidate_id"]).to_dict()
|
||||
approval["source_commit"] = "f" * 40
|
||||
return approval
|
||||
|
||||
runner = GovernedUnattendedReleaseRunner(
|
||||
admission=object(), environment_mode="AUTHORITY", release_service=service,
|
||||
owner_approval_broker=broker,
|
||||
)
|
||||
result = runner.postpublication_rc(
|
||||
event_id="isolated-substitution", source_commit=COMMIT,
|
||||
source_readback={"authenticated": True, "source_commit": COMMIT, "tree_oid": "3" * 40},
|
||||
)
|
||||
assert result["phase"] == "BLOCKED"
|
||||
assert len(broker_calls) == 1
|
||||
assert not list(service.approvals_root.glob("*.json"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("damage", ["missing_verifier", "tampered_attestation"])
|
||||
def test_durable_acceptance_reverification_fails_closed(tmp_path, damage):
|
||||
service, _ = trusted_service(tmp_path)
|
||||
bind_simulated_source(service)
|
||||
created = service.create_release_intent({"event_id": "acceptance-recheck", "source_commit": COMMIT})
|
||||
candidate_id = created["candidate_id"]
|
||||
assert service.verify_release_acceptance(candidate_id)["status"] == "VERIFIED"
|
||||
if damage == "missing_verifier":
|
||||
service.builder_attestation_verifier = None
|
||||
else:
|
||||
path = service.acceptance_root / (candidate_id + ".json")
|
||||
record = json.loads(path.read_text())
|
||||
record["attestation"]["artifact_sha256"] = "f" * 64
|
||||
path.write_text(json.dumps(record))
|
||||
with pytest.raises(CapabilityAuthorizationError):
|
||||
service.verify_release_acceptance(candidate_id)
|
||||
@@ -13,8 +13,9 @@ fixed clients must satisfy.
|
||||
"""
|
||||
import re
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
|
||||
REPO = "C:/Users/ImRuY/aios-main-clone"
|
||||
REPO = str(Path(__file__).resolve().parents[1]) # repo root (was hardcoded dev path)
|
||||
ADMIN_HTML = f"{REPO}/active-runtime/app/static/admin-ai-evolution.html"
|
||||
GITOPS_HTML = f"{REPO}/overlays/user-control-center/portal/gitops-dashboard/index.html"
|
||||
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from governed_publication_client import ( # noqa: E402
|
||||
CommittedTreeSourceReader,
|
||||
GovernedPublicationClient,
|
||||
GovernedPublicationError,
|
||||
)
|
||||
from governed_unattended_release import SourcePublicationIntent # noqa: E402
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
TREE = "b" * 40
|
||||
BASE = "c" * 40
|
||||
DIGEST = "d" * 64
|
||||
|
||||
|
||||
def intent() -> SourcePublicationIntent:
|
||||
return SourcePublicationIntent(
|
||||
repository="aios/aios", source_commit=COMMIT, source_tree_oid=TREE,
|
||||
authority_base_oid=BASE, target_branch="agent/c5-publication",
|
||||
file_allowlist=("active-runtime/app/example.py", "tests/test_example.py"),
|
||||
requested_capabilities=("governed.write.files", "writer.lease"),
|
||||
risk="medium", execution_digest=DIGEST, generation=2,
|
||||
)
|
||||
|
||||
|
||||
def admission(i: SourcePublicationIntent) -> dict[str, object]:
|
||||
return {
|
||||
"task_id": i.task_id, "authority_base_oid": BASE,
|
||||
"target_branch": i.target_branch, "generation": i.generation,
|
||||
"execution_digest": DIGEST,
|
||||
}
|
||||
|
||||
|
||||
class Writer:
|
||||
def __init__(self, *, fail: BaseException | None = None):
|
||||
self.calls: list[tuple[str, object]] = []
|
||||
self.fail = fail
|
||||
self.result = None
|
||||
|
||||
def acquire(self, *, request, job_id):
|
||||
self.calls.append(("acquire", request))
|
||||
if self.fail:
|
||||
raise self.fail
|
||||
return {"session": "opaque"}
|
||||
|
||||
def commit_and_readback(self, *, session, request, files, expected_sha256, message):
|
||||
self.calls.append(("commit", (session, dict(files), dict(expected_sha256), message)))
|
||||
if self.fail:
|
||||
raise self.fail
|
||||
return {"branch_head": "e" * 40}
|
||||
|
||||
def submit_task_result(self, *, session, result, idempotency_key):
|
||||
self.calls.append(("result", (session, dict(result), idempotency_key)))
|
||||
if self.fail:
|
||||
raise self.fail
|
||||
self.result = dict(result)
|
||||
return {
|
||||
"task_id": result["task_id"], "status": "VERIFYING",
|
||||
"claimed_by": "agent/c5", "revision": 8,
|
||||
"result": dict(result),
|
||||
}
|
||||
|
||||
|
||||
def client(writer: Writer, files=None, readback=None, *, resolver=None, git=False) -> GovernedPublicationClient:
|
||||
i = intent()
|
||||
source = files or {p: p.encode() for p in i.file_allowlist}
|
||||
evidence = readback or {
|
||||
"authenticated": True, "task_id": i.task_id,
|
||||
"authority_base_oid": BASE, "target_branch": i.target_branch,
|
||||
"generation": i.generation, "execution_digest": DIGEST,
|
||||
"task_lease": {"task_id": i.task_id, "ref": "tasklease:1"},
|
||||
"task_principal": {"task_id": i.task_id, "ref": "principal:1"},
|
||||
"writer_lease": {"task_id": i.task_id, "ref": "writerlease:1"},
|
||||
"source_oid": COMMIT, "tree_oid": TREE, "commit_oid": COMMIT,
|
||||
"commit_receipt": {"commit_oid": COMMIT, "tree_oid": TREE},
|
||||
}
|
||||
kwargs = {"git_tree_file": (lambda *, tree_oid, path: source[path])}
|
||||
return GovernedPublicationClient(
|
||||
writer_executor=writer, **kwargs,
|
||||
admission_binding=resolver or (lambda **_kwargs: {"task_id": i.task_id, "authority_base_oid": BASE, "target_branch": i.target_branch, "generation": i.generation, "execution_digest": DIGEST, "job_id": "job-c5-1"}),
|
||||
authenticated_readback=lambda **_kwargs: evidence,
|
||||
subject="aios-autonomous-admin", manifest_id="manifest:c5",
|
||||
runtime_fingerprint="runtime:c5", process_start_identity="proc:c5",
|
||||
)
|
||||
|
||||
|
||||
def test_contract_uses_job_binding_and_immutable_bytes():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
result = client(writer).publish(i, admission(i))
|
||||
assert result["job_id"] == "job-c5-1"
|
||||
assert result["source_oid"] == COMMIT
|
||||
commit = writer.calls[1][1]
|
||||
assert commit[1]["active-runtime/app/example.py"] == b"active-runtime/app/example.py"
|
||||
assert all(isinstance(value, str) for value in commit[2].values())
|
||||
assert [call[0] for call in writer.calls] == ["acquire", "commit", "result"]
|
||||
assert result["task_result_submission"]["status"] == "VERIFYING"
|
||||
task_result = result["task_result"]
|
||||
assert task_result["candidate_source_oid"] == COMMIT
|
||||
assert task_result["published_source_oid"] == COMMIT
|
||||
assert task_result["source_tree_oid"] == TREE
|
||||
assert result["task_result_idempotency_key"].startswith(
|
||||
"governed-source-result-"
|
||||
)
|
||||
|
||||
|
||||
def test_live_commit_receipt_preserved_without_invented_tree_field():
|
||||
i = intent()
|
||||
evidence = client(Writer()).publish(i, admission(i))
|
||||
evidence["commit_receipt"] = {"commit_oid": COMMIT, "authority_oid": COMMIT}
|
||||
result = client(Writer(), readback=evidence).publish(i, admission(i))
|
||||
assert result["commit_receipt"] == evidence["commit_receipt"]
|
||||
assert "tree_oid" not in result["commit_receipt"]
|
||||
evidence["commit_receipt"]["tree_oid"] = "f" * 40
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_COMMIT_BINDING_MISMATCH"):
|
||||
client(Writer(), readback=evidence).publish(i, admission(i))
|
||||
|
||||
|
||||
def test_file_set_mismatch_fails_before_any_mutation():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
bad = {i.file_allowlist[0]: b"one"}
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_FILE_SET_MISMATCH"):
|
||||
client(writer, files=bad).publish(i, admission(i))
|
||||
assert writer.calls == []
|
||||
|
||||
|
||||
def test_workspace_source_reader_is_rejected():
|
||||
with pytest.raises(ValueError, match="workspace_source_reader_forbidden"):
|
||||
GovernedPublicationClient(
|
||||
writer_executor=Writer(), source_files=lambda _intent: {},
|
||||
admission_binding=lambda **_kwargs: {},
|
||||
authenticated_readback=lambda **_kwargs: {}, subject="s",
|
||||
manifest_id="m", runtime_fingerprint="r", process_start_identity="p",
|
||||
)
|
||||
|
||||
|
||||
def test_committed_tree_source_reader_binds_the_intent_tree():
|
||||
seen = []
|
||||
reader = CommittedTreeSourceReader(
|
||||
lambda *, tree_oid, path: seen.append((tree_oid, path)) or path.encode()
|
||||
)
|
||||
values = reader(intent())
|
||||
assert set(values) == set(intent().file_allowlist)
|
||||
assert {tree for tree, _path in seen} == {TREE}
|
||||
|
||||
|
||||
def test_transport_failure_is_unknown_and_never_retried():
|
||||
writer = Writer(fail=TimeoutError("secret transport detail"))
|
||||
i = intent()
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_EFFECT_UNKNOWN"):
|
||||
client(writer).publish(i, admission(i))
|
||||
assert [call[0] for call in writer.calls] == ["acquire"]
|
||||
|
||||
|
||||
def test_missing_authenticated_readback_fails_closed():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_AUTHENTICATED_READBACK_REQUIRED"):
|
||||
client(writer, readback={"authenticated": False}).publish(i, admission(i))
|
||||
|
||||
|
||||
def test_real_admission_receipt_has_no_caller_job_field():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
result = client(writer).publish(i, admission(i))
|
||||
assert result["job_id"] == "job-c5-1"
|
||||
|
||||
|
||||
def test_committed_tree_reader_is_used_and_commit_receipt_is_mandatory():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
result = client(writer, git=True).publish(i, admission(i))
|
||||
assert result["source_oid"] == COMMIT
|
||||
incomplete = {
|
||||
"authenticated": True, "task_id": i.task_id,
|
||||
"authority_base_oid": BASE, "target_branch": i.target_branch,
|
||||
"generation": i.generation, "execution_digest": DIGEST,
|
||||
"task_lease": {"task_id": i.task_id},
|
||||
"task_principal": {"task_id": i.task_id},
|
||||
"writer_lease": {"task_id": i.task_id},
|
||||
"source_oid": COMMIT, "tree_oid": TREE, "commit_oid": COMMIT,
|
||||
}
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_COMMIT_RECEIPT_REQUIRED"):
|
||||
client(writer, git=True, readback=incomplete).publish(i, admission(i))
|
||||
|
||||
|
||||
def test_readback_source_tree_binding_is_required():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
evidence = {
|
||||
"authenticated": True, "task_id": i.task_id,
|
||||
"authority_base_oid": BASE, "target_branch": i.target_branch,
|
||||
"generation": i.generation, "execution_digest": DIGEST,
|
||||
"task_lease": {"task_id": i.task_id},
|
||||
"task_principal": {"task_id": i.task_id},
|
||||
"writer_lease": {"task_id": i.task_id},
|
||||
"source_oid": COMMIT, "tree_oid": "f" * 40, "commit_oid": COMMIT,
|
||||
}
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_SOURCE_TREE_BINDING_MISMATCH"):
|
||||
client(writer, readback=evidence).publish(i, admission(i))
|
||||
|
||||
|
||||
def test_admission_binding_mismatch_precedes_writer_call():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
bad = {**admission(i), "external_job_id": "job-other" , "task_id": "other"}
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_ADMISSION_BINDING_MISMATCH"):
|
||||
client(writer).publish(i, bad)
|
||||
assert writer.calls == []
|
||||
|
||||
|
||||
def test_invalid_authenticated_readback_never_submits_task_result():
|
||||
writer = Writer()
|
||||
i = intent()
|
||||
with pytest.raises(
|
||||
GovernedPublicationError,
|
||||
match="PUBLICATION_SOURCE_TREE_BINDING_MISMATCH",
|
||||
):
|
||||
client(
|
||||
writer,
|
||||
readback={
|
||||
"authenticated": True, "task_id": i.task_id,
|
||||
"authority_base_oid": BASE, "target_branch": i.target_branch,
|
||||
"generation": i.generation, "execution_digest": DIGEST,
|
||||
"task_lease": {"task_id": i.task_id},
|
||||
"task_principal": {"task_id": i.task_id},
|
||||
"writer_lease": {"task_id": i.task_id},
|
||||
"source_oid": COMMIT, "tree_oid": "f" * 40,
|
||||
"commit_oid": COMMIT,
|
||||
},
|
||||
).publish(i, admission(i))
|
||||
assert [call[0] for call in writer.calls] == ["acquire", "commit"]
|
||||
|
||||
|
||||
def test_result_transport_failure_is_unknown_and_not_retried():
|
||||
class ResultFailureWriter(Writer):
|
||||
def submit_task_result(self, **kwargs):
|
||||
self.calls.append(("result", kwargs))
|
||||
raise TimeoutError("lost response")
|
||||
|
||||
writer = ResultFailureWriter()
|
||||
i = intent()
|
||||
with pytest.raises(GovernedPublicationError, match="PUBLICATION_EFFECT_UNKNOWN"):
|
||||
client(writer).publish(i, admission(i))
|
||||
assert [call[0] for call in writer.calls] == ["acquire", "commit", "result"]
|
||||
@@ -0,0 +1,271 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_single_host_authority import (
|
||||
LocalOwnerAuthority,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SingleHostAuthorityError,
|
||||
SingleHostOwnerPolicy,
|
||||
SourcePublicationPolicyRequest,
|
||||
ssh_fingerprint,
|
||||
ssh_public_line,
|
||||
)
|
||||
from governed_source_publication_policy import (
|
||||
ScopedSourcePublicationPolicyAdmission,
|
||||
SourcePublicationPolicyVerifier,
|
||||
require_source_publication_epoch_scope,
|
||||
)
|
||||
from aios_task_admission_bootstrap import TaskAdmissionPolicy
|
||||
|
||||
|
||||
class SourcePublicationPolicyTests(unittest.TestCase):
|
||||
now = 2_000_000_000
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
root = Path(self.tmp.name)
|
||||
self.key = Ed25519PrivateKey.generate()
|
||||
self.key_path = root / "owner.key"
|
||||
self.key_path.write_bytes(
|
||||
self.key.private_bytes(
|
||||
serialization.Encoding.PEM,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.NoEncryption(),
|
||||
)
|
||||
)
|
||||
os.chmod(self.key_path, 0o600)
|
||||
self.registry = root / "registry.json"
|
||||
self.registry.write_text(json.dumps({
|
||||
"schema": "aios.single-host-signer-registry.v1",
|
||||
"authority_generation": 1,
|
||||
"signers": [{
|
||||
"signer_id": "local-owner-authority", "role": "OWNER_AUTHORITY",
|
||||
"provider": "local_unix_service", "fingerprint": ssh_fingerprint(self.key.public_key()),
|
||||
"public_key": ssh_public_line(self.key.public_key(), "owner"),
|
||||
"socket": "/run/aios/owner.sock", "secret_ref": "aios-secret://owner", "status": "ACTIVE",
|
||||
"generation": 1,
|
||||
"parent_signer_id": "", "parent_fingerprint": "",
|
||||
}],
|
||||
}), encoding="utf-8")
|
||||
self.registry.chmod(0o600)
|
||||
self.policy = SingleHostOwnerPolicy(
|
||||
repository="aios/aios", trust_domain="owner",
|
||||
allowed_branch_prefixes=("agent/",),
|
||||
allowed_capabilities=frozenset({
|
||||
"agent-task:control", "governed.task.create", "governed.write.files",
|
||||
"task.principal.issue", "writer.lease",
|
||||
}),
|
||||
allowed_file_prefixes=("active-runtime/app/", "tests/"),
|
||||
risk_ceiling="high", production_scope="none", destructive_policy="forbidden",
|
||||
maximum_epoch_ttl_seconds=3600, maximum_machine_capability_ttl_seconds=3600,
|
||||
allowed_caller_uids=(0,),
|
||||
)
|
||||
self.owner = LocalOwnerAuthority(self.key_path, self.policy, require_root_owner=False, clock=lambda: self.now)
|
||||
self.request = SourcePublicationPolicyRequest(
|
||||
policy_id="C5_SOURCE_PUBLICATION_ONLY", repository="aios/aios",
|
||||
authority_base_oid="a" * 40, source_oid="b" * 40, source_tree_oid="c" * 40,
|
||||
target_agent="agent/c5", target_branch="agent/c5", generation=1,
|
||||
execution_digest="d" * 64, exact_files=("active-runtime/app/example.py",),
|
||||
nonce="nonce-c5-1", ttl_seconds=600,
|
||||
)
|
||||
self.artifact = self.owner.issue_source_publication_policy(self.request)
|
||||
self.verifier = SourcePublicationPolicyVerifier(
|
||||
RegistryOwnerApprovalVerifier(self.registry, require_root_owner=False),
|
||||
revocation_check=lambda _policy: True,
|
||||
clock=lambda: self.now,
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self.tmp.cleanup()
|
||||
|
||||
def test_real_signature_and_exact_scope(self) -> None:
|
||||
self.assertTrue(self.verifier.verify(self.artifact, self.request))
|
||||
admission = ScopedSourcePublicationPolicyAdmission(self.verifier)
|
||||
projected = admission.project_task_admission_policy(self.artifact, self.request)
|
||||
self.assertEqual(projected.max_risk, "engineering")
|
||||
self.assertEqual(projected.exact_files, self.request.exact_files)
|
||||
|
||||
def test_tamper_expiry_revoke_missing_and_scope_widening(self) -> None:
|
||||
changed = dict(self.artifact.payload)
|
||||
changed["target_branch"] = "agent/wider"
|
||||
tampered = type(self.artifact)(changed, self.artifact.canonical_payload, self.artifact.signature, self.artifact.key_id, self.artifact.namespace)
|
||||
self.assertFalse(self.verifier.verify(tampered, self.request))
|
||||
expired = SourcePublicationPolicyVerifier(
|
||||
RegistryOwnerApprovalVerifier(self.registry, require_root_owner=False),
|
||||
revocation_check=lambda _policy: True, clock=lambda: self.now + 601,
|
||||
)
|
||||
self.assertFalse(expired.verify(self.artifact, self.request))
|
||||
revoked = SourcePublicationPolicyVerifier(
|
||||
RegistryOwnerApprovalVerifier(self.registry, require_root_owner=False),
|
||||
revocation_check=lambda _policy: False, clock=lambda: self.now,
|
||||
)
|
||||
self.assertFalse(revoked.verify(self.artifact, self.request))
|
||||
missing = SourcePublicationPolicyVerifier(
|
||||
RegistryOwnerApprovalVerifier(self.registry, require_root_owner=False),
|
||||
revocation_check=None, clock=lambda: self.now,
|
||||
)
|
||||
self.assertFalse(missing.verify(self.artifact, self.request))
|
||||
widened = SourcePublicationPolicyRequest(**{**self.request.__dict__, "exact_files": ("outside/secret",)}) if hasattr(self.request, "__dict__") else SourcePublicationPolicyRequest(
|
||||
**{field: getattr(self.request, field) for field in self.request.__dataclass_fields__ if field != "exact_files"}, exact_files=("outside/secret",)
|
||||
)
|
||||
self.assertFalse(self.verifier.verify(self.artifact, widened))
|
||||
|
||||
def test_unknown_fields_and_medium_ceiling_reject_engineering(self) -> None:
|
||||
unknown = dict(self.artifact.payload)
|
||||
unknown["unexpected"] = True
|
||||
artifact = type(self.artifact)(unknown, self.artifact.canonical_payload, self.artifact.signature, self.artifact.key_id, self.artifact.namespace)
|
||||
self.assertFalse(self.verifier.verify(artifact, self.request))
|
||||
medium = SingleHostOwnerPolicy(
|
||||
**{**self.policy.to_dict(), "allowed_capabilities": self.policy.allowed_capabilities, "risk_ceiling": "medium", "schema": self.policy.schema}
|
||||
)
|
||||
with self.assertRaises(SingleHostAuthorityError):
|
||||
LocalOwnerAuthority(self.key_path, medium, require_root_owner=False, clock=lambda: self.now).issue_source_publication_policy(self.request)
|
||||
|
||||
narrow_caps = SingleHostOwnerPolicy(
|
||||
**{**self.policy.to_dict(), "allowed_capabilities": frozenset({"governed.task.create"}), "schema": self.policy.schema}
|
||||
)
|
||||
with self.assertRaises(SingleHostAuthorityError):
|
||||
LocalOwnerAuthority(self.key_path, narrow_caps, require_root_owner=False, clock=lambda: self.now).issue_source_publication_policy(self.request)
|
||||
|
||||
def test_owner_verifier_failure_is_fail_closed(self) -> None:
|
||||
class BrokenVerifier:
|
||||
def verify(self, **_kwargs):
|
||||
raise RuntimeError("verifier unavailable")
|
||||
|
||||
verifier = SourcePublicationPolicyVerifier(
|
||||
BrokenVerifier(), revocation_check=lambda _policy: True, clock=lambda: self.now,
|
||||
)
|
||||
self.assertFalse(verifier.verify(self.artifact, self.request))
|
||||
|
||||
def test_epoch_scope_requires_v2_and_exact_c5_provenance(self) -> None:
|
||||
matching = SimpleNamespace(
|
||||
schema="aios.owner-delegation-epoch.v2",
|
||||
production_scope="none", destructive_policy="forbidden",
|
||||
policy_digest=self.artifact.payload["policy_digest"],
|
||||
repository=self.artifact.payload["repository"],
|
||||
trust_domain=self.artifact.payload["trust_domain"],
|
||||
)
|
||||
require_source_publication_epoch_scope(matching, self.artifact.payload)
|
||||
with self.assertRaisesRegex(SingleHostAuthorityError, "SCHEMA_REQUIRED"):
|
||||
require_source_publication_epoch_scope(
|
||||
SimpleNamespace(schema="aios.owner-delegation-epoch.v1"),
|
||||
self.artifact.payload,
|
||||
)
|
||||
for field, value in (("production_scope", "staging"), ("destructive_policy", "rollback-only")):
|
||||
changed = dict(matching.__dict__, **{field: value})
|
||||
with self.assertRaisesRegex(SingleHostAuthorityError, "SCOPE_MISMATCH"):
|
||||
require_source_publication_epoch_scope(
|
||||
SimpleNamespace(**changed), self.artifact.payload,
|
||||
)
|
||||
|
||||
def test_admission_and_status_reject_v1_epoch_provenance(self) -> None:
|
||||
artifact = self.artifact
|
||||
request = self.request
|
||||
|
||||
class Bootstrap:
|
||||
def __init__(self):
|
||||
self.policy = TaskAdmissionPolicy(
|
||||
policy_digest=artifact.payload["policy_digest"],
|
||||
trust_domain=artifact.payload["trust_domain"],
|
||||
)
|
||||
self.envelope = SimpleNamespace(as_dict=lambda: {
|
||||
"repository": request.repository,
|
||||
"authority_base_oid": request.authority_base_oid,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"execution_digest": request.execution_digest,
|
||||
"risk": request.risk,
|
||||
"requested_capabilities": list(artifact.payload["capabilities"]),
|
||||
"file_allowlist": list(request.exact_files),
|
||||
"policy_digest": artifact.payload["policy_digest"],
|
||||
"trust_domain": artifact.payload["trust_domain"],
|
||||
"task_id": "task-c5",
|
||||
})
|
||||
|
||||
def _load_envelope(self, _envelope_id):
|
||||
return self.envelope
|
||||
|
||||
def _verify_epoch(self, **_kwargs):
|
||||
return SimpleNamespace(
|
||||
schema="aios.owner-delegation-epoch.v1",
|
||||
)
|
||||
|
||||
def authorize_and_admit(self, _raw):
|
||||
return {"admitted": True}
|
||||
|
||||
def status(self, _envelope_id):
|
||||
return {"status": "QUEUED"}
|
||||
|
||||
bootstrap = Bootstrap()
|
||||
scoped = ScopedSourcePublicationPolicyAdmission(
|
||||
self.verifier, bootstrap=bootstrap, artifact=artifact,
|
||||
request=request, expected_agent_id=request.target_agent,
|
||||
)
|
||||
raw = {"envelope_id": "env-c5", "authorization_kind": "delegation_epoch",
|
||||
"delegation_epoch_id": "epoch-c5"}
|
||||
with self.assertRaisesRegex(SingleHostAuthorityError, "SCHEMA_REQUIRED"):
|
||||
scoped.authorize_and_admit(raw)
|
||||
with self.assertRaisesRegex(SingleHostAuthorityError, "SCHEMA_REQUIRED"):
|
||||
scoped.status({"envelope_id": "env-c5", "delegation_epoch_id": "epoch-c5"})
|
||||
|
||||
def test_bootstrap_wrapper_rechecks_each_call_and_blocks_owner_bypass(self) -> None:
|
||||
class Bootstrap:
|
||||
def __init__(self) -> None:
|
||||
self.created = 0
|
||||
self.admitted = 0
|
||||
self.policy = TaskAdmissionPolicy(policy_digest=self_artifact.payload["policy_digest"], trust_domain="owner")
|
||||
self.envelope = SimpleNamespace(as_dict=lambda: {
|
||||
"repository": "aios/aios", "authority_base_oid": "a" * 40,
|
||||
"target_branch": "agent/c5", "generation": 1,
|
||||
"execution_digest": "d" * 64, "risk": "engineering",
|
||||
"requested_capabilities": list(self_artifact.payload["capabilities"]),
|
||||
"file_allowlist": list(self_artifact.payload["exact_files"]),
|
||||
})
|
||||
def create_envelope(self, raw):
|
||||
self.created += 1
|
||||
return {"ok": True}
|
||||
def _load_envelope(self, _envelope_id):
|
||||
return self.envelope
|
||||
def authorize_and_admit(self, raw):
|
||||
self.admitted += 1
|
||||
return {"admitted": True}
|
||||
|
||||
self_artifact = self.artifact
|
||||
bootstrap = Bootstrap()
|
||||
scoped = ScopedSourcePublicationPolicyAdmission(
|
||||
self.verifier, bootstrap=bootstrap, artifact=self.artifact, request=self.request,
|
||||
expected_agent_id="agent/c5",
|
||||
)
|
||||
raw = {
|
||||
"idempotency_key": "idem-c5", "task_id": "task-c5", "repository": "aios/aios",
|
||||
"authority_base_oid": "a" * 40, "target_branch": "agent/c5", "generation": 1,
|
||||
"execution_digest": "d" * 64, "risk": "engineering",
|
||||
"requested_capabilities": list(self.artifact.payload["capabilities"]),
|
||||
"file_allowlist": list(self.artifact.payload["exact_files"]),
|
||||
}
|
||||
self.assertEqual(scoped.create_envelope(raw), {"ok": True})
|
||||
self.assertEqual(bootstrap.created, 1)
|
||||
self.assertTrue(scoped.authorize(self.artifact, self.request))
|
||||
agent_mismatch = ScopedSourcePublicationPolicyAdmission(
|
||||
self.verifier, bootstrap=bootstrap, artifact=self.artifact,
|
||||
request=self.request, expected_agent_id="agent/other",
|
||||
)
|
||||
self.assertFalse(agent_mismatch.authorize(self.artifact, self.request))
|
||||
widened = dict(raw, target_branch="agent/other")
|
||||
with self.assertRaises(SingleHostAuthorityError):
|
||||
scoped.create_envelope(widened)
|
||||
with self.assertRaises(SingleHostAuthorityError):
|
||||
scoped.authorize_and_admit({"envelope_id": "e", "authorization_kind": "owner_signature"})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,268 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
||||
|
||||
from governed_unattended_release import ( # noqa: E402
|
||||
GovernedUnattendedReleaseRunner,
|
||||
SourcePublicationIntent,
|
||||
UnattendedReleaseError,
|
||||
)
|
||||
|
||||
|
||||
COMMIT = "a" * 40
|
||||
TREE = "b" * 40
|
||||
BASE = "c" * 40
|
||||
DIGEST = "d" * 64
|
||||
|
||||
|
||||
def receipt():
|
||||
request = intent()
|
||||
return {"schema": "aios.task-admission-receipt.v1", "task_id": request.task_id,
|
||||
"authority_base_oid": BASE, "target_branch": request.target_branch,
|
||||
"generation": 1, "execution_digest": DIGEST, "task_state": "QUEUED",
|
||||
"envelope_id": "env-1", "grant_id": "grant-1"}
|
||||
|
||||
|
||||
def intent(**changes):
|
||||
value = dict(
|
||||
repository="aios/aios",
|
||||
source_commit=COMMIT,
|
||||
source_tree_oid=TREE,
|
||||
authority_base_oid=BASE,
|
||||
target_branch="agent/c5-unattended-runner-20260926",
|
||||
file_allowlist=("active-runtime/app/foo.py", "tests/test_foo.py"),
|
||||
requested_capabilities=("release.candidate", "source.publish"),
|
||||
risk="low",
|
||||
execution_digest=DIGEST,
|
||||
event_id="release-event-1",
|
||||
version="v2r2c5",
|
||||
)
|
||||
value.update(changes)
|
||||
return SourcePublicationIntent(**value)
|
||||
|
||||
|
||||
def test_existing_plan_task_and_idempotency_identity_are_preserved():
|
||||
value = intent(admission_task_id="TASK-V2R2C5-SOURCE-PUBLICATION-20260926-R1",
|
||||
admission_idempotency_key="c5-source-publication-20260926-r1-1ba534d551991e8c9901")
|
||||
assert value.admission_request()["task_id"] == value.admission_task_id
|
||||
assert value.admission_request()["idempotency_key"] == value.admission_idempotency_key
|
||||
assert SourcePublicationIntent(**value.as_dict()).admission_request() == value.admission_request()
|
||||
with pytest.raises(UnattendedReleaseError, match="INCOMPLETE_ADMISSION_IDENTITY"):
|
||||
intent(admission_task_id=value.admission_task_id)
|
||||
|
||||
|
||||
class Admission:
|
||||
def __init__(self, create=None, authorize=None, status=None):
|
||||
self.create_calls = self.authorize_calls = self.status_calls = 0
|
||||
self.create_error = create
|
||||
self.authorize_error = authorize
|
||||
self.status_value = status or {"status": "ADMITTED", "task": receipt()}
|
||||
|
||||
def create_envelope(self, raw):
|
||||
self.create_calls += 1
|
||||
if self.create_error:
|
||||
raise self.create_error
|
||||
return {"envelope": {"envelope_id": "env-1"}, "request": raw}
|
||||
|
||||
def authorize_and_admit(self, raw):
|
||||
self.authorize_calls += 1
|
||||
if self.authorize_error:
|
||||
raise self.authorize_error
|
||||
return receipt()
|
||||
|
||||
def status(self, envelope_id):
|
||||
self.status_calls += 1
|
||||
return self.status_value
|
||||
|
||||
|
||||
class Failure(RuntimeError):
|
||||
def __init__(self, code, status_code=None):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
def runner(admission, **kwargs):
|
||||
return GovernedUnattendedReleaseRunner(
|
||||
admission=admission,
|
||||
environment_mode=kwargs.pop("environment_mode", "AUTHORITY"),
|
||||
max_attempts=3,
|
||||
deadline_seconds=10,
|
||||
sleep=lambda _seconds: None,
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_intake_is_immutable_and_deterministic():
|
||||
first = intent(file_allowlist=("tests/test_foo.py", "active-runtime/app/foo.py"), requested_capabilities=("source.publish", "release.candidate"))
|
||||
second = intent()
|
||||
assert first.task_id == second.task_id
|
||||
assert first.idempotency_key == second.idempotency_key
|
||||
with pytest.raises(Exception):
|
||||
first.file_allowlist += ("x",)
|
||||
|
||||
|
||||
def test_admission_reconciles_server_error_without_duplicate_mutation():
|
||||
admission = Admission(authorize=Failure("TEMPORARY_FAILURE", 503))
|
||||
result = runner(admission).admit(intent(), {"authorization_kind": "delegation_epoch", "delegation_epoch_id": "epoch-1"})
|
||||
assert result["phase"] == "RECONCILED"
|
||||
assert admission.create_calls == 1
|
||||
assert admission.authorize_calls == 1
|
||||
assert admission.status_calls == 1
|
||||
|
||||
|
||||
def test_admission_does_not_retry_forbidden():
|
||||
admission = Admission(create=Failure("FORBIDDEN", 403))
|
||||
result = runner(admission).admit(intent(), {"authorization_kind": "delegation_epoch", "delegation_epoch_id": "epoch-1"})
|
||||
assert result["phase"] == "BLOCKED"
|
||||
assert admission.create_calls == 1
|
||||
|
||||
|
||||
def test_unknown_mutation_outcome_reconciles_before_resubmit():
|
||||
admission = Admission(authorize=Failure("CONNECTION_LOST"))
|
||||
result = runner(admission).admit(intent(), {"authorization_kind": "delegation_epoch", "delegation_epoch_id": "epoch-1"})
|
||||
assert result["phase"] == "RECONCILED"
|
||||
assert admission.authorize_calls == 1
|
||||
assert admission.status_calls == 1
|
||||
|
||||
|
||||
def test_publication_requires_full_lease_chain_and_exact_rc_binding():
|
||||
class Adapter:
|
||||
def publish(self, _intent, _receipt):
|
||||
return {"task_lease": {"task_id": _receipt_task}, "task_principal": {"task_id": _receipt_task}, "writer_lease": {"task_id": _receipt_task},
|
||||
"commit_receipt": {"task_id": _receipt_task, "commit_oid": "e" * 40, "tree_oid": TREE,
|
||||
"base_oid": BASE, "target_branch": _receipt_branch, "execution_digest": DIGEST},
|
||||
"source_oid": "e" * 40, "tree_oid": TREE, "authenticated": True, "task_id": _receipt_task,
|
||||
"authority_base_oid": BASE, "target_branch": _receipt_branch, "execution_digest": DIGEST, "generation": 1}
|
||||
|
||||
_receipt_task = intent().task_id
|
||||
_receipt_branch = intent().target_branch
|
||||
admitted = receipt()
|
||||
result = runner(Admission(), publication_adapter=Adapter()).publish(intent(), admitted)
|
||||
assert result["phase"] == "PUBLISHED"
|
||||
|
||||
class LiveReceipt(Adapter):
|
||||
def publish(self, _intent, _receipt):
|
||||
value = super().publish(_intent, _receipt)
|
||||
value["commit_receipt"] = {"commit_oid": "e" * 40, "authority_oid": "e" * 40,
|
||||
"git_remote_oid": "e" * 40, "api_branch_oid": "e" * 40,
|
||||
"remote_verified": True, "commit_visible": True}
|
||||
return value
|
||||
|
||||
result = runner(Admission(), publication_adapter=LiveReceipt()).publish(intent(), admitted)
|
||||
assert result["phase"] == "PUBLISHED"
|
||||
assert "tree_oid" not in result["readback"]["commit_receipt"]
|
||||
|
||||
class WrongTree(LiveReceipt):
|
||||
def publish(self, _intent, _receipt):
|
||||
value = super().publish(_intent, _receipt)
|
||||
value["tree_oid"] = "f" * 40
|
||||
return value
|
||||
|
||||
assert runner(Admission(), publication_adapter=WrongTree()).publish(intent(), admitted)["phase"] == "BLOCKED"
|
||||
|
||||
class Missing:
|
||||
def publish(self, _intent, _receipt):
|
||||
return {"source_oid": COMMIT, "tree_oid": TREE}
|
||||
|
||||
assert runner(Admission(), publication_adapter=Missing()).publish(intent(), admitted)["error"] == "PUBLICATION_LEASE_CHAIN_MISSING"
|
||||
|
||||
|
||||
def test_unknown_pending_is_not_success_and_does_not_retry():
|
||||
admission = Admission(authorize=TimeoutError(), status={"status": "PENDING_AUTHORIZATION", "task": None})
|
||||
result = runner(admission).admit(intent(), {"authorization_kind": "delegation_epoch", "delegation_epoch_id": "epoch-1"})
|
||||
assert result["phase"] == "RECONCILIATION_REQUIRED"
|
||||
assert admission.authorize_calls == 1
|
||||
|
||||
|
||||
def test_engineering_and_default_simulation_are_explicit():
|
||||
assert intent(risk="engineering").admission_request()["risk"] == "engineering"
|
||||
admission = Admission()
|
||||
r = runner(admission, environment_mode="SIMULATION")
|
||||
assert r.admit(intent(), {})["phase"] == "BLOCKED"
|
||||
admission.simulation_only = True
|
||||
assert r.admit(intent(), {})["mode"] == "SIMULATION"
|
||||
assert r.publish(intent(), receipt())["phase"] == "BLOCKED"
|
||||
|
||||
|
||||
def test_deadline_prevents_call_and_exception_message_is_not_exported():
|
||||
ticks = iter([0, 11])
|
||||
admission = Admission()
|
||||
assert runner(admission, clock=lambda: next(ticks)).admit(intent(), {})["phase"] == "BLOCKED"
|
||||
assert admission.create_calls == 0
|
||||
error = runner(Admission(create=RuntimeError("Bearer private-test-material"))).admit(intent(), {})
|
||||
assert "private-test-material" not in str(error)
|
||||
|
||||
|
||||
def test_pre_envelope_transient_failure_retries_same_identity():
|
||||
class Flaky(Admission):
|
||||
def create_envelope(self, raw):
|
||||
if self.create_calls == 0:
|
||||
self.create_calls += 1
|
||||
self.first_raw = raw
|
||||
raise Failure("UNAVAILABLE", 503)
|
||||
assert raw == self.first_raw
|
||||
return super().create_envelope(raw)
|
||||
admission = Flaky()
|
||||
assert runner(admission).admit(intent(), {})["phase"] == "ADMITTED"
|
||||
assert admission.create_calls == 2
|
||||
assert admission.authorize_calls == 1
|
||||
|
||||
|
||||
def test_rc_missing_approval_backend_is_blocked_and_preparation_cannot_deploy():
|
||||
class Release:
|
||||
def create_release_intent(self, payload):
|
||||
return {"candidate_id": "rc-1", "payload": payload}
|
||||
|
||||
def get_approval_envelope(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "status": "READY_FOR_OWNER_SIGNATURE"}
|
||||
|
||||
def get_release_candidate(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "source_commit": COMMIT}
|
||||
|
||||
def get_release_plan(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "deployment": {"source_commit": COMMIT, "tree_oid": TREE}}
|
||||
|
||||
def get_acceptance(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "status": "ACCEPTED"}
|
||||
|
||||
service = Release()
|
||||
r = runner(Admission(), release_service=service)
|
||||
assert r.postpublication_rc(event_id="release-event-1", source_commit=COMMIT, source_readback={"authenticated": True, "source_commit": COMMIT, "tree_oid": TREE})["error"] == "AUTHENTICATED_SOURCE_ADMISSION_REQUIRED"
|
||||
assert r.deploy("rc-1")["error"] == "DEPLOYMENT_NOT_ENABLED"
|
||||
|
||||
|
||||
def test_rc_cannot_replace_authenticated_source_with_caller_flag():
|
||||
class Release:
|
||||
def __init__(self):
|
||||
self.approval = None
|
||||
|
||||
def create_release_intent(self, payload):
|
||||
return {"candidate_id": "rc-1", "payload": payload}
|
||||
|
||||
def get_approval_envelope(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "candidate_fingerprint": "fp", "status": "READY_FOR_OWNER_SIGNATURE"}
|
||||
|
||||
def get_release_candidate(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "source_commit": COMMIT}
|
||||
|
||||
def get_release_plan(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "deployment": {"source_commit": COMMIT, "tree_oid": TREE}}
|
||||
|
||||
def get_acceptance(self, candidate_id):
|
||||
return {"candidate_id": candidate_id, "status": "ACCEPTED"}
|
||||
|
||||
def submit_owner_approval(self, payload):
|
||||
self.approval = payload
|
||||
return {"status": "VERIFIED_AND_STORED"}
|
||||
|
||||
service = Release()
|
||||
result = runner(Admission(), release_service=service, owner_approval_broker=lambda envelope: {"candidate_id": envelope["candidate_id"], "signature": "signed-by-owner"}).postpublication_rc(event_id="release-event-1", source_commit=COMMIT, source_readback={"authenticated": True, "source_commit": COMMIT, "tree_oid": TREE})
|
||||
assert result["phase"] == "BLOCKED"
|
||||
assert service.approval is None
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
from aios_single_host_authority import (
|
||||
LocalOwnerAuthority,
|
||||
OwnerEpochRequest,
|
||||
SingleHostOwnerPolicy,
|
||||
sign_sshsig,
|
||||
ssh_fingerprint,
|
||||
verify_sshsig,
|
||||
)
|
||||
from registered_owner_signer_adapter import (
|
||||
OWNER_NAMESPACE,
|
||||
RegisteredOwnerSignerAdapter,
|
||||
RegisteredOwnerSignerError,
|
||||
)
|
||||
|
||||
|
||||
class RegisteredOwnerSignerTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.key = Ed25519PrivateKey.generate()
|
||||
self.key_id = ssh_fingerprint(self.key.public_key())
|
||||
self.payloads: list[bytes] = []
|
||||
class Verifier:
|
||||
def verify(inner, *, namespace, payload, signature, key_id):
|
||||
return key_id == self.key_id and verify_sshsig(
|
||||
self.key.public_key(), payload, signature,
|
||||
namespace=namespace, expected_key_id=key_id,
|
||||
)
|
||||
self.owner_verifier = Verifier()
|
||||
|
||||
def runner(argv, **_kwargs):
|
||||
input_path = Path(argv[argv.index("--input") + 1])
|
||||
signature_path = Path(argv[argv.index("--signature-out") + 1])
|
||||
key_id_path = Path(argv[argv.index("--key-id-out") + 1])
|
||||
payload = input_path.read_bytes()
|
||||
self.payloads.append(payload)
|
||||
signature_path.write_bytes(sign_sshsig(self.key, payload, namespace=OWNER_NAMESPACE).encode())
|
||||
key_id_path.write_bytes((self.key_id + "\n").encode())
|
||||
return type("Result", (), {"returncode": 0})()
|
||||
|
||||
self.adapter = RegisteredOwnerSignerAdapter(
|
||||
command="registered-signer-test", key_id=self.key_id,
|
||||
owner_verifier=self.owner_verifier, require_root=False,
|
||||
test_mode=True, runner=runner,
|
||||
)
|
||||
|
||||
def policy(self) -> SingleHostOwnerPolicy:
|
||||
return SingleHostOwnerPolicy(
|
||||
repository="aios/aios", trust_domain="owner",
|
||||
allowed_branch_prefixes=("agent/",),
|
||||
allowed_capabilities=frozenset({"agent-task:control"}),
|
||||
allowed_file_prefixes=("active-runtime/app/",), risk_ceiling="high",
|
||||
production_scope="none", destructive_policy="forbidden",
|
||||
maximum_epoch_ttl_seconds=3600,
|
||||
maximum_machine_capability_ttl_seconds=3600,
|
||||
allowed_caller_uids=(0,),
|
||||
)
|
||||
|
||||
def test_typed_authority_uses_registered_signer_and_preserves_payload(self) -> None:
|
||||
authority = LocalOwnerAuthority(
|
||||
None, self.policy(), require_root_owner=False,
|
||||
clock=lambda: 2_000_000_000, registered_signer=self.adapter,
|
||||
)
|
||||
request = OwnerEpochRequest(
|
||||
epoch_id="epoch-registered", allowed_branch_prefixes=("agent/c5/",),
|
||||
allowed_capabilities=frozenset({"agent-task:control"}),
|
||||
allowed_file_prefixes=("active-runtime/app/example.py",),
|
||||
risk_ceiling="high", business_goal="typed test",
|
||||
production_scope="none", destructive_policy="forbidden", ttl_seconds=600,
|
||||
)
|
||||
artifact = authority.issue_epoch(request)
|
||||
self.assertEqual(artifact.key_id, self.key_id)
|
||||
self.assertEqual(artifact.canonical_payload, self.payloads[0])
|
||||
self.assertEqual(artifact.signature.splitlines()[0], "-----BEGIN SSH SIGNATURE-----")
|
||||
|
||||
def test_adapter_has_no_public_raw_sign_method_and_rejects_wrong_outputs(self) -> None:
|
||||
self.assertFalse(hasattr(self.adapter, "sign"))
|
||||
with self.assertRaises(RegisteredOwnerSignerError):
|
||||
self.adapter._sign_canonical_payload("wrong-namespace", b"{}")
|
||||
|
||||
def bad_runner(argv, **_kwargs):
|
||||
Path(argv[argv.index("--signature-out") + 1]).write_bytes(b"-----BEGIN SSH SIGNATURE-----\n")
|
||||
Path(argv[argv.index("--key-id-out") + 1]).write_bytes(b"wrong\n")
|
||||
return type("Result", (), {"returncode": 0})()
|
||||
|
||||
bad = RegisteredOwnerSignerAdapter(
|
||||
command="registered-signer-test", key_id=self.key_id,
|
||||
owner_verifier=self.owner_verifier, require_root=False,
|
||||
test_mode=True, runner=bad_runner,
|
||||
)
|
||||
with self.assertRaises(RegisteredOwnerSignerError):
|
||||
bad._sign_canonical_payload(OWNER_NAMESPACE, b"{}")
|
||||
|
||||
with self.assertRaises(RegisteredOwnerSignerError):
|
||||
RegisteredOwnerSignerAdapter(
|
||||
command="relative-signer", key_id=self.key_id,
|
||||
owner_verifier=self.owner_verifier, require_root=False,
|
||||
)
|
||||
|
||||
def test_typed_policy_rejection_does_not_call_runner(self) -> None:
|
||||
calls = 0
|
||||
def counting_runner(*_args, **_kwargs):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
raise AssertionError("runner must not be called")
|
||||
adapter = RegisteredOwnerSignerAdapter(
|
||||
command="registered-signer-test", key_id=self.key_id,
|
||||
owner_verifier=self.owner_verifier, require_root=False,
|
||||
test_mode=True, runner=counting_runner,
|
||||
)
|
||||
authority = LocalOwnerAuthority(
|
||||
None, self.policy(), require_root_owner=False,
|
||||
clock=lambda: 2_000_000_000, registered_signer=adapter,
|
||||
)
|
||||
request = OwnerEpochRequest(
|
||||
epoch_id="epoch-widened", allowed_branch_prefixes=("agent/evil",),
|
||||
allowed_capabilities=frozenset({"writer.lease"}),
|
||||
allowed_file_prefixes=("active-runtime/app/",), risk_ceiling="high",
|
||||
business_goal="typed test", production_scope="none",
|
||||
destructive_policy="forbidden", ttl_seconds=600,
|
||||
)
|
||||
with self.assertRaises(Exception):
|
||||
authority.issue_epoch(request)
|
||||
self.assertEqual(calls, 0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,117 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_task_admission_bootstrap import TaskAdmissionError
|
||||
from aios_task_admission_live_adapters import TaskAdmissionRuntimeBundle
|
||||
from source_publication_runtime import AdmissionRuntimeRouter, ScopedPrincipalProvider, expired_admission_runtime, ExistingPrincipalPolicyFence
|
||||
|
||||
|
||||
def router_fixture():
|
||||
names = ("getTaskAdmissionEnvelope", "submitTaskAdmissionAuthorization", "getTaskAdmissionStatus")
|
||||
store = Mock()
|
||||
store.get_envelope.return_value = None
|
||||
store.get_grant_by_task_id.return_value = None
|
||||
bootstrap = SimpleNamespace(store=store, _load_envelope=Mock())
|
||||
guard = SimpleNamespace(bootstrap=bootstrap, artifact=SimpleNamespace(payload={"target_branch": "agent/c5"}))
|
||||
ordinary = TaskAdmissionRuntimeBundle({op: Mock(return_value="ordinary") for op in names}, Mock())
|
||||
source = TaskAdmissionRuntimeBundle({op: Mock(return_value="source") for op in names}, Mock(), service=guard)
|
||||
return AdmissionRuntimeRouter(ordinary, source), ordinary, source, bootstrap
|
||||
|
||||
|
||||
def test_exact_branch_is_routed_without_changing_ordinary_policy():
|
||||
router, ordinary, source, _ = router_fixture()
|
||||
route = router.handlers()["getTaskAdmissionEnvelope"]
|
||||
assert route({"target_branch": "agent/ordinary", "risk": "engineering"}) == "ordinary"
|
||||
assert route({"target_branch": "agent/c5", "risk": "engineering"}) == "source"
|
||||
ordinary.handlers["getTaskAdmissionEnvelope"].assert_called_once()
|
||||
source.handlers["getTaskAdmissionEnvelope"].assert_called_once()
|
||||
|
||||
|
||||
def test_source_denial_does_not_fall_through_to_ordinary():
|
||||
router, ordinary, source, _ = router_fixture()
|
||||
source.handlers["getTaskAdmissionEnvelope"].side_effect = TaskAdmissionError("REVOKED", 403)
|
||||
with pytest.raises(TaskAdmissionError, match="REVOKED"):
|
||||
router.handlers()["getTaskAdmissionEnvelope"]({"target_branch": "agent/c5"})
|
||||
ordinary.handlers["getTaskAdmissionEnvelope"].assert_not_called()
|
||||
|
||||
|
||||
def test_authorization_routes_by_durable_envelope_not_caller_epoch():
|
||||
router, ordinary, source, bootstrap = router_fixture()
|
||||
bootstrap.store.get_envelope.return_value = {"present": True}
|
||||
bootstrap._load_envelope.return_value = SimpleNamespace(target_branch="agent/c5")
|
||||
request = {"envelope_id": "env-1", "delegation_epoch_id": "attacker-epoch"}
|
||||
assert router.handlers()["submitTaskAdmissionAuthorization"](request) == "source"
|
||||
ordinary.handlers["submitTaskAdmissionAuthorization"].assert_not_called()
|
||||
|
||||
|
||||
def test_revoked_source_policy_fences_already_admitted_principal():
|
||||
guarded = Mock()
|
||||
guarded.status.side_effect = TaskAdmissionError("POLICY_REVOKED", 403)
|
||||
delegate = Mock(epoch_id="epoch-1")
|
||||
delegate.store.get_grant_by_task_id.return_value = SimpleNamespace(
|
||||
parent_epoch_id="epoch-1", envelope_id="env-1")
|
||||
provider = ScopedPrincipalProvider(guarded, delegate)
|
||||
with pytest.raises(TaskAdmissionError, match="POLICY_REVOKED"):
|
||||
provider({"operation": "commitFilesGoverned"}, {"task_id": "task-1"})
|
||||
delegate.assert_not_called()
|
||||
|
||||
|
||||
def test_principal_router_uses_durable_task_scope():
|
||||
router, ordinary, source, bootstrap = router_fixture()
|
||||
bootstrap.store.get_grant_by_task_id.return_value = SimpleNamespace(target_branch="agent/c5")
|
||||
transport = {"operation": "acquireWriterLease"}
|
||||
request = {"task_id": "task-1", "target_branch": "agent/ordinary"}
|
||||
router(transport, request)
|
||||
source.principal_verifier.assert_called_once_with(transport, request)
|
||||
ordinary.principal_verifier.assert_not_called()
|
||||
|
||||
|
||||
def test_expired_ordinary_epoch_remains_closed():
|
||||
runtime = expired_admission_runtime()
|
||||
for handler in runtime.handlers.values():
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_EXPIRED"):
|
||||
handler({})
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_EXPIRED"):
|
||||
runtime.principal_verifier({}, {})
|
||||
assert runtime.principal_verifier.readiness()["ready"] is False
|
||||
|
||||
|
||||
def test_existing_signed_principal_verifier_is_preserved_and_policy_fenced():
|
||||
_, _, source, bootstrap = router_fixture()
|
||||
source.service.status = Mock()
|
||||
bootstrap.store.get_grant_by_task_id.return_value = SimpleNamespace(
|
||||
target_branch="agent/c5", envelope_id="env", parent_epoch_id="epoch")
|
||||
original = Mock(side_effect=TaskAdmissionError("SIGNED_PRINCIPAL_REQUIRED", 403))
|
||||
fenced = ExistingPrincipalPolicyFence(original, source)
|
||||
with pytest.raises(TaskAdmissionError, match="SIGNED_PRINCIPAL_REQUIRED"):
|
||||
fenced({}, {"task_id": "c5-task"})
|
||||
source.service.status.assert_called_once()
|
||||
original.assert_called_once()
|
||||
original.reset_mock()
|
||||
source.service.status.side_effect = TaskAdmissionError("SOURCE_POLICY_REVOKED", 403)
|
||||
with pytest.raises(TaskAdmissionError, match="SOURCE_POLICY_REVOKED"):
|
||||
fenced({}, {"task_id": "c5-task"})
|
||||
original.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("files", [
|
||||
{"a.py": "dGFtcGVyZWQ="},
|
||||
{"a.py": "eA==", "extra.py": "eA=="},
|
||||
[{"path": "a.py", "content_base64": "eA=="}, {"path": "a.py", "content_base64": "eA=="}],
|
||||
])
|
||||
def test_source_commit_rejects_content_drift_before_existing_verifier(files):
|
||||
import hashlib
|
||||
_, _, source, bootstrap = router_fixture()
|
||||
source.service.status = Mock()
|
||||
source.service.expected_file_sha256 = {"a.py": hashlib.sha256(b"x").hexdigest()}
|
||||
bootstrap.store.get_grant_by_task_id.return_value = SimpleNamespace(
|
||||
target_branch="agent/c5", envelope_id="env", parent_epoch_id="epoch")
|
||||
original = Mock()
|
||||
fenced = ExistingPrincipalPolicyFence(original, source)
|
||||
with pytest.raises(TaskAdmissionError, match="SOURCE_COMMIT"):
|
||||
fenced({"operation": "commitFilesGoverned"}, {"task_id": "c5-task", "files": files})
|
||||
original.assert_not_called()
|
||||
fenced({"operation": "commitFilesGoverned"}, {"task_id": "c5-task", "files": {"a.py": "eA=="}})
|
||||
original.assert_called_once()
|
||||
@@ -0,0 +1,174 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_task_admission_bootstrap import (
|
||||
SCHEMA_EPOCH_V2,
|
||||
TaskAdmissionError,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
from aios_task_admission_live_adapters import SignedEpochDirectoryProvider
|
||||
|
||||
|
||||
class _Verifier:
|
||||
def __init__(self) -> None:
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def verify(self, **kwargs):
|
||||
self.calls.append(kwargs)
|
||||
return True
|
||||
|
||||
|
||||
def _value(now: int = 1_700_000_000) -> dict:
|
||||
value = {
|
||||
"epoch_id": "owner-epoch-v2-test-20260926",
|
||||
"owner_identity": "aios-task-grant-signer",
|
||||
"trust_domain": "aios.owner",
|
||||
"repository": "aios/aios",
|
||||
"allowed_branch_prefixes": ["agent/p0-test"],
|
||||
"allowed_capabilities": ["git.read", "governed.write.file"],
|
||||
"allowed_file_prefixes": ["active-runtime/app/example.py"],
|
||||
"risk_ceiling": "engineering",
|
||||
"issued_at": now - 1,
|
||||
"not_before": now - 1,
|
||||
"expires_at": now + 600,
|
||||
"revocation_epoch": 0,
|
||||
"policy_digest": "a" * 64,
|
||||
"business_goal": "test",
|
||||
"production_scope": "staging",
|
||||
"destructive_policy": "forbidden",
|
||||
"signature_namespace": "aios-owner-approval-v1",
|
||||
"signature_key_id": "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/",
|
||||
"status": "ACTIVE",
|
||||
"schema": SCHEMA_EPOCH_V2,
|
||||
}
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return value
|
||||
|
||||
|
||||
def _provider(tmp_path, value: dict, now: int = 1_700_000_000):
|
||||
root = tmp_path / "epoch"
|
||||
root.mkdir(parents=True, mode=0o700)
|
||||
payload = canonical_json_bytes(value)
|
||||
(root / "owner-delegation-epoch.canonical.json").write_bytes(payload)
|
||||
(root / "owner-delegation-epoch.sig").write_text("sig", encoding="utf-8")
|
||||
(root / "owner-delegation-epoch.key-id").write_text(
|
||||
value.get("signature_key_id", "SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/"), encoding="utf-8"
|
||||
)
|
||||
revocations = tmp_path / "revocations.json"
|
||||
revocations.write_bytes(canonical_json_bytes({
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": [],
|
||||
}))
|
||||
for path in [*root.iterdir(), revocations]:
|
||||
path.chmod(0o600)
|
||||
verifier = _Verifier()
|
||||
provider = SignedEpochDirectoryProvider(
|
||||
root=root,
|
||||
verifier=verifier,
|
||||
revocation_file=revocations,
|
||||
expected_caller={"service": "test"},
|
||||
require_root_owner=False,
|
||||
clock=lambda: now,
|
||||
)
|
||||
return provider, verifier, root
|
||||
|
||||
|
||||
def test_v2_preserves_schema_digest_and_exact_signed_bytes(tmp_path):
|
||||
value = _value()
|
||||
provider, verifier, root = _provider(tmp_path, value)
|
||||
|
||||
epoch = provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
payload = (root / "owner-delegation-epoch.canonical.json").read_bytes()
|
||||
assert epoch.schema == SCHEMA_EPOCH_V2
|
||||
assert epoch.canonical_payload == payload
|
||||
assert epoch.canonical_payload_digest == value["canonical_payload_digest"]
|
||||
assert epoch.policy_digest == value["policy_digest"]
|
||||
assert verifier.calls[0]["payload"] == payload
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "replacement", "error"),
|
||||
[
|
||||
("status", "REVOKED", "TASK_ADMISSION_EPOCH_V2_BINDING_INVALID"),
|
||||
("signature_namespace", "wrong", "TASK_ADMISSION_EPOCH_V2_BINDING_INVALID"),
|
||||
("canonical_payload_digest", "0" * 64, "TASK_ADMISSION_EPOCH_PAYLOAD_DIGEST_INVALID"),
|
||||
("signature_key_id", "SHA256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB+", "TASK_ADMISSION_EPOCH_KEY_BINDING_INVALID"),
|
||||
],
|
||||
)
|
||||
def test_v2_binding_and_digest_fail_closed(tmp_path, field, replacement, error):
|
||||
value = _value()
|
||||
value[field] = replacement
|
||||
if field != "canonical_payload_digest":
|
||||
value["canonical_payload_digest"] = sha256_json(
|
||||
{k: v for k, v in value.items() if k != "canonical_payload_digest"}
|
||||
)
|
||||
provider, _, _ = _provider(tmp_path, value)
|
||||
if field == "signature_key_id":
|
||||
(provider.root / "owner-delegation-epoch.key-id").write_text(
|
||||
"SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(TaskAdmissionError, match=error):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
|
||||
def test_v2_rejects_missing_field_and_noncanonical_signed_bytes(tmp_path):
|
||||
value = _value()
|
||||
value.pop("business_goal")
|
||||
provider, _, _ = _provider(tmp_path, value)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_FIELDS_INVALID"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
value = _value()
|
||||
provider, _, root = _provider(tmp_path / "noncanonical", value)
|
||||
raw = json.dumps(value, indent=2).encode("utf-8")
|
||||
(root / "owner-delegation-epoch.canonical.json").write_bytes(raw)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_CANONICAL_MISMATCH"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
|
||||
def test_v2_rejects_expiry_and_revocation(tmp_path):
|
||||
value = _value(now=1_700_000_000)
|
||||
value["expires_at"] = 1_699_999_999
|
||||
value["canonical_payload_digest"] = sha256_json(
|
||||
{k: v for k, v in value.items() if k != "canonical_payload_digest"}
|
||||
)
|
||||
provider, _, _ = _provider(tmp_path, value)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_EXPIRED"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
value = _value()
|
||||
provider, _, _ = _provider(tmp_path / "revoked", value)
|
||||
(provider.revocation_file).write_bytes(canonical_json_bytes({
|
||||
"schema": "aios.owner-delegation-revocations.v1",
|
||||
"revoked_epoch_ids": [value["epoch_id"]],
|
||||
}))
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_REVOKED"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["allowed_branch_prefixes", "allowed_capabilities", "allowed_file_prefixes"])
|
||||
def test_v2_rejects_empty_scope(tmp_path, field):
|
||||
value = _value()
|
||||
value[field] = []
|
||||
value["canonical_payload_digest"] = sha256_json(
|
||||
{k: v for k, v in value.items() if k != "canonical_payload_digest"}
|
||||
)
|
||||
provider, _, _ = _provider(tmp_path, value)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_SCOPE_INVALID"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
|
||||
|
||||
def test_v2_rejects_unknown_destructive_policy(tmp_path):
|
||||
value = _value()
|
||||
value["destructive_policy"] = "deny"
|
||||
value["canonical_payload_digest"] = sha256_json(
|
||||
{k: v for k, v in value.items() if k != "canonical_payload_digest"}
|
||||
)
|
||||
provider, _, _ = _provider(tmp_path, value)
|
||||
with pytest.raises(TaskAdmissionError, match="TASK_ADMISSION_EPOCH_V2_BINDING_INVALID"):
|
||||
provider(epoch_id=value["epoch_id"], caller={"service": "test"})
|
||||
@@ -1,7 +1,30 @@
|
||||
from pathlib import Path
|
||||
import json, subprocess, sys
|
||||
import json, os, subprocess, sys
|
||||
|
||||
def test_trusted_builder_emits_plan_bound_payload(tmp_path):
|
||||
REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
SCRIPT = REPO_ROOT / "scripts/autonomous-release/build_trusted_release_artifact.py"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RE-BIND REQUIRED after the governed true-merge of PR #88 lands on main.
|
||||
# Set both constants to the exact merged main commit and its tree id, e.g.
|
||||
# git -C <authority-repo> rev-parse refs/heads/main
|
||||
# git -C <authority-repo> rev-parse 'refs/heads/main^{tree}'
|
||||
# Until re-bound, the values below are all-zero placeholders: the canary then
|
||||
# proves only that the successor-aware producer is wired, NOT that it is bound
|
||||
# to the merged source. Do not treat an all-zero run as a live binding proof.
|
||||
# ---------------------------------------------------------------------------
|
||||
MERGED_SOURCE_OID = "0" * 40 # PLACEHOLDER - merged source commit (40 hex)
|
||||
MERGED_TREE_OID = "0" * 40 # PLACEHOLDER - merged source tree oid (40 hex)
|
||||
|
||||
|
||||
def _env_with_pythonpath() -> dict:
|
||||
env = dict(os.environ)
|
||||
root = str(REPO_ROOT)
|
||||
env["PYTHONPATH"] = root + os.pathsep + env.get("PYTHONPATH", "")
|
||||
return env
|
||||
|
||||
|
||||
def _make_source(tmp_path) -> Path:
|
||||
source = tmp_path / "source"; source.mkdir()
|
||||
files = {
|
||||
"active-runtime/app/deployment_state.py": "x\n",
|
||||
@@ -11,13 +34,54 @@ def test_trusted_builder_emits_plan_bound_payload(tmp_path):
|
||||
}
|
||||
for rel, text in files.items():
|
||||
path = source / rel; path.parent.mkdir(parents=True, exist_ok=True); path.write_text(text)
|
||||
return source
|
||||
|
||||
|
||||
def test_trusted_builder_emits_plan_bound_payload(tmp_path):
|
||||
source = _make_source(tmp_path)
|
||||
output = tmp_path / "release.tar"
|
||||
script = Path(__file__).resolve().parents[1] / "scripts/autonomous-release/build_trusted_release_artifact.py"
|
||||
result = subprocess.run([sys.executable, str(script), "--source", str(source), "--output", str(output),
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), "--source", str(source), "--output", str(output),
|
||||
"--version", "V2R2C2", "--source-oid", "1" * 40, "--tree-oid", "2" * 40,
|
||||
"--rollback-version", "V2R2C1", "--rollback-target-oid", "3" * 40,
|
||||
"--rollback-artifact-sha256", "4" * 64, "--service", "aios-v2r2c2-production.service", "--port", "18928"],
|
||||
check=True, capture_output=True, text=True)
|
||||
check=True, capture_output=True, text=True, env=_env_with_pythonpath())
|
||||
receipt = json.loads(result.stdout)
|
||||
assert receipt["source_commit"] == "1" * 40
|
||||
assert output.is_file() and receipt["artifact_size"] == output.stat().st_size
|
||||
|
||||
|
||||
def test_successor_aware_binds_v2r2c3_live_binding_converge(tmp_path):
|
||||
"""Successor-scoped fixture: the producer must bind the V2R2C3-LIVE-BINDING-CONVERGE
|
||||
successor to its exact source_commit/tree_oid (no V2R2C2-only gate in source)."""
|
||||
source = _make_source(tmp_path)
|
||||
output = tmp_path / "v2r2c3.tar"
|
||||
result = subprocess.run([sys.executable, str(SCRIPT), "--source", str(source), "--output", str(output),
|
||||
"--version", "V2R2C3-LIVE-BINDING-CONVERGE",
|
||||
"--source-oid", MERGED_SOURCE_OID,
|
||||
"--tree-oid", MERGED_TREE_OID,
|
||||
"--rollback-version", "V2R2C2",
|
||||
"--rollback-target-oid", "5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
"--rollback-artifact-sha256", "43f12a14a248bea726dd9793587413c7e05d5c7cfd8704e1cabcc7d1d3214013",
|
||||
"--service", "aios-gitops-model-api-v2r2c3-round3-release-control.service", "--port", "18933"],
|
||||
check=True, capture_output=True, text=True, env=_env_with_pythonpath())
|
||||
receipt = json.loads(result.stdout)
|
||||
assert receipt["version"] == "V2R2C3-LIVE-BINDING-CONVERGE"
|
||||
assert receipt["source_commit"] == MERGED_SOURCE_OID
|
||||
assert receipt["tree_oid"] == MERGED_TREE_OID
|
||||
assert output.is_file() and receipt["artifact_size"] == output.stat().st_size
|
||||
|
||||
|
||||
def test_successor_aware_rejects_rollback_equal_version(tmp_path):
|
||||
"""Fail-closed safety invariant preserved: rollback must never equal the payload version."""
|
||||
source = _make_source(tmp_path)
|
||||
for out in ("x.tar", "y.tar"):
|
||||
broken = subprocess.run([sys.executable, str(SCRIPT), "--source", str(source), "--output", str(tmp_path / out),
|
||||
"--version", "V2R2C3-LIVE-BINDING-CONVERGE",
|
||||
"--source-oid", MERGED_SOURCE_OID,
|
||||
"--tree-oid", MERGED_TREE_OID,
|
||||
"--rollback-version", "V2R2C3-LIVE-BINDING-CONVERGE",
|
||||
"--rollback-target-oid", "5cf20128d8be2d10aef87b09f8f7a2aecb2000f3",
|
||||
"--rollback-artifact-sha256", "43f12a14a248bea726dd9793587413c7e05d5c7cfd8704e1cabcc7d1d3214013",
|
||||
"--service", "x.service", "--port", "18933"],
|
||||
capture_output=True, text=True, env=_env_with_pythonpath())
|
||||
assert broken.returncode != 0 and "release_version_binding_invalid" in (broken.stderr + broken.stdout)
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
import pytest
|
||||
|
||||
from aios_capability_contract import (
|
||||
has_capabilities,
|
||||
normalize_required_capabilities,
|
||||
normalize_worker_capabilities,
|
||||
)
|
||||
from aios_agent_task_protocol import TaskProtocolError, TaskStore
|
||||
|
||||
|
||||
def test_n01_unknown_required_token_is_explicit_and_denied():
|
||||
assert normalize_required_capabilities(["unknown-token"]) == frozenset({"exact:unknown-token"})
|
||||
assert has_capabilities(["testing"], ["unknown-token"]) is False
|
||||
|
||||
|
||||
def test_n02_unknown_worker_token_is_never_granted():
|
||||
assert "exact:unknown-token" not in normalize_worker_capabilities(["unknown-token"])
|
||||
assert has_capabilities(["unknown-token"], ["testing"]) is False
|
||||
|
||||
|
||||
def test_n03_admitted_capability_and_claim_match_use_same_contract():
|
||||
assert has_capabilities(["git.read"], ["repository_read"]) is True
|
||||
assert has_capabilities(["git.read"], ["testing"]) is False
|
||||
|
||||
|
||||
def test_n01_task_store_rejects_unknown_before_queue_insert(tmp_path):
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3")
|
||||
with pytest.raises(TaskProtocolError) as exc:
|
||||
store.create_task(
|
||||
task_id="task-unknown",
|
||||
goal="unknown",
|
||||
instructions="unknown",
|
||||
success_criteria=[],
|
||||
required_capabilities=["unknown-token"],
|
||||
idempotency_key="idempotency-unknown",
|
||||
)
|
||||
assert exc.value.code == "TASK_CAPABILITY_UNKNOWN"
|
||||
with store._connect() as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM tasks").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_n02_existing_unknown_requirement_is_denied_by_poll_and_claim(tmp_path):
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3")
|
||||
store.register_agent(
|
||||
agent_id="agent-old-row",
|
||||
agent_type="test",
|
||||
agent_version="1",
|
||||
capabilities=["testing"],
|
||||
)
|
||||
task = store.create_task(
|
||||
task_id="task-old-row",
|
||||
goal="old",
|
||||
instructions="old",
|
||||
success_criteria=[],
|
||||
required_capabilities=["testing"],
|
||||
idempotency_key="idempotency-old-row",
|
||||
)
|
||||
with store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE tasks SET required_capabilities_json=? WHERE task_id=?",
|
||||
('["unknown-token"]', task["task_id"]),
|
||||
)
|
||||
assert store.poll_tasks(agent_id="agent-old-row") == []
|
||||
with pytest.raises(TaskProtocolError) as exc:
|
||||
store.claim_task(task_id=task["task_id"], agent_id="agent-old-row")
|
||||
assert exc.value.code == "AGENT_CAPABILITY_MISMATCH"
|
||||
|
||||
|
||||
def test_n03_atomic_claim_denies_admitted_requirement_worker_drift(tmp_path):
|
||||
store = TaskStore(tmp_path / "tasks.sqlite3")
|
||||
store.register_agent(
|
||||
agent_id="agent-drift",
|
||||
agent_type="test",
|
||||
agent_version="1",
|
||||
capabilities=["git.read"],
|
||||
)
|
||||
task = store.create_task(
|
||||
task_id="task-drift",
|
||||
goal="drift",
|
||||
instructions="drift",
|
||||
success_criteria=[],
|
||||
required_capabilities=["testing"],
|
||||
idempotency_key="idempotency-drift",
|
||||
)
|
||||
with pytest.raises(TaskProtocolError) as exc:
|
||||
store.claim_task(task_id=task["task_id"], agent_id="agent-drift")
|
||||
assert exc.value.code == "AGENT_CAPABILITY_MISMATCH"
|
||||
assert store.get_task(task["task_id"])["status"] == "QUEUED"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# C5 production.* capability family (V2R2C5 / formal-lineage-canonical-v17)
|
||||
# ---------------------------------------------------------------------------
|
||||
# The full 17-cap set of the provisioned C5 promotion worker
|
||||
# (agent/c5-promotion-exec-20260929).
|
||||
C5_WORKER17 = [
|
||||
"agent-task:control",
|
||||
"code.read",
|
||||
"code.write",
|
||||
"git.read",
|
||||
"git.test",
|
||||
"governed.task.create",
|
||||
"governed.write.branch",
|
||||
"governed.write.file",
|
||||
"governed.write.files",
|
||||
"production.plan.apply",
|
||||
"production.promote",
|
||||
"production.rollback",
|
||||
"task.principal.issue",
|
||||
"test.execute",
|
||||
"testing",
|
||||
"verification",
|
||||
"writer.lease",
|
||||
]
|
||||
|
||||
|
||||
def test_c5_01_production_required_normalization_recognized():
|
||||
# production.* must NOT be tagged as unknown (exact:prefix) after patch.
|
||||
required = normalize_required_capabilities(["production.promote"])
|
||||
assert required == frozenset({"production.promote"})
|
||||
unknown = [c for c in normalize_required_capabilities(C5_WORKER17) if c.startswith("exact:")]
|
||||
assert unknown == []
|
||||
|
||||
|
||||
def test_c5_02_production_worker_normalization_recognized():
|
||||
effective = normalize_worker_capabilities(C5_WORKER17)
|
||||
assert {"production.plan.apply", "production.promote", "production.rollback"}.issubset(effective)
|
||||
assert len(effective) >= 17
|
||||
|
||||
|
||||
def test_c5_03_has_capabilities_full17_is_true():
|
||||
# Previously raw claim returned 403 AGENT_CAPABILITY_MISMATCH; patched contract must be True.
|
||||
assert has_capabilities(C5_WORKER17, C5_WORKER17) is True
|
||||
|
||||
|
||||
def test_c5_04_unknown_capability_still_fail_closed():
|
||||
assert has_capabilities(C5_WORKER17, ["production.promote", "unknown-cap"]) is False
|
||||
assert normalize_required_capabilities(["unknown-cap"]) == frozenset({"exact:unknown-cap"})
|
||||
|
||||
|
||||
def test_c5_05_wildcard_still_rejected():
|
||||
assert has_capabilities(["production.*"], ["production.promote"]) is False
|
||||
assert normalize_required_capabilities(["production.*"]) == frozenset({"exact:production.*"})
|
||||
|
||||
|
||||
def test_c5_06_missing_production_still_denied():
|
||||
# A worker WITHOUT production.* must not be able to claim a production task.
|
||||
worker_no_prod = [c for c in C5_WORKER17 if not c.startswith("production.")]
|
||||
assert has_capabilities(worker_no_prod, ["production.promote"]) is False
|
||||
Reference in New Issue
Block a user